pi-readseek 0.4.28 → 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 +2 -2
- package/src/edit-classify.ts +0 -127
- package/src/edit-output.ts +2 -8
- package/src/edit-syntax-validate.ts +3 -2
- package/src/edit.ts +3 -17
- package/src/read.ts +7 -7
- package/src/readseek-client.ts +63 -69
- package/src/readseek-value.ts +0 -2
- package/src/write.ts +33 -59
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-readseek",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
42
|
+
"@jarkkojs/readseek": "^0.4.34",
|
|
43
43
|
"diff": "^8.0.3",
|
|
44
44
|
"xxhash-wasm": "^1.1.0"
|
|
45
45
|
},
|
package/src/edit-classify.ts
CHANGED
|
@@ -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
|
-
}
|
package/src/edit-output.ts
CHANGED
|
@@ -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(
|
|
80
|
-
|
|
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
|
|
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
|
|
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
|
|
78
|
-
if (
|
|
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
|
|
182
|
-
if (!
|
|
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
|
-
|
|
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}`;
|
package/src/readseek-client.ts
CHANGED
|
@@ -108,15 +108,7 @@ export interface ReadSeekCheckOutput {
|
|
|
108
108
|
diagnostics: ReadSeekDiagnostic[];
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
692
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
|
package/src/readseek-value.ts
CHANGED
|
@@ -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 {
|
|
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<
|
|
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
|
-
|
|
149
|
-
warnings,
|
|
150
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
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: {
|