pi-readseek 0.4.29 → 0.4.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-readseek",
3
- "version": "0.4.29",
3
+ "version": "0.4.30",
4
4
  "description": "Pi extension for readseek-backed hash-anchored read/edit/grep, structural code maps, structural search, and file exploration",
5
5
  "type": "module",
6
6
  "exports": {
@@ -39,7 +39,7 @@
39
39
  "node": ">=20.0.0"
40
40
  },
41
41
  "dependencies": {
42
- "@jarkkojs/readseek": "^0.4.32",
42
+ "@jarkkojs/readseek": "^0.4.34",
43
43
  "diff": "^8.0.3",
44
44
  "xxhash-wasm": "^1.1.0"
45
45
  },
@@ -1,8 +1,4 @@
1
1
  import * as Diff from "diff";
2
- import { execFile } from "node:child_process";
3
- import { writeFileSync, mkdtempSync, rmSync } from "node:fs";
4
- import { resolve } from "node:path";
5
- import { tmpdir } from "node:os";
6
2
 
7
3
  export type EditClassification = "no-op" | "whitespace-only" | "semantic" | "mixed";
8
4
 
@@ -60,126 +56,3 @@ export function classifyEdit(oldContent: string, newContent: string): EditClassi
60
56
  return { classification: "semantic" };
61
57
  }
62
58
 
63
- let difftCachedResult: boolean | null = null;
64
-
65
-
66
- export async function isDifftAvailable(): Promise<boolean> {
67
- if (difftCachedResult !== null) return difftCachedResult;
68
-
69
- return new Promise<boolean>((resolve) => {
70
- execFile("which", ["difft"], (err) => {
71
- difftCachedResult = !err;
72
- resolve(difftCachedResult);
73
- });
74
- });
75
- }
76
-
77
- export interface DifftClassifyResult {
78
- classification: EditClassification;
79
- movedBlocks: number;
80
- }
81
-
82
- function getChunkSideText(side: any): string {
83
- if (!side || typeof side !== "object") return "";
84
- if (!Array.isArray(side.changes)) return "";
85
- return side.changes
86
- .map((change: any) => (change && typeof change.content === "string" ? change.content : ""))
87
- .join("");
88
- }
89
-
90
- function getChunkOnlySideSignature(chunk: any[]): { side: "lhs" | "rhs"; text: string } | null {
91
- let hasLhs = false;
92
- let hasRhs = false;
93
- let text = "";
94
-
95
- for (const entry of chunk) {
96
- if (entry?.lhs) {
97
- hasLhs = true;
98
- text += getChunkSideText(entry.lhs);
99
- }
100
- if (entry?.rhs) {
101
- hasRhs = true;
102
- text += getChunkSideText(entry.rhs);
103
- }
104
- }
105
-
106
- if (hasLhs === hasRhs) return null;
107
- return { side: hasLhs ? "lhs" : "rhs", text };
108
- }
109
-
110
- export function parseDifftJson(json: any): DifftClassifyResult | null {
111
- if (!json || typeof json !== "object" || !("status" in json)) return null;
112
-
113
- if (json.status === "unchanged") {
114
- return { classification: "whitespace-only", movedBlocks: 0 };
115
- }
116
-
117
- if (json.status !== "changed" || !Array.isArray(json.chunks)) {
118
- return null;
119
- }
120
-
121
- const lhsOnlyChunkTexts: string[] = [];
122
- const rhsOnlyChunkTexts: string[] = [];
123
- for (const chunk of json.chunks) {
124
- if (!Array.isArray(chunk)) continue;
125
- const signature = getChunkOnlySideSignature(chunk);
126
- if (!signature) continue;
127
- if (signature.side === "lhs") lhsOnlyChunkTexts.push(signature.text);
128
- if (signature.side === "rhs") rhsOnlyChunkTexts.push(signature.text);
129
- }
130
-
131
- const remainingRhs = new Map<string, number>();
132
- for (const text of rhsOnlyChunkTexts) {
133
- remainingRhs.set(text, (remainingRhs.get(text) ?? 0) + 1);
134
- }
135
-
136
- let movedBlocks = 0;
137
- for (const text of lhsOnlyChunkTexts) {
138
- const count = remainingRhs.get(text) ?? 0;
139
- if (count <= 0) continue;
140
- movedBlocks++;
141
- if (count === 1) remainingRhs.delete(text);
142
- else remainingRhs.set(text, count - 1);
143
- }
144
-
145
- return { classification: "semantic", movedBlocks };
146
- }
147
-
148
- export async function runDifftastic(
149
- oldContent: string,
150
- newContent: string,
151
- fileExtension: string,
152
- ): Promise<DifftClassifyResult | null> {
153
- const available = await isDifftAvailable();
154
- if (!available) return null;
155
-
156
- let tempDir: string | null = null;
157
- try {
158
- tempDir = mkdtempSync(resolve(tmpdir(), "pi-difft-"));
159
- const oldPath = resolve(tempDir, `old.${fileExtension}`);
160
- const newPath = resolve(tempDir, `new.${fileExtension}`);
161
- writeFileSync(oldPath, oldContent, "utf-8");
162
- writeFileSync(newPath, newContent, "utf-8");
163
-
164
- const stdout = await new Promise<string>((resolve, reject) => {
165
- execFile(
166
- "difft",
167
- ["--display=json", oldPath, newPath],
168
- { env: { ...process.env, DFT_UNSTABLE: "yes" }, timeout: 10_000 },
169
- (err, stdout) => {
170
- if (err) return reject(err);
171
- resolve(stdout);
172
- },
173
- );
174
- });
175
-
176
- const json = JSON.parse(stdout);
177
- return parseDifftJson(json);
178
- } catch {
179
- return null;
180
- } finally {
181
- if (tempDir) {
182
- try { rmSync(tempDir, { recursive: true, force: true }); } catch { /* ignore */ }
183
- }
184
- }
185
- }
@@ -76,14 +76,8 @@ function formatWhitespaceOnlyWarning(semanticSummary: SemanticSummary | undefine
76
76
  if (!extractNewTextValues(edits).some((text) => /\S/.test(text))) return undefined;
77
77
  return "⚠ Edit classified as whitespace-only — if you intended a behavior change, re-read to verify.";
78
78
  }
79
- function formatSemanticSuffix(semanticSummary: SemanticSummary | undefined): string {
80
- if (!semanticSummary) return "";
81
-
82
- const movedBlocks = semanticSummary.movedBlocks ?? 0;
83
- if (movedBlocks <= 0) return "";
84
-
85
- const blockWord = movedBlocks === 1 ? "block" : "blocks";
86
- return ` [semantic: ${semanticSummary.classification}, ${movedBlocks} ${blockWord} moved]`;
79
+ function formatSemanticSuffix(_semanticSummary: SemanticSummary | undefined): string {
80
+ return "";
87
81
  }
88
82
  function formatReplaceHint(edits: unknown[] | undefined, noopEdits: unknown[]): string | undefined {
89
83
  if ((noopEdits ?? []).length > 0) return undefined;
@@ -41,12 +41,13 @@ const EMPTY: ReadSeekCheckOutput = { errorCount: 0, missingCount: 0, diagnostics
41
41
  */
42
42
  export async function validateSyntaxRegression(
43
43
  input: ValidateInput,
44
+ options: { signal?: AbortSignal } = {},
44
45
  ): Promise<ValidateResult | null> {
45
46
  let before: ReadSeekCheckOutput;
46
47
  let after: ReadSeekCheckOutput;
47
48
  try {
48
- before = input.before === undefined ? EMPTY : await readseekCheck(input.filePath, input.before);
49
- after = await readseekCheck(input.filePath, input.after);
49
+ before = input.before === undefined ? EMPTY : await readseekCheck(input.filePath, input.before, { signal: options.signal });
50
+ after = await readseekCheck(input.filePath, input.after, { signal: options.signal });
50
51
  } catch {
51
52
  return null;
52
53
  }
package/src/edit.ts CHANGED
@@ -14,7 +14,7 @@ import { looksLikeBinary } from "./binary-detect.js";
14
14
  import { throwIfAborted } from "./runtime.js";
15
15
  import { formatFsError } from "./fs-error.js";
16
16
  import { buildEditOutput } from "./edit-output.js";
17
- import { classifyEdit, isDifftAvailable, runDifftastic } from "./edit-classify.js";
17
+ import { classifyEdit } from "./edit-classify.js";
18
18
  import type { SemanticSummary } from "./readseek-value.js";
19
19
  import { buildReadSeekError } from "./readseek-value.js";
20
20
  import { classifyReadSeekFailure } from "./readseek-client.js";
@@ -454,7 +454,7 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
454
454
  filePath: absolutePath,
455
455
  before: originalNormalized,
456
456
  after: result,
457
- });
457
+ }, { signal });
458
458
  if (regression) {
459
459
  const lines = regression.errorLines.join(", ");
460
460
  const message = `syntax-regression: lines ${lines}`;
@@ -516,23 +516,9 @@ export async function executeEdit(opts: ExecuteEditOptions): Promise<any> {
516
516
  if (syntaxWarning) warnings.push(syntaxWarning);
517
517
  // Semantic classification
518
518
  const internalClassification = classifyEdit(originalNormalized, result);
519
- const difftAvailable = await isDifftAvailable();
520
- let semanticSummary: SemanticSummary = {
519
+ const semanticSummary: SemanticSummary = {
521
520
  classification: internalClassification.classification,
522
- difftasticAvailable: difftAvailable,
523
521
  };
524
-
525
- if (difftAvailable) {
526
- const ext = path.split(".").pop() ?? "txt";
527
- const difftResult = await runDifftastic(originalNormalized, result, ext);
528
- if (difftResult) {
529
- semanticSummary = {
530
- classification: difftResult.classification,
531
- difftasticAvailable: true,
532
- ...(difftResult.movedBlocks > 0 ? { movedBlocks: difftResult.movedBlocks } : {}),
533
- };
534
- }
535
- }
536
522
  const builtOutput = buildEditOutput({
537
523
  path: absolutePath,
538
524
  displayPath: path,
package/src/read.ts CHANGED
@@ -74,8 +74,8 @@ function hasReadAnchors(result: AgentToolResult<any>): boolean {
74
74
  function formatImageAnalysis(detection: ReadSeekDetection): string | undefined {
75
75
  if (detection.kind !== "image") return undefined;
76
76
  const sections: string[] = [];
77
- const transcript = detection.transcribe?.text?.trim();
78
- if (transcript) sections.push(`Transcribed text from image:\n${transcript}`);
77
+ const ocr = detection.ocr?.trim();
78
+ if (ocr) sections.push(`OCR text from image:\n${ocr}`);
79
79
  const caption = detection.caption?.trim();
80
80
  if (caption) sections.push(`Image caption:\n${caption}`);
81
81
  if (detection.objects?.length) {
@@ -178,13 +178,13 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
178
178
  const builtinRead = createReadTool(cwd);
179
179
  const builtinResult = await builtinRead.execute(toolCallId, p, signal, onUpdate);
180
180
  const ocrMode = resolveReadSeekOcrMode();
181
- const shouldTranscribe = ocrMode === "on" || (ocrMode === "auto" && !opts.modelSupportsImages);
182
- if (!shouldTranscribe) return succeed(builtinResult);
181
+ const shouldRunVision = ocrMode === "on" || (ocrMode === "auto" && !opts.modelSupportsImages);
182
+ if (!shouldRunVision) return succeed(builtinResult);
183
183
 
184
184
  let ocrFailed = false;
185
185
  try {
186
186
  const ocrDetection = await readseekDetect(absolutePath, {
187
- transcribe: true,
187
+ ocr: true,
188
188
  caption: true,
189
189
  objects: true,
190
190
  signal,
@@ -359,8 +359,8 @@ export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolRe
359
359
  let readseekOutput: Awaited<ReturnType<typeof readseekRead>>;
360
360
  try {
361
361
  readseekOutput = total === 0
362
- ? await readseekRead(absolutePath)
363
- : await readseekRead(absolutePath, startLine, endIdx);
362
+ ? await readseekRead(absolutePath, undefined, undefined, { signal })
363
+ : await readseekRead(absolutePath, startLine, endIdx, { signal });
364
364
  } catch (err: any) {
365
365
  const detail = err?.message ? ` — ${err.message}` : "";
366
366
  const message = `readseek failed while reading ${rawPath}${detail}`;
@@ -108,15 +108,7 @@ export interface ReadSeekCheckOutput {
108
108
  diagnostics: ReadSeekDiagnostic[];
109
109
  }
110
110
 
111
- export interface ReadSeekTranscriptRegion {
112
- text: string;
113
- quad: [number, number, number, number, number, number, number, number];
114
- }
115
-
116
- export interface ReadSeekTranscript {
117
- text: string;
118
- regions: ReadSeekTranscriptRegion[];
119
- }
111
+ export type ReadSeekOcrText = string;
120
112
 
121
113
  export interface ReadSeekDetectedObject {
122
114
  label: string;
@@ -143,7 +135,7 @@ export type ReadSeekDetection =
143
135
  width: number;
144
136
  height: number;
145
137
  animated: boolean;
146
- transcribe?: ReadSeekTranscript;
138
+ ocr?: ReadSeekOcrText;
147
139
  caption?: string;
148
140
  objects?: ReadSeekDetectedObject[];
149
141
  }
@@ -163,7 +155,7 @@ interface ReadSeekSearchOptions {
163
155
  }
164
156
 
165
157
  interface ReadSeekDetectOptions {
166
- transcribe?: boolean;
158
+ ocr?: boolean;
167
159
  caption?: boolean;
168
160
  objects?: boolean;
169
161
  signal?: AbortSignal;
@@ -556,11 +548,16 @@ function parseSearchOutput(value: unknown): ReadSeekSearchOutput {
556
548
  };
557
549
  }
558
550
 
559
- export async function readseekRead(filePath: string, startLine?: number, endLine?: number): Promise<ReadSeekReadOutput> {
551
+ export async function readseekRead(
552
+ filePath: string,
553
+ startLine?: number,
554
+ endLine?: number,
555
+ options: { signal?: AbortSignal } = {},
556
+ ): Promise<ReadSeekReadOutput> {
560
557
  const args = ["read", filePath];
561
558
  if (startLine !== undefined) args.push("--start", String(startLine));
562
559
  if (endLine !== undefined) args.push("--end", String(endLine));
563
- return parseReadOutput(await runReadSeek(args));
560
+ return parseReadOutput(await runReadSeek(args, { signal: options.signal }));
564
561
  }
565
562
 
566
563
  function fileMapFromReadSeekOutput(output: ReadSeekMapOutput, filePath: string, totalBytes: number): FileMap | null {
@@ -688,24 +685,8 @@ export async function readseekCheck(
688
685
  );
689
686
  }
690
687
 
691
- function parseTranscript(value: unknown): ReadSeekTranscript | undefined {
692
- if (value === undefined || value === null) return undefined;
693
- if (typeof value !== "object") throw new Error("invalid readseek detect transcribe");
694
- const transcribe = value as Record<string, unknown>;
695
- if (!Array.isArray(transcribe.regions)) throw new Error("invalid readseek detect transcribe.regions");
696
- return {
697
- text: requireString(transcribe.text, "transcribe.text"),
698
- regions: transcribe.regions.map((region) => {
699
- if (!region || typeof region !== "object") throw new Error("invalid readseek detect transcribe region");
700
- const item = region as Record<string, unknown>;
701
- const quad = item.quad;
702
- if (!Array.isArray(quad) || quad.length !== 8) throw new Error("invalid readseek detect transcribe quad");
703
- return {
704
- text: requireString(item.text, "transcribe.region.text"),
705
- quad: quad.map((n, i) => requireNumber(n, `transcribe.region.quad[${i}]`)) as ReadSeekTranscriptRegion["quad"],
706
- };
707
- }),
708
- };
688
+ function parseOcrText(value: unknown): ReadSeekOcrText | undefined {
689
+ return optionalString(value, "ocr");
709
690
  }
710
691
 
711
692
  function parseDetectedObjects(value: unknown): ReadSeekDetectedObject[] | undefined {
@@ -745,7 +726,7 @@ function parseDetectOutput(value: unknown): ReadSeekDetection {
745
726
  width: requireNumber(output.width, "width"),
746
727
  height: requireNumber(output.height, "height"),
747
728
  animated: requireBoolean(output.animated, "animated"),
748
- transcribe: parseTranscript(output.transcribe),
729
+ ocr: parseOcrText(output.ocr),
749
730
  caption: optionalString(output.caption, "caption"),
750
731
  objects: parseDetectedObjects(output.objects),
751
732
  };
@@ -770,7 +751,7 @@ export async function readseekDetect(
770
751
  options: ReadSeekDetectOptions = {},
771
752
  ): Promise<ReadSeekDetection> {
772
753
  const args = ["detect"];
773
- if (options.transcribe) args.push("--transcribe");
754
+ if (options.ocr) args.push("--ocr");
774
755
  if (options.caption) args.push("--caption");
775
756
  if (options.objects) args.push("--objects");
776
757
  args.push(filePath);
@@ -831,9 +812,44 @@ interface RenameOptions {
831
812
  signal?: AbortSignal;
832
813
  }
833
814
 
815
+ function parseRenameConflicts(value: unknown, field: string): RenameConflict[] {
816
+ if (value === undefined) return [];
817
+ if (!Array.isArray(value)) throw new Error(`invalid readseek ${field}`);
818
+ return value.map((item) => {
819
+ if (!item || typeof item !== "object") throw new Error(`invalid readseek ${field} entry`);
820
+ const c = item as Record<string, unknown>;
821
+ return {
822
+ line: requireNumber(c.line, `${field}.line`),
823
+ column: requireNumber(c.column, `${field}.column`),
824
+ reason: requireString(c.reason, `${field}.reason`),
825
+ };
826
+ });
827
+ }
828
+
829
+ function parseRenameEdits(value: unknown, field: string): RenameEdit[] {
830
+ if (value === undefined) return [];
831
+ if (!Array.isArray(value)) throw new Error(`invalid readseek ${field}`);
832
+ return value.map((item) => {
833
+ if (!item || typeof item !== "object") throw new Error(`invalid readseek ${field} entry`);
834
+ const e = item as Record<string, unknown>;
835
+ return {
836
+ line: requireNumber(e.line, `${field}.line`),
837
+ start_column: requireNumber(e.start_column, `${field}.start_column`),
838
+ end_column: requireNumber(e.end_column, `${field}.end_column`),
839
+ start_byte: requireNumber(e.start_byte, `${field}.start_byte`),
840
+ end_byte: requireNumber(e.end_byte, `${field}.end_byte`),
841
+ occurrence: requireString(e.occurrence, `${field}.occurrence`),
842
+ line_hash: requireString(e.line_hash, `${field}.line_hash`),
843
+ text: requireString(e.text, `${field}.text`),
844
+ };
845
+ });
846
+ }
847
+
834
848
  function parseRenameOutput(value: unknown): RenameOutput {
835
849
  if (!value || typeof value !== "object") throw new Error("invalid readseek rename output");
836
850
  const output = value as Record<string, unknown>;
851
+ const others = output.others;
852
+ if (others !== undefined && !Array.isArray(others)) throw new Error("invalid readseek others");
837
853
  return {
838
854
  file: requireString(output.file, "file"),
839
855
  language: requireString(output.language, "language"),
@@ -842,42 +858,20 @@ function parseRenameOutput(value: unknown): RenameOutput {
842
858
  old_name: requireString(output.old_name, "old_name"),
843
859
  new_name: requireString(output.new_name, "new_name"),
844
860
  applied: requireBoolean(output.applied, "applied"),
845
- conflicts: (output.conflicts as any[] | undefined)?.map((c: Record<string, unknown>) => ({
846
- line: requireNumber(c.line, "conflict.line"),
847
- column: requireNumber(c.column, "conflict.column"),
848
- reason: requireString(c.reason, "conflict.reason"),
849
- })) ?? [],
850
- edits: (output.edits as any[] | undefined)?.map((e: Record<string, unknown>) => ({
851
- line: requireNumber(e.line, "edit.line"),
852
- start_column: requireNumber(e.start_column, "edit.start_column"),
853
- end_column: requireNumber(e.end_column, "edit.end_column"),
854
- start_byte: requireNumber(e.start_byte, "edit.start_byte"),
855
- end_byte: requireNumber(e.end_byte, "edit.end_byte"),
856
- occurrence: requireString(e.occurrence, "edit.occurrence"),
857
- line_hash: requireString(e.line_hash, "edit.line_hash"),
858
- text: requireString(e.text, "edit.text"),
859
- })) ?? [],
860
- others: (output.others as any[] | undefined)?.map((o: Record<string, unknown>) => ({
861
- file: requireString(o.file, "other.file"),
862
- language: requireString(o.language, "other.language"),
863
- engine: optionalString(o.engine, "other.engine"),
864
- file_hash: requireString(o.file_hash, "other.file_hash"),
865
- conflicts: (o.conflicts as any[] | undefined)?.map((c: Record<string, unknown>) => ({
866
- line: requireNumber(c.line, "conflict.line"),
867
- column: requireNumber(c.column, "conflict.column"),
868
- reason: requireString(c.reason, "conflict.reason"),
869
- })) ?? [],
870
- edits: (o.edits as any[] | undefined)?.map((e: Record<string, unknown>) => ({
871
- line: requireNumber(e.line, "edit.line"),
872
- start_column: requireNumber(e.start_column, "edit.start_column"),
873
- end_column: requireNumber(e.end_column, "edit.end_column"),
874
- start_byte: requireNumber(e.start_byte, "edit.start_byte"),
875
- end_byte: requireNumber(e.end_byte, "edit.end_byte"),
876
- occurrence: requireString(e.occurrence, "edit.occurrence"),
877
- line_hash: requireString(e.line_hash, "edit.line_hash"),
878
- text: requireString(e.text, "edit.text"),
879
- })) ?? [],
880
- })) ?? [],
861
+ conflicts: parseRenameConflicts(output.conflicts, "conflicts"),
862
+ edits: parseRenameEdits(output.edits, "edits"),
863
+ others: (others as unknown[] | undefined)?.map((entry) => {
864
+ if (!entry || typeof entry !== "object") throw new Error("invalid readseek other");
865
+ const o = entry as Record<string, unknown>;
866
+ return {
867
+ file: requireString(o.file, "other.file"),
868
+ language: requireString(o.language, "other.language"),
869
+ engine: optionalString(o.engine, "other.engine"),
870
+ file_hash: requireString(o.file_hash, "other.file_hash"),
871
+ conflicts: parseRenameConflicts(o.conflicts, "other.conflicts"),
872
+ edits: parseRenameEdits(o.edits, "other.edits"),
873
+ };
874
+ }) ?? [],
881
875
  };
882
876
  }
883
877
 
@@ -39,8 +39,6 @@ export interface ReadSeekRange {
39
39
 
40
40
  export interface SemanticSummary {
41
41
  classification: "no-op" | "whitespace-only" | "semantic" | "mixed";
42
- difftasticAvailable: boolean;
43
- movedBlocks?: number;
44
42
  }
45
43
  export interface ReadSeekEditResult {
46
44
  tool: "edit";
package/src/write.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
2
  import { dirname, relative } from "node:path";
3
3
 
4
- import { withFileMutationQueue, type ExtensionAPI, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
+ import { withFileMutationQueue, truncateHead, type ExtensionAPI, type ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { Type } from "@sinclair/typebox";
7
7
 
8
8
  import { resolveToCwd } from "./path-utils.js";
9
9
  import { ensureHashInit, formatHashlineDisplay } from "./hashline.js";
10
- import { buildReadSeekError, buildReadSeekLine, buildReadSeekWarning, buildToolErrorResult, type ReadSeekLine, type ReadSeekWarning } from "./readseek-value.js";
10
+ import { buildReadSeekLine, buildReadSeekWarning, buildToolErrorResult, type ReadSeekLine, type ReadSeekWarning, type ToolErrorResult } from "./readseek-value.js";
11
11
  import { looksLikeBinary } from "./binary-detect.js";
12
12
  import { formatFsError } from "./fs-error.js";
13
13
  import { getOrGenerateMap } from "./file-map.js";
@@ -66,8 +66,6 @@ function pendingWritePreviewParts(summary: string, preview: PendingDiffPreviewRe
66
66
  return { lines: [summary, headerLine], diffData: expanded ? diffData : undefined };
67
67
  }
68
68
 
69
- const MAX_LINES = 2000;
70
- const MAX_BYTES = 50 * 1024;
71
69
  const WRITE_PROMPT_METADATA = defineToolPromptMetadata({
72
70
  promptUrl: new URL("../prompts/write.md", import.meta.url),
73
71
  promptSnippet: "Create or overwrite a complete file and return edit anchors",
@@ -93,6 +91,13 @@ export interface WriteResult extends WriteDiffFields {
93
91
  };
94
92
  }
95
93
 
94
+
95
+ type ExecuteWriteResult = WriteResult | ToolErrorResult;
96
+
97
+ function isToolErrorResult(result: ExecuteWriteResult): result is ToolErrorResult {
98
+ return "isError" in result;
99
+ }
100
+
96
101
  async function readPreviousTextForDiff(filePath: string): Promise<string> {
97
102
  try {
98
103
  const previous = await readFile(filePath);
@@ -133,7 +138,7 @@ export async function executeWrite(opts: {
133
138
  content: string;
134
139
  map?: boolean;
135
140
  cwd?: string;
136
- }): Promise<WriteResult> {
141
+ }): Promise<ExecuteWriteResult> {
137
142
  await ensureHashInit();
138
143
 
139
144
  const { path: filePath, content, map: requestMap, cwd } = opts;
@@ -144,30 +149,19 @@ export async function executeWrite(opts: {
144
149
  const message = "File content contains bare CR (\\r) line endings; write refuses to emit anchors that read/edit would normalize differently.";
145
150
  warnings.push(message);
146
151
  readseekWarnings.push(buildReadSeekWarning("bare-cr", message));
147
- return {
148
- text: `Cannot write ${filePath}\n⚠️ ${message}`,
149
- warnings,
150
- readseekValue: {
151
- tool: "write",
152
- path: filePath,
153
- lines: [],
154
- warnings: readseekWarnings,
155
- },
156
- };
152
+ return buildToolErrorResult("write", "bare-cr", `Cannot write ${filePath}\n⚠️ ${message}`, {
153
+ path: filePath,
154
+ extra: { lines: [], warnings: readseekWarnings },
155
+ });
157
156
  }
158
157
  if (looksLikeBinary(Buffer.from(content, "utf-8"))) {
159
- warnings.push("File content appears to be binary.");
160
- readseekWarnings.push(buildReadSeekWarning("binary-content", "File content appears to be binary."));
161
- return {
162
- text: `Cannot write ${filePath}\n⚠️ File content appears to be binary — refusing to write.`,
163
- warnings,
164
- readseekValue: {
165
- tool: "write",
166
- path: filePath,
167
- lines: [],
168
- warnings: readseekWarnings,
169
- },
170
- };
158
+ const message = "File content appears to be binary.";
159
+ warnings.push(message);
160
+ readseekWarnings.push(buildReadSeekWarning("binary-content", message));
161
+ return buildToolErrorResult("write", "binary-content", `Cannot write ${filePath}\n⚠️ ${message} — refusing to write.`, {
162
+ path: filePath,
163
+ extra: { lines: [], warnings: readseekWarnings },
164
+ });
171
165
  }
172
166
 
173
167
  const previousContent = await readPreviousTextForDiff(filePath);
@@ -189,15 +183,15 @@ export async function executeWrite(opts: {
189
183
  displayLines.push(formatHashlineDisplay(lineNum, rawLines[i]));
190
184
  }
191
185
 
192
- let text = displayLines.join("\n");
193
- if (rawLines.length > MAX_LINES) {
194
- text = displayLines.slice(0, MAX_LINES).join("\n");
195
- text += `\n[… ${rawLines.length - MAX_LINES} more lines not shown — full anchors in readseekValue]`;
196
- }
197
- const bytes = Buffer.byteLength(text, "utf8");
198
- if (bytes > MAX_BYTES) {
199
- text = Buffer.from(text, "utf8").subarray(0, MAX_BYTES).toString("utf8");
200
- text += "\n[… output truncated at 50 KB — full anchors in readseekValue]";
186
+ const fullText = displayLines.join("\n");
187
+ const truncated = truncateHead(fullText);
188
+ let text = truncated.content;
189
+ if (truncated.truncated) {
190
+ if (truncated.truncatedBy === "lines") {
191
+ text += `\n[… ${truncated.totalLines - truncated.outputLines} more lines not shown — full anchors in readseekValue]`;
192
+ } else {
193
+ text += "\n[… output truncated at 50 KB — full anchors in readseekValue]";
194
+ }
201
195
  }
202
196
 
203
197
  // Optional structural map
@@ -267,7 +261,7 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
267
261
  const absolutePath = resolveToCwd(params.path, cwd);
268
262
  try {
269
263
  return await withFileMutationQueue(absolutePath, async () => {
270
- let result: WriteResult;
264
+ let result: ExecuteWriteResult;
271
265
  try {
272
266
  result = await executeWrite({
273
267
  path: absolutePath,
@@ -279,32 +273,12 @@ export function registerWriteTool(pi: ExtensionAPI, options: WriteToolOptions =
279
273
  return buildWriteFsErrorResult(err, absolutePath);
280
274
  }
281
275
 
276
+ if (isToolErrorResult(result)) return result;
277
+
282
278
  if (result.readseekValue.lines.length > 0) {
283
279
  options.onFileAnchored?.(absolutePath);
284
280
  }
285
281
 
286
- // Lift binary-content signal into a fatal readseekValue.error envelope so
287
- // downstream consumers get the same taxonomy shape as every other tool.
288
- // The existing ReadSeekWarning entry is preserved on readseekValue.warnings for
289
- // backward compatibility.
290
- for (const code of ["binary-content", "bare-cr"] as const) {
291
- const warning = result.readseekValue.warnings.find((w) => w.code === code);
292
- if (warning) {
293
- return {
294
- content: [{ type: "text" as const, text: result.text }],
295
- isError: true,
296
- details: {
297
- readseekValue: {
298
- ...result.readseekValue,
299
- ok: false,
300
- error: buildReadSeekError(code, warning.message),
301
- },
302
- warnings: result.warnings,
303
- },
304
- };
305
- }
306
- }
307
-
308
282
  return {
309
283
  content: [{ type: "text" as const, text: result.text }],
310
284
  details: {