@aexol/spectral 0.9.127 → 0.9.128

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 (27) hide show
  1. package/dist/sdk/agent-core/agent-loop.js +1 -1
  2. package/dist/sdk/coding-agent/core/extensions/runner.d.ts.map +1 -1
  3. package/dist/sdk/coding-agent/core/extensions/runner.js +15 -1
  4. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts +7 -2
  5. package/dist/sdk/coding-agent/core/tools/apply-patch.d.ts.map +1 -1
  6. package/dist/sdk/coding-agent/core/tools/apply-patch.js +219 -41
  7. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts +30 -0
  8. package/dist/sdk/coding-agent/core/tools/atomic-write.d.ts.map +1 -0
  9. package/dist/sdk/coding-agent/core/tools/atomic-write.js +42 -0
  10. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts +10 -4
  11. package/dist/sdk/coding-agent/core/tools/edit-diff.d.ts.map +1 -1
  12. package/dist/sdk/coding-agent/core/tools/edit-diff.js +44 -24
  13. package/dist/sdk/coding-agent/core/tools/edit.d.ts +6 -0
  14. package/dist/sdk/coding-agent/core/tools/edit.d.ts.map +1 -1
  15. package/dist/sdk/coding-agent/core/tools/edit.js +31 -5
  16. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts +9 -3
  17. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.d.ts.map +1 -1
  18. package/dist/sdk/coding-agent/core/tools/file-mutation-queue.js +42 -3
  19. package/dist/sdk/coding-agent/core/tools/read.d.ts.map +1 -1
  20. package/dist/sdk/coding-agent/core/tools/read.js +79 -47
  21. package/dist/sdk/coding-agent/core/tools/truncate.d.ts +4 -2
  22. package/dist/sdk/coding-agent/core/tools/truncate.d.ts.map +1 -1
  23. package/dist/sdk/coding-agent/core/tools/truncate.js +43 -23
  24. package/dist/sdk/coding-agent/core/tools/write.d.ts +5 -8
  25. package/dist/sdk/coding-agent/core/tools/write.d.ts.map +1 -1
  26. package/dist/sdk/coding-agent/core/tools/write.js +29 -11
  27. package/package.json +1 -1
@@ -7,13 +7,21 @@ import { constants } from "fs";
7
7
  import { access, readFile } from "fs/promises";
8
8
  import { resolveToCwd } from "./path-utils.js";
9
9
  export function detectLineEnding(content) {
10
- const crlfIdx = content.indexOf("\r\n");
11
- const lfIdx = content.indexOf("\n");
12
- if (lfIdx === -1)
13
- return "\n";
14
- if (crlfIdx === -1)
10
+ let crlfCount = 0;
11
+ let lfCount = 0;
12
+ for (let i = 0; i < content.length; i++) {
13
+ if (content[i] !== "\n")
14
+ continue;
15
+ if (i > 0 && content[i - 1] === "\r") {
16
+ crlfCount++;
17
+ }
18
+ else {
19
+ lfCount++;
20
+ }
21
+ }
22
+ if (crlfCount === 0 && lfCount === 0)
15
23
  return "\n";
16
- return crlfIdx < lfIdx ? "\r\n" : "\n";
24
+ return crlfCount > lfCount ? "\r\n" : "\n";
17
25
  }
18
26
  export function normalizeToLF(text) {
19
27
  return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
@@ -22,31 +30,26 @@ export function restoreLineEndings(text, ending) {
22
30
  return ending === "\r\n" ? text.replace(/\n/g, "\r\n") : text;
23
31
  }
24
32
  /**
25
- * Normalize text for fuzzy matching. Applies progressive transformations:
33
+ * Normalize text for fuzzy matching. Applies only semantically-safe
34
+ * whitespace/visibility transformations:
26
35
  * - Strip trailing whitespace from each line
27
- * - Normalize smart quotes to ASCII equivalents
28
- * - Normalize Unicode dashes/hyphens to ASCII hyphen
29
- * - Normalize special Unicode spaces to regular space
36
+ * - Normalize special Unicode spaces to a regular space
37
+ *
38
+ * Intentionally does NOT perform NFKC compatibility decomposition, smart-quote
39
+ * → straight-quote, or dash/hyphen substitutions: those transformations make
40
+ * semantically different code match silently. Matching across those characters
41
+ * requires the oldText to use the exact same characters as the file.
30
42
  */
31
43
  export function normalizeForFuzzyMatch(text) {
32
- return (text
33
- .normalize("NFKC")
34
- // Strip trailing whitespace per line
44
+ return text
45
+ // Strip trailing whitespace per line (tab/space visibility tolerance)
35
46
  .split("\n")
36
47
  .map((line) => line.trimEnd())
37
48
  .join("\n")
38
- // Smart single quotes '
39
- .replace(/[\u2018\u2019\u201A\u201B]/g, "'")
40
- // Smart double quotes → "
41
- .replace(/[\u201C\u201D\u201E\u201F]/g, '"')
42
- // Various dashes/hyphens → -
43
- // U+2010 hyphen, U+2011 non-breaking hyphen, U+2012 figure dash,
44
- // U+2013 en-dash, U+2014 em-dash, U+2015 horizontal bar, U+2212 minus
45
- .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, "-")
46
- // Special spaces → regular space
49
+ // Special spacesregular space (whitespace-equivalence only)
47
50
  // U+00A0 NBSP, U+2002-U+200A various spaces, U+202F narrow NBSP,
48
51
  // U+205F medium math space, U+3000 ideographic space
49
- .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " "));
52
+ .replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ");
50
53
  }
51
54
  function findExactMatches(content, oldText) {
52
55
  const matches = [];
@@ -194,6 +197,12 @@ function findLineTrimmedMatches(content, oldText) {
194
197
  }
195
198
  return dedupeMatches(matches);
196
199
  }
200
+ /**
201
+ * Files at or above this size skip the O(n) normalized index map. Line-trimmed
202
+ * matching (which already covers tab/space/CRLF tolerance) is still attempted;
203
+ * only the costly normalized pass is skipped for large files.
204
+ */
205
+ const NORMALIZED_MATCH_MAX_BYTES = 256 * 1024; // 256KB
197
206
  function findTextMatches(content, oldText) {
198
207
  const exactMatches = findExactMatches(content, oldText);
199
208
  if (exactMatches.length > 0)
@@ -201,6 +210,8 @@ function findTextMatches(content, oldText) {
201
210
  const lineTrimmedMatches = findLineTrimmedMatches(content, oldText);
202
211
  if (lineTrimmedMatches.length > 0)
203
212
  return lineTrimmedMatches;
213
+ if (content.length >= NORMALIZED_MATCH_MAX_BYTES)
214
+ return [];
204
215
  return findNormalizedTextMatches(content, oldText);
205
216
  }
206
217
  export function fuzzyFindText(content, oldText) {
@@ -240,8 +251,14 @@ function adaptIndentationFlexibleReplacement(oldText, newText, matchedText) {
240
251
  const matchedBaseIndent = leadingWhitespace(matchedBaseLine);
241
252
  if (oldBaseIndent === matchedBaseIndent)
242
253
  return newText;
254
+ // When oldText/matchedText/newText have differing line counts, only adapt the
255
+ // common-length prefix; leave the remaining newText lines untouched so a
256
+ // divergent tail is not corrupted by fabricated base-indent prefixes.
257
+ const commonLength = Math.min(oldLines.length, matchedLines.length, newLines.length);
243
258
  return newLines
244
259
  .map((line, index) => {
260
+ if (index >= commonLength)
261
+ return line;
245
262
  if (line.trim().length === 0)
246
263
  return line;
247
264
  const oldLine = oldLines[index];
@@ -312,6 +329,7 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
312
329
  }
313
330
  const baseContent = normalizedContent;
314
331
  const matchedEdits = [];
332
+ let usedFuzzyMatch = false;
315
333
  for (let i = 0; i < normalizedEdits.length; i++) {
316
334
  const edit = normalizedEdits[i];
317
335
  const matches = findTextMatches(baseContent, edit.oldText);
@@ -322,6 +340,8 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
322
340
  throw getDuplicateError(path, i, normalizedEdits.length, matches.length);
323
341
  }
324
342
  const match = matches[0];
343
+ if (match.strategy !== "exact")
344
+ usedFuzzyMatch = true;
325
345
  matchedEdits.push({
326
346
  editIndex: i,
327
347
  matchIndex: match.index,
@@ -348,7 +368,7 @@ export function applyEditsToNormalizedContent(normalizedContent, edits, path) {
348
368
  if (baseContent === newContent) {
349
369
  throw getNoChangeError(path, normalizedEdits.length);
350
370
  }
351
- return { baseContent, newContent };
371
+ return { baseContent, newContent, usedFuzzyMatch };
352
372
  }
353
373
  /** Generate a standard unified patch. */
354
374
  export function generateUnifiedPatch(path, oldContent, newContent, contextLines = 4) {
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool } from "../../../agent-core/index.js";
2
+ import type { Stats } from "fs";
2
3
  import { type Static, Type } from "typebox";
3
4
  import type { ToolDefinition } from "../extensions/types.js";
4
5
  declare const editSchema: Type.TObject<{
@@ -28,6 +29,11 @@ export interface EditOperations {
28
29
  writeFile: (absolutePath: string, content: string) => Promise<void>;
29
30
  /** Check if file is readable and writable (throw if not) */
30
31
  access: (absolutePath: string) => Promise<void>;
32
+ /**
33
+ * Stat a file to obtain a freshness token (mtime). Used to detect stale reads
34
+ * between read and write. Optional — defaults to local fs.stat.
35
+ */
36
+ stat?: (absolutePath: string) => Promise<Stats>;
31
37
  }
32
38
  export interface EditToolOptions {
33
39
  /** Custom operations for file editing. Default: local filesystem */
@@ -1 +1 @@
1
- {"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/edit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AA6B7D,QAAA,MAAM,UAAU;;;;;;EASf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAMtD,MAAM,WAAW,eAAe;IAC/B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,8BAA8B;IAC9B,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,4DAA4D;IAC5D,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAoFD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CAsIhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
1
+ {"version":3,"file":"edit.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/edit.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,IAAI,CAAC;AAChC,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AA8B7D,QAAA,MAAM,UAAU;;;;;;EASf,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAMtD,MAAM,WAAW,eAAe;IAC/B,gDAAgD;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,8BAA8B;IAC9B,SAAS,EAAE,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,4DAA4D;IAC5D,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD;;;OAGG;IACH,IAAI,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC;CAChD;AASD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAqGD,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CAyJhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
@@ -1,8 +1,9 @@
1
1
  import { constants } from "fs";
2
- import { access as fsAccess, readFile as fsReadFile, writeFile as fsWriteFile } from "fs/promises";
2
+ import { access as fsAccess, readFile as fsReadFile, stat as fsStat, writeFile as fsWriteFile } from "fs/promises";
3
3
  import { Type } from "typebox";
4
4
  import { renderDiff } from "./render-diff.js";
5
5
  import { applyEditsToNormalizedContent, detectLineEnding, generateDiffString, generateUnifiedPatch, normalizeToLF, restoreLineEndings, stripBom, } from "./edit-diff.js";
6
+ import { atomicWriteFile } from "./atomic-write.js";
6
7
  import { withFileMutationQueue } from "./file-mutation-queue.js";
7
8
  import { resolveToCwd } from "./path-utils.js";
8
9
  import { shortenPath, str } from "./render-utils.js";
@@ -23,6 +24,7 @@ const defaultEditOperations = {
23
24
  readFile: (path) => fsReadFile(path),
24
25
  writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
25
26
  access: (path) => fsAccess(path, constants.R_OK | constants.W_OK),
27
+ stat: (path) => fsStat(path),
26
28
  };
27
29
  function prepareEditArguments(input) {
28
30
  if (!input || typeof input !== "object") {
@@ -36,7 +38,10 @@ function prepareEditArguments(input) {
36
38
  if (Array.isArray(parsed))
37
39
  args.edits = parsed;
38
40
  }
39
- catch { }
41
+ catch (error) {
42
+ const reason = error instanceof Error ? error.message : String(error);
43
+ throw new Error(`Invalid "edits" argument for edit tool: expected an array but received a string that could not be parsed as JSON. Parse error: ${reason}`);
44
+ }
40
45
  }
41
46
  const legacy = args;
42
47
  if (typeof legacy.oldText !== "string" || typeof legacy.newText !== "string") {
@@ -51,6 +56,14 @@ function validateEditInput(input) {
51
56
  if (!Array.isArray(input.edits) || input.edits.length === 0) {
52
57
  throw new Error("Edit tool input is invalid. edits must contain at least one replacement.");
53
58
  }
59
+ for (const edit of input.edits) {
60
+ if (!edit ||
61
+ typeof edit !== "object" ||
62
+ typeof edit.oldText !== "string" ||
63
+ typeof edit.newText !== "string") {
64
+ throw new Error("Edit tool input is invalid. Each edit must have string oldText and newText properties.");
65
+ }
66
+ }
54
67
  return { path: input.path, edits: input.edits };
55
68
  }
56
69
  function formatEditCall(args, _theme) {
@@ -136,6 +149,8 @@ export function createEditToolDefinition(cwd, options) {
136
149
  // Read the file.
137
150
  const buffer = await ops.readFile(absolutePath);
138
151
  const rawContent = buffer.toString("utf-8");
152
+ // Capture a freshness token so we can detect stale reads before writing.
153
+ const freshnessTokenBefore = ops.stat ? (await ops.stat(absolutePath)).mtimeMs : undefined;
139
154
  // Check if aborted after reading.
140
155
  if (aborted) {
141
156
  return;
@@ -144,13 +159,24 @@ export function createEditToolDefinition(cwd, options) {
144
159
  const { bom, text: content } = stripBom(rawContent);
145
160
  const originalEnding = detectLineEnding(content);
146
161
  const normalizedContent = normalizeToLF(content);
147
- const { baseContent, newContent } = applyEditsToNormalizedContent(normalizedContent, edits, path);
162
+ const { baseContent, newContent, usedFuzzyMatch } = applyEditsToNormalizedContent(normalizedContent, edits, path);
148
163
  // Check if aborted before writing.
149
164
  if (aborted) {
150
165
  return;
151
166
  }
167
+ // Detect stale file: re-check freshness token before writing.
168
+ if (freshnessTokenBefore !== undefined && ops.stat) {
169
+ const freshnessTokenAfter = (await ops.stat(absolutePath)).mtimeMs;
170
+ if (freshnessTokenAfter !== freshnessTokenBefore) {
171
+ if (signal) {
172
+ signal.removeEventListener("abort", onAbort);
173
+ }
174
+ reject(new Error(`File ${path} was modified since read. Re-read the file and retry your edit.`));
175
+ return;
176
+ }
177
+ }
152
178
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
153
- await ops.writeFile(absolutePath, finalContent);
179
+ await atomicWriteFile(absolutePath, finalContent);
154
180
  // Check if aborted after writing.
155
181
  if (aborted) {
156
182
  return;
@@ -165,7 +191,7 @@ export function createEditToolDefinition(cwd, options) {
165
191
  content: [
166
192
  {
167
193
  type: "text",
168
- text: `Successfully replaced ${edits.length} block(s) in ${path}.`,
194
+ text: `Successfully replaced ${edits.length} block(s) in ${path}.${usedFuzzyMatch ? " (used tolerant matching — verify the change)" : ""}`,
169
195
  },
170
196
  ],
171
197
  details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Serialize file mutation operations targeting the same file.
3
- * Operations for different files still run in parallel.
2
+ * Run `fn` while holding the per-file mutation lock for `filePath`.
3
+ *
4
+ * `filePath` may be a single path or an array of paths. For an array, each
5
+ * file gets its own per-file queue key (NOT a joined blob — a joined key would
6
+ * fail to serialize against single-path locks on the same file). Keys are
7
+ * sorted and acquired in a fixed order to avoid deadlock between two array
8
+ * locks that share a subset of files. The callback runs only once all locks
9
+ * are held and every lock is released in the `finally` block.
4
10
  */
5
- export declare function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T>;
11
+ export declare function withFileMutationQueue<T>(filePath: string | string[], fn: () => Promise<T>): Promise<T>;
6
12
  //# sourceMappingURL=file-mutation-queue.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"file-mutation-queue.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/file-mutation-queue.ts"],"names":[],"mappings":"AAcA;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,CAoBjG"}
1
+ {"version":3,"file":"file-mutation-queue.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/file-mutation-queue.ts"],"names":[],"mappings":"AAqBA;;;;;;;;;GASG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,EAC5C,QAAQ,EAAE,MAAM,GAAG,MAAM,EAAE,EAC3B,EAAE,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GAClB,OAAO,CAAC,CAAC,CAAC,CA0BZ"}
@@ -1,6 +1,13 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  const fileMutationQueues = new Map();
4
+ /**
5
+ * Derive a stable per-file queue key. Tries realpath first (canonicalizes
6
+ * symlinks and existing files); falls back to the resolved path for files that
7
+ * do not exist yet (e.g. Add File targets). The fallback is essential —
8
+ * realpathSync throws ENOENT for non-existent files, which would otherwise
9
+ * produce a different key before vs after creation.
10
+ */
4
11
  function getMutationQueueKey(filePath) {
5
12
  const resolvedPath = resolve(filePath);
6
13
  try {
@@ -11,11 +18,43 @@ function getMutationQueueKey(filePath) {
11
18
  }
12
19
  }
13
20
  /**
14
- * Serialize file mutation operations targeting the same file.
15
- * Operations for different files still run in parallel.
21
+ * Run `fn` while holding the per-file mutation lock for `filePath`.
22
+ *
23
+ * `filePath` may be a single path or an array of paths. For an array, each
24
+ * file gets its own per-file queue key (NOT a joined blob — a joined key would
25
+ * fail to serialize against single-path locks on the same file). Keys are
26
+ * sorted and acquired in a fixed order to avoid deadlock between two array
27
+ * locks that share a subset of files. The callback runs only once all locks
28
+ * are held and every lock is released in the `finally` block.
16
29
  */
17
30
  export async function withFileMutationQueue(filePath, fn) {
18
- const key = getMutationQueueKey(filePath);
31
+ const paths = Array.isArray(filePath) ? filePath : [filePath];
32
+ // Empty/empty-array: no locks to acquire, just run the callback.
33
+ if (paths.length === 0) {
34
+ return fn();
35
+ }
36
+ // Dedupe and sort per-file keys. Sorting gives a stable acquisition order so
37
+ // two overlapping array locks cannot deadlock each other.
38
+ const keys = Array.from(new Set(paths.map((p) => getMutationQueueKey(p)))).sort();
39
+ // For a single unique key, use the original single-path fast path.
40
+ if (keys.length === 1) {
41
+ return withSingleFileMutationQueue(keys[0], fn);
42
+ }
43
+ // For multiple keys, chain each lock. Each step waits on the previous
44
+ // queue before acquiring the next, so the callback runs only once every
45
+ // per-file lock is held.
46
+ let chain = fn;
47
+ for (const key of keys) {
48
+ const next = chain;
49
+ chain = () => withSingleFileMutationQueue(key, next);
50
+ }
51
+ return chain();
52
+ }
53
+ /**
54
+ * Acquire a single per-file queue, run `fn`, then release. This is the core
55
+ * lock primitive shared by the single-path and array paths above.
56
+ */
57
+ async function withSingleFileMutationQueue(key, fn) {
19
58
  const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
20
59
  let releaseNext;
21
60
  const nextQueue = new Promise((resolveQueue) => {
@@ -1 +1 @@
1
- {"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/read.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAEtH,QAAA,MAAM,UAAU;;;;EAId,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AASD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,+CAA+C;IAC/C,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,sEAAsE;IACtE,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnF;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAkID,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CA+IhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
1
+ {"version":3,"file":"read.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/read.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAI9D,OAAO,EAAE,KAAK,MAAM,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AAM5C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAI7D,OAAO,EAAoD,KAAK,gBAAgB,EAAgB,MAAM,eAAe,CAAC;AAEtH,QAAA,MAAM,UAAU;;;;EAId,CAAC;AAEH,MAAM,MAAM,aAAa,GAAG,MAAM,CAAC,OAAO,UAAU,CAAC,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC/B,UAAU,CAAC,EAAE,gBAAgB,CAAC;CAC9B;AASD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC9B,qCAAqC;IACrC,QAAQ,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACpD,+CAA+C;IAC/C,MAAM,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAChD,sEAAsE;IACtE,mBAAmB,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,CAAC;CACnF;AAQD,MAAM,WAAW,eAAe;IAC/B,oEAAoE;IACpE,gBAAgB,CAAC,EAAE,OAAO,CAAC;IAC3B,oEAAoE;IACpE,UAAU,CAAC,EAAE,cAAc,CAAC;CAC5B;AAkID,wBAAgB,wBAAwB,CACvC,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE,eAAe,GACvB,cAAc,CAAC,OAAO,UAAU,EAAE,eAAe,GAAG,SAAS,CAAC,CA8KhE;AAED,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,eAAe,GAAG,SAAS,CAAC,OAAO,UAAU,CAAC,CAEnG"}
@@ -131,6 +131,13 @@ export function createReadToolDefinition(cwd, options) {
131
131
  parameters: readSchema,
132
132
  async execute(_toolCallId, { path, offset, limit }, signal, _onUpdate, ctx) {
133
133
  const absolutePath = resolveReadPath(path, cwd);
134
+ // Validate offset/limit before doing any I/O. Distinguish undefined (unset) from 0.
135
+ if (offset !== undefined && offset <= 0) {
136
+ throw new Error("offset must be ≥ 1");
137
+ }
138
+ if (limit !== undefined && limit <= 0) {
139
+ throw new Error("limit must be ≥ 1");
140
+ }
134
141
  return new Promise((resolve, reject) => {
135
142
  if (signal?.aborted) {
136
143
  reject(new Error("Operation aborted"));
@@ -199,60 +206,85 @@ export function createReadToolDefinition(cwd, options) {
199
206
  else {
200
207
  // Read text content.
201
208
  const buffer = await ops.readFile(absolutePath);
202
- const textContent = buffer.toString("utf-8");
203
- const allLines = textContent.split("\n");
204
- const totalFileLines = allLines.length;
205
- // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
206
- const startLine = offset ? Math.max(0, offset - 1) : 0;
207
- const startLineDisplay = startLine + 1;
208
- // Check if offset is out of bounds.
209
- if (startLine >= allLines.length) {
210
- throw new Error(`Offset ${offset} is beyond end of file (${allLines.length} lines total)`);
209
+ // Binary file detection: scan the first chunk for NUL bytes.
210
+ // Legitimate UTF-8 (including emoji and CJK) never contains NUL bytes, so
211
+ // a NUL byte is a strong signal that this is a non-text binary (executable,
212
+ // archive, etc.). Return a note instead of decoding garbage UTF-8.
213
+ const binaryScanLen = Math.min(buffer.length, 8192);
214
+ let isBinary = false;
215
+ for (let i = 0; i < binaryScanLen; i++) {
216
+ if (buffer[i] === 0x00) {
217
+ isBinary = true;
218
+ break;
219
+ }
211
220
  }
212
- let selectedContent;
213
- let userLimitedLines;
214
- // If limit is specified by the user, honor it first. Otherwise truncateHead decides.
215
- if (limit !== undefined) {
216
- const endLine = Math.min(startLine + limit, allLines.length);
217
- selectedContent = allLines.slice(startLine, endLine).join("\n");
218
- userLimitedLines = endLine - startLine;
221
+ if (isBinary) {
222
+ content = [
223
+ { type: "text", text: `[Binary file, ${formatSize(buffer.length)}, not displayed]` },
224
+ ];
219
225
  }
220
226
  else {
221
- selectedContent = allLines.slice(startLine).join("\n");
222
- }
223
- // Apply truncation, respecting both line and byte limits.
224
- const truncation = truncateHead(selectedContent);
225
- let outputText;
226
- if (truncation.firstLineExceedsLimit) {
227
- // First line alone exceeds the byte limit. Point the model at a bash fallback.
228
- const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
229
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
230
- details = { truncation };
231
- }
232
- else if (truncation.truncated) {
233
- // Truncation occurred. Build an actionable continuation notice.
234
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
235
- const nextOffset = endLineDisplay + 1;
236
- outputText = truncation.content;
237
- if (truncation.truncatedBy === "lines") {
238
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
227
+ const textContent = buffer.toString("utf-8");
228
+ let allLines = textContent.split("\n");
229
+ // A trailing newline produces an extra empty element on split; drop it so the
230
+ // reported line count and all offset/limit math match the real content.
231
+ const totalFileLines = textContent.endsWith("\n") ? allLines.length - 1 : allLines.length;
232
+ if (textContent.endsWith("\n")) {
233
+ allLines = allLines.slice(0, -1);
234
+ }
235
+ // Apply offset if specified. Convert from 1-indexed input to 0-indexed array access.
236
+ // Use explicit undefined check so offset=0 is rejected above rather than treated as "no offset".
237
+ const startLine = offset !== undefined ? Math.max(0, offset - 1) : 0;
238
+ const startLineDisplay = startLine + 1;
239
+ // Check if offset is out of bounds.
240
+ if (startLine >= allLines.length) {
241
+ throw new Error(`Offset ${offset} is beyond end of file (${totalFileLines} lines total)`);
242
+ }
243
+ let selectedContent;
244
+ let userLimitedLines;
245
+ // If limit is specified by the user, honor it first. Otherwise truncateHead decides.
246
+ if (limit !== undefined) {
247
+ const endLine = Math.min(startLine + limit, allLines.length);
248
+ selectedContent = allLines.slice(startLine, endLine).join("\n");
249
+ userLimitedLines = endLine - startLine;
239
250
  }
240
251
  else {
241
- outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
252
+ selectedContent = allLines.slice(startLine).join("\n");
242
253
  }
243
- details = { truncation };
244
- }
245
- else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
246
- // User-specified limit stopped early, but the file still has more content.
247
- const remaining = allLines.length - (startLine + userLimitedLines);
248
- const nextOffset = startLine + userLimitedLines + 1;
249
- outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
250
- }
251
- else {
252
- // No truncation and no remaining user-limited content.
253
- outputText = truncation.content;
254
+ // Apply truncation, respecting both line and byte limits.
255
+ const truncation = truncateHead(selectedContent);
256
+ let outputText;
257
+ if (truncation.firstLineExceedsLimit) {
258
+ // First line alone exceeds the byte limit. Point the model at a bash fallback.
259
+ const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine], "utf-8"));
260
+ outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${path} | head -c ${DEFAULT_MAX_BYTES}]`;
261
+ details = { truncation };
262
+ }
263
+ else if (truncation.truncated) {
264
+ // Truncation occurred. Build an actionable continuation notice.
265
+ const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
266
+ const nextOffset = endLineDisplay + 1;
267
+ outputText = truncation.content;
268
+ if (truncation.truncatedBy === "lines") {
269
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines}. Use offset=${nextOffset} to continue.]`;
270
+ }
271
+ else {
272
+ outputText += `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${totalFileLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
273
+ }
274
+ details = { truncation };
275
+ }
276
+ else if (userLimitedLines !== undefined && startLine + userLimitedLines < allLines.length) {
277
+ // User-specified limit stopped early, but the file still has more content.
278
+ const remaining = allLines.length - (startLine + userLimitedLines);
279
+ const nextOffset = startLine + userLimitedLines + 1;
280
+ outputText = `${truncation.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
281
+ }
282
+ else {
283
+ // No truncation and no remaining user-limited content.
284
+ outputText = truncation.content;
285
+ }
286
+ content = [{ type: "text", text: outputText }];
254
287
  }
255
- content = [{ type: "text", text: outputText }];
256
288
  }
257
289
  if (aborted)
258
290
  return;
@@ -48,8 +48,10 @@ export declare function formatSize(bytes: number): string;
48
48
  * Truncate content from the head (keep first N lines/bytes).
49
49
  * Suitable for file reads where you want to see the beginning.
50
50
  *
51
- * Never returns partial lines. If first line exceeds byte limit,
52
- * returns empty content with firstLineExceedsLimit=true.
51
+ * If a single line exceeds the byte limit, that line is byte-capped
52
+ * (keeping a valid UTF-8 boundary) rather than aborting the whole read;
53
+ * subsequent lines are still scanned. firstLineExceedsLimit is set true only
54
+ * when the oversized line is the first requested line.
53
55
  */
54
56
  export declare function truncateHead(content: string, options?: TruncationOptions): TruncationResult;
55
57
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/truncate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,gBAAgB;IAChC,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAkF/F;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAyE/F;AAuBD;;;GAGG;AACH,wBAAgB,YAAY,CAC3B,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,MAA6B,GACrC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,CAKzC"}
1
+ {"version":3,"file":"truncate.d.ts","sourceRoot":"","sources":["../../../../../src/sdk/coding-agent/core/tools/truncate.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AACtC,eAAO,MAAM,iBAAiB,QAAY,CAAC;AAC3C,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,MAAM,WAAW,gBAAgB;IAChC,4BAA4B;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,kCAAkC;IAClC,SAAS,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,EAAE,OAAO,GAAG,OAAO,GAAG,IAAI,CAAC;IACtC,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,oDAAoD;IACpD,UAAU,EAAE,MAAM,CAAC;IACnB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,8CAA8C;IAC9C,WAAW,EAAE,MAAM,CAAC;IACpB,yFAAyF;IACzF,eAAe,EAAE,OAAO,CAAC;IACzB,2EAA2E;IAC3E,qBAAqB,EAAE,OAAO,CAAC;IAC/B,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IACjC,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,8CAA8C;IAC9C,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAaD;;GAEG;AACH,wBAAgB,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAQhD;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAkF/F;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAyE/F;AA2CD;;;GAGG;AACH,wBAAgB,YAAY,CAC3B,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,MAA6B,GACrC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,OAAO,CAAA;CAAE,CAKzC"}
@@ -38,8 +38,10 @@ export function formatSize(bytes) {
38
38
  * Truncate content from the head (keep first N lines/bytes).
39
39
  * Suitable for file reads where you want to see the beginning.
40
40
  *
41
- * Never returns partial lines. If first line exceeds byte limit,
42
- * returns empty content with firstLineExceedsLimit=true.
41
+ * If a single line exceeds the byte limit, that line is byte-capped
42
+ * (keeping a valid UTF-8 boundary) rather than aborting the whole read;
43
+ * subsequent lines are still scanned. firstLineExceedsLimit is set true only
44
+ * when the oversized line is the first requested line.
43
45
  */
44
46
  export function truncateHead(content, options = {}) {
45
47
  const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
@@ -63,30 +65,31 @@ export function truncateHead(content, options = {}) {
63
65
  maxBytes,
64
66
  };
65
67
  }
66
- // Check if first line alone exceeds byte limit
67
- const firstLineBytes = Buffer.byteLength(lines[0], "utf-8");
68
- if (firstLineBytes > maxBytes) {
69
- return {
70
- content: "",
71
- truncated: true,
72
- truncatedBy: "bytes",
73
- totalLines,
74
- totalBytes,
75
- outputLines: 0,
76
- outputBytes: 0,
77
- lastLinePartial: false,
78
- firstLineExceedsLimit: true,
79
- maxLines,
80
- maxBytes,
81
- };
82
- }
83
- // Collect complete lines that fit
68
+ // Scan lines, byte-capping any single oversized line instead of aborting.
69
+ // firstLineExceedsLimit is surfaced only when the very first requested line
70
+ // is the oversized one, so callers that render a per-line hint (e.g. read)
71
+ // keep doing so without losing the rest of the content.
84
72
  const outputLinesArr = [];
85
73
  let outputBytesCount = 0;
86
74
  let truncatedBy = "lines";
75
+ let firstLineExceedsLimit = false;
76
+ let lastLinePartial = false;
87
77
  for (let i = 0; i < lines.length && i < maxLines; i++) {
88
78
  const line = lines[i];
89
- const lineBytes = Buffer.byteLength(line, "utf-8") + (i > 0 ? 1 : 0); // +1 for newline
79
+ const newlineCost = i > 0 ? 1 : 0; // +1 for joining newline
80
+ let lineBytes = Buffer.byteLength(line, "utf-8");
81
+ // A single line larger than the whole byte budget: cap it in place.
82
+ if (lineBytes > maxBytes) {
83
+ if (i === 0)
84
+ firstLineExceedsLimit = true;
85
+ const capped = truncateStringToBytesFromStart(line, maxBytes);
86
+ outputLinesArr.push(capped);
87
+ outputBytesCount = maxBytes;
88
+ lastLinePartial = true;
89
+ truncatedBy = "bytes";
90
+ break;
91
+ }
92
+ lineBytes += newlineCost;
90
93
  if (outputBytesCount + lineBytes > maxBytes) {
91
94
  truncatedBy = "bytes";
92
95
  break;
@@ -108,8 +111,8 @@ export function truncateHead(content, options = {}) {
108
111
  totalBytes,
109
112
  outputLines: outputLinesArr.length,
110
113
  outputBytes: finalOutputBytes,
111
- lastLinePartial: false,
112
- firstLineExceedsLimit: false,
114
+ lastLinePartial,
115
+ firstLineExceedsLimit,
113
116
  maxLines,
114
117
  maxBytes,
115
118
  };
@@ -202,6 +205,23 @@ function truncateStringToBytesFromEnd(str, maxBytes) {
202
205
  }
203
206
  return buf.slice(start).toString("utf-8");
204
207
  }
208
+ /**
209
+ * Truncate a string to fit within a byte limit (from the start).
210
+ * Handles multi-byte UTF-8 characters correctly.
211
+ */
212
+ function truncateStringToBytesFromStart(str, maxBytes) {
213
+ const buf = Buffer.from(str, "utf-8");
214
+ if (buf.length <= maxBytes) {
215
+ return str;
216
+ }
217
+ let end = maxBytes;
218
+ // Walk back to a valid UTF-8 character boundary so we don't slice
219
+ // through the middle of a multi-byte sequence.
220
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) {
221
+ end--;
222
+ }
223
+ return buf.slice(0, end).toString("utf-8");
224
+ }
205
225
  /**
206
226
  * Truncate a single line to max characters, adding [truncated] suffix.
207
227
  * Used for grep match lines.