pi-readseek 0.1.0
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/LICENSE +22 -0
- package/README.md +41 -0
- package/index.ts +142 -0
- package/package.json +73 -0
- package/prompts/edit.md +113 -0
- package/prompts/find.md +19 -0
- package/prompts/grep.md +26 -0
- package/prompts/ls.md +11 -0
- package/prompts/read.md +33 -0
- package/prompts/sg.md +25 -0
- package/prompts/write.md +46 -0
- package/src/binary-detect.ts +22 -0
- package/src/binary-resolution.ts +77 -0
- package/src/coerce-obvious-int.ts +39 -0
- package/src/context-application.ts +70 -0
- package/src/context-hygiene.ts +503 -0
- package/src/diff-data.ts +303 -0
- package/src/doom-loop-suggestions.ts +42 -0
- package/src/doom-loop.ts +216 -0
- package/src/edit-classify.ts +190 -0
- package/src/edit-diff.ts +354 -0
- package/src/edit-output.ts +107 -0
- package/src/edit-render-helpers.ts +141 -0
- package/src/edit-syntax-validate.ts +120 -0
- package/src/edit.ts +725 -0
- package/src/find-parsers.ts +89 -0
- package/src/find-stat.ts +36 -0
- package/src/find.ts +613 -0
- package/src/grep-budget.ts +79 -0
- package/src/grep-output.ts +197 -0
- package/src/grep-render-helpers.ts +77 -0
- package/src/grep-symbol-scope.ts +197 -0
- package/src/grep.ts +792 -0
- package/src/hashline.ts +747 -0
- package/src/ls.ts +293 -0
- package/src/map-cache.ts +152 -0
- package/src/path-utils.ts +24 -0
- package/src/pending-diff-preview.ts +269 -0
- package/src/persistent-map-cache.ts +251 -0
- package/src/read-local-bundle.ts +87 -0
- package/src/read-output.ts +212 -0
- package/src/read-render-helpers.ts +104 -0
- package/src/read.ts +748 -0
- package/src/readseek/constants.ts +21 -0
- package/src/readseek/enums.ts +38 -0
- package/src/readseek/formatter.ts +431 -0
- package/src/readseek/language-detect.ts +29 -0
- package/src/readseek/mapper.ts +69 -0
- package/src/readseek/parser-errors.ts +22 -0
- package/src/readseek/parser-loader.ts +83 -0
- package/src/readseek/symbol-error-format.ts +18 -0
- package/src/readseek/symbol-lookup.ts +294 -0
- package/src/readseek/types.ts +79 -0
- package/src/readseek-client.ts +343 -0
- package/src/readseek-error-codes.ts +54 -0
- package/src/readseek-settings.ts +287 -0
- package/src/readseek-value.ts +144 -0
- package/src/replace-symbol.ts +74 -0
- package/src/runtime.ts +3 -0
- package/src/sg-output.ts +88 -0
- package/src/sg.ts +308 -0
- package/src/syntax-validate-mode.ts +25 -0
- package/src/tool-prompt-metadata.ts +76 -0
- package/src/tui-diff-component.ts +86 -0
- package/src/tui-diff-renderer.ts +92 -0
- package/src/tui-render-utils.ts +129 -0
- package/src/write.ts +532 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { DetailLevel } from "./readseek/enums.js";
|
|
8
|
+
import type { FileMap, FileSymbol } from "./readseek/types.js";
|
|
9
|
+
import { SymbolKind } from "./readseek/enums.js";
|
|
10
|
+
|
|
11
|
+
interface ReadseekHashline {
|
|
12
|
+
line: number;
|
|
13
|
+
hash: string;
|
|
14
|
+
text: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReadseekReadOutput {
|
|
18
|
+
file: string;
|
|
19
|
+
language: string;
|
|
20
|
+
line_count: number;
|
|
21
|
+
file_hash: string;
|
|
22
|
+
start_line: number;
|
|
23
|
+
end_line: number;
|
|
24
|
+
hashlines: ReadseekHashline[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface ReadseekSymbol {
|
|
28
|
+
kind: string;
|
|
29
|
+
name: string;
|
|
30
|
+
address: string;
|
|
31
|
+
start_line: number;
|
|
32
|
+
end_line: number;
|
|
33
|
+
start_hash: string;
|
|
34
|
+
end_hash: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ReadseekMapOutput {
|
|
38
|
+
file: string;
|
|
39
|
+
language: string;
|
|
40
|
+
line_count: number;
|
|
41
|
+
file_hash: string;
|
|
42
|
+
symbols: ReadseekSymbol[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ReadseekSearchCapture {
|
|
46
|
+
name: string;
|
|
47
|
+
start_line: number;
|
|
48
|
+
end_line: number;
|
|
49
|
+
start_hash: string;
|
|
50
|
+
end_hash: string;
|
|
51
|
+
hashlines: ReadseekHashline[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ReadseekSearchMatch {
|
|
55
|
+
pattern_index: number;
|
|
56
|
+
start_line: number;
|
|
57
|
+
end_line: number;
|
|
58
|
+
start_hash: string;
|
|
59
|
+
end_hash: string;
|
|
60
|
+
hashlines: ReadseekHashline[];
|
|
61
|
+
captures: ReadseekSearchCapture[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ReadseekSearchFileOutput {
|
|
65
|
+
file: string;
|
|
66
|
+
language: string;
|
|
67
|
+
file_hash: string;
|
|
68
|
+
matches: ReadseekSearchMatch[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ReadseekSearchOutput {
|
|
72
|
+
results: ReadseekSearchFileOutput[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeLanguage(language: string): string {
|
|
76
|
+
return language === "java" ? "Java" : language;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizeKind(kind: string): FileSymbol["kind"] {
|
|
80
|
+
if (kind === "constructor") return SymbolKind.Method;
|
|
81
|
+
if (Object.values(SymbolKind).includes(kind as SymbolKind)) return kind as FileSymbol["kind"];
|
|
82
|
+
return SymbolKind.Unknown;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function symbolsFromReadseek(symbols: ReadseekSymbol[]): FileSymbol[] {
|
|
86
|
+
const byAddress = new Map<string, FileSymbol[]>();
|
|
87
|
+
const entries: Array<{ address: string; parentAddress: string; symbol: FileSymbol }> = [];
|
|
88
|
+
|
|
89
|
+
for (const symbol of symbols) {
|
|
90
|
+
const address = symbol.address || symbol.name;
|
|
91
|
+
const parentAddress = address.includes(".") ? address.slice(0, address.lastIndexOf(".")) : "";
|
|
92
|
+
const fileSymbol: FileSymbol = {
|
|
93
|
+
name: symbol.name,
|
|
94
|
+
kind: normalizeKind(symbol.kind),
|
|
95
|
+
startLine: symbol.start_line,
|
|
96
|
+
endLine: symbol.end_line,
|
|
97
|
+
};
|
|
98
|
+
const bucket = byAddress.get(address);
|
|
99
|
+
if (bucket) bucket.push(fileSymbol);
|
|
100
|
+
else byAddress.set(address, [fileSymbol]);
|
|
101
|
+
entries.push({ address, parentAddress, symbol: fileSymbol });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const roots: FileSymbol[] = [];
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
const parent = entry.parentAddress ? byAddress.get(entry.parentAddress)?.[0] : undefined;
|
|
107
|
+
if (parent) {
|
|
108
|
+
parent.children ??= [];
|
|
109
|
+
parent.children.push(entry.symbol);
|
|
110
|
+
} else {
|
|
111
|
+
roots.push(entry.symbol);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return roots;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const require = createRequire(import.meta.url);
|
|
119
|
+
|
|
120
|
+
function readseekPackageDir(): string {
|
|
121
|
+
return path.dirname(require.resolve("@jarkkojs/readseek/package.json"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function readseekBinaryPath(): string {
|
|
125
|
+
const platformPackage = (() => {
|
|
126
|
+
switch (process.platform) {
|
|
127
|
+
case "darwin":
|
|
128
|
+
return "@jarkkojs/readseek-darwin-arm64";
|
|
129
|
+
case "linux":
|
|
130
|
+
return "@jarkkojs/readseek-linux-x64";
|
|
131
|
+
case "win32":
|
|
132
|
+
return "@jarkkojs/readseek-win32-x64";
|
|
133
|
+
default:
|
|
134
|
+
throw new Error(`unsupported readseek platform: ${process.platform}`);
|
|
135
|
+
}
|
|
136
|
+
})();
|
|
137
|
+
|
|
138
|
+
const packageJson = require.resolve(`${platformPackage}/package.json`, { paths: [readseekPackageDir()] });
|
|
139
|
+
return path.join(path.dirname(packageJson), "bin", process.platform === "win32" ? "readseek.exe" : "readseek");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isReadseekAvailable(): boolean {
|
|
143
|
+
try {
|
|
144
|
+
readseekBinaryPath();
|
|
145
|
+
return true;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function runReadseek(args: string[], options: { signal?: AbortSignal } = {}): Promise<unknown> {
|
|
152
|
+
const { stdout, stderr } = await new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
153
|
+
const child = spawn(readseekBinaryPath(), args, { stdio: ["ignore", "pipe", "pipe"], signal: options.signal });
|
|
154
|
+
const stdoutChunks: Buffer[] = [];
|
|
155
|
+
const stderrChunks: Buffer[] = [];
|
|
156
|
+
let stdoutBytes = 0;
|
|
157
|
+
|
|
158
|
+
child.stdout.on("data", (chunk: Buffer) => {
|
|
159
|
+
stdoutBytes += chunk.length;
|
|
160
|
+
if (stdoutBytes > 32 * 1024 * 1024) {
|
|
161
|
+
child.kill();
|
|
162
|
+
reject(new Error("readseek output exceeded 32 MiB"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
stdoutChunks.push(chunk);
|
|
166
|
+
});
|
|
167
|
+
child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk));
|
|
168
|
+
child.on("error", (error: any) => reject(error));
|
|
169
|
+
child.on("close", (code) => {
|
|
170
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf-8");
|
|
171
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
|
|
172
|
+
if (code === 0) resolve({ stdout, stderr });
|
|
173
|
+
else reject(new Error((stderr || `readseek exited with status ${code}`).replace(/^error:\s*/i, "")));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
void stderr;
|
|
177
|
+
return JSON.parse(stdout) as unknown;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function requireNumber(value: unknown, field: string): number {
|
|
181
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`invalid readseek ${field}`);
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function requireString(value: unknown, field: string): string {
|
|
186
|
+
if (typeof value !== "string") throw new Error(`invalid readseek ${field}`);
|
|
187
|
+
return value;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function parseReadOutput(value: unknown): ReadseekReadOutput {
|
|
191
|
+
if (!value || typeof value !== "object") throw new Error("invalid readseek read output");
|
|
192
|
+
const output = value as Record<string, unknown>;
|
|
193
|
+
const hashlines = output.hashlines;
|
|
194
|
+
if (!Array.isArray(hashlines)) throw new Error("invalid readseek hashlines");
|
|
195
|
+
return {
|
|
196
|
+
file: requireString(output.file, "file"),
|
|
197
|
+
language: requireString(output.language, "language"),
|
|
198
|
+
line_count: requireNumber(output.line_count, "line_count"),
|
|
199
|
+
file_hash: requireString(output.file_hash, "file_hash"),
|
|
200
|
+
start_line: requireNumber(output.start_line, "start_line"),
|
|
201
|
+
end_line: requireNumber(output.end_line, "end_line"),
|
|
202
|
+
hashlines: hashlines.map((line) => {
|
|
203
|
+
if (!line || typeof line !== "object") throw new Error("invalid readseek hashline");
|
|
204
|
+
const item = line as Record<string, unknown>;
|
|
205
|
+
return {
|
|
206
|
+
line: requireNumber(item.line, "hashline.line"),
|
|
207
|
+
hash: requireString(item.hash, "hashline.hash"),
|
|
208
|
+
text: requireString(item.text, "hashline.text"),
|
|
209
|
+
};
|
|
210
|
+
}),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseMapOutput(value: unknown): ReadseekMapOutput {
|
|
215
|
+
if (!value || typeof value !== "object") throw new Error("invalid readseek map output");
|
|
216
|
+
const output = value as Record<string, unknown>;
|
|
217
|
+
const symbols = output.symbols;
|
|
218
|
+
if (!Array.isArray(symbols)) throw new Error("invalid readseek symbols");
|
|
219
|
+
return {
|
|
220
|
+
file: requireString(output.file, "file"),
|
|
221
|
+
language: requireString(output.language, "language"),
|
|
222
|
+
line_count: requireNumber(output.line_count, "line_count"),
|
|
223
|
+
file_hash: requireString(output.file_hash, "file_hash"),
|
|
224
|
+
symbols: symbols.map((symbol) => {
|
|
225
|
+
if (!symbol || typeof symbol !== "object") throw new Error("invalid readseek symbol");
|
|
226
|
+
const item = symbol as Record<string, unknown>;
|
|
227
|
+
return {
|
|
228
|
+
kind: requireString(item.kind, "symbol.kind"),
|
|
229
|
+
name: requireString(item.name, "symbol.name"),
|
|
230
|
+
address: requireString(item.address, "symbol.address"),
|
|
231
|
+
start_line: requireNumber(item.start_line, "symbol.start_line"),
|
|
232
|
+
end_line: requireNumber(item.end_line, "symbol.end_line"),
|
|
233
|
+
start_hash: requireString(item.start_hash, "symbol.start_hash"),
|
|
234
|
+
end_hash: requireString(item.end_hash, "symbol.end_hash"),
|
|
235
|
+
};
|
|
236
|
+
}),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseSearchHashlines(value: unknown, field: string): ReadseekHashline[] {
|
|
241
|
+
if (!Array.isArray(value)) throw new Error(`invalid readseek ${field}`);
|
|
242
|
+
return value.map((line) => {
|
|
243
|
+
if (!line || typeof line !== "object") throw new Error(`invalid readseek ${field}`);
|
|
244
|
+
const item = line as Record<string, unknown>;
|
|
245
|
+
return {
|
|
246
|
+
line: requireNumber(item.line, `${field}.line`),
|
|
247
|
+
hash: requireString(item.hash, `${field}.hash`),
|
|
248
|
+
text: requireString(item.text, `${field}.text`),
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseSearchOutput(value: unknown): ReadseekSearchOutput {
|
|
254
|
+
if (!value || typeof value !== "object") throw new Error("invalid readseek search output");
|
|
255
|
+
const output = value as Record<string, unknown>;
|
|
256
|
+
if (!Array.isArray(output.results)) throw new Error("invalid readseek search results");
|
|
257
|
+
return {
|
|
258
|
+
results: output.results.map((result) => {
|
|
259
|
+
if (!result || typeof result !== "object") throw new Error("invalid readseek search result");
|
|
260
|
+
const file = result as Record<string, unknown>;
|
|
261
|
+
if (!Array.isArray(file.matches)) throw new Error("invalid readseek search matches");
|
|
262
|
+
return {
|
|
263
|
+
file: requireString(file.file, "search.file"),
|
|
264
|
+
language: requireString(file.language, "search.language"),
|
|
265
|
+
file_hash: requireString(file.file_hash, "search.file_hash"),
|
|
266
|
+
matches: file.matches.map((match) => {
|
|
267
|
+
if (!match || typeof match !== "object") throw new Error("invalid readseek search match");
|
|
268
|
+
const item = match as Record<string, unknown>;
|
|
269
|
+
if (!Array.isArray(item.captures)) throw new Error("invalid readseek search captures");
|
|
270
|
+
return {
|
|
271
|
+
pattern_index: requireNumber(item.pattern_index, "search.match.pattern_index"),
|
|
272
|
+
start_line: requireNumber(item.start_line, "search.match.start_line"),
|
|
273
|
+
end_line: requireNumber(item.end_line, "search.match.end_line"),
|
|
274
|
+
start_hash: requireString(item.start_hash, "search.match.start_hash"),
|
|
275
|
+
end_hash: requireString(item.end_hash, "search.match.end_hash"),
|
|
276
|
+
hashlines: parseSearchHashlines(item.hashlines, "search.match.hashlines"),
|
|
277
|
+
captures: item.captures.map((capture) => {
|
|
278
|
+
if (!capture || typeof capture !== "object") throw new Error("invalid readseek search capture");
|
|
279
|
+
const captureItem = capture as Record<string, unknown>;
|
|
280
|
+
return {
|
|
281
|
+
name: requireString(captureItem.name, "search.capture.name"),
|
|
282
|
+
start_line: requireNumber(captureItem.start_line, "search.capture.start_line"),
|
|
283
|
+
end_line: requireNumber(captureItem.end_line, "search.capture.end_line"),
|
|
284
|
+
start_hash: requireString(captureItem.start_hash, "search.capture.start_hash"),
|
|
285
|
+
end_hash: requireString(captureItem.end_hash, "search.capture.end_hash"),
|
|
286
|
+
hashlines: parseSearchHashlines(captureItem.hashlines, "search.capture.hashlines"),
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
289
|
+
};
|
|
290
|
+
}),
|
|
291
|
+
};
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function readseekRead(filePath: string, startLine: number, endLine: number): Promise<ReadseekReadOutput> {
|
|
297
|
+
return parseReadOutput(await runReadseek([
|
|
298
|
+
"read",
|
|
299
|
+
filePath,
|
|
300
|
+
"--start",
|
|
301
|
+
String(startLine),
|
|
302
|
+
"--end",
|
|
303
|
+
String(endLine),
|
|
304
|
+
]));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function readseekMap(filePath: string, totalBytes: number): Promise<FileMap | null> {
|
|
308
|
+
const output = parseMapOutput(await runReadseek(["map", filePath]));
|
|
309
|
+
if (output.language === "unknown" && output.symbols.length === 0) return null;
|
|
310
|
+
return {
|
|
311
|
+
path: filePath,
|
|
312
|
+
totalLines: output.line_count,
|
|
313
|
+
totalBytes,
|
|
314
|
+
language: normalizeLanguage(output.language),
|
|
315
|
+
detailLevel: DetailLevel.Full,
|
|
316
|
+
imports: [],
|
|
317
|
+
symbols: symbolsFromReadseek(output.symbols),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function readseekSearch(
|
|
322
|
+
target: string,
|
|
323
|
+
pattern: string,
|
|
324
|
+
language?: string,
|
|
325
|
+
signal?: AbortSignal,
|
|
326
|
+
): Promise<ReadseekSearchFileOutput[]> {
|
|
327
|
+
const args = ["search", target, pattern];
|
|
328
|
+
if (language) args.push("--language", language);
|
|
329
|
+
return parseSearchOutput(await runReadseek(args, { signal })).results;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function readseekMapContent(filePath: string, content: string): Promise<FileMap | null> {
|
|
333
|
+
const tempDir = await mkdtemp(path.join(tmpdir(), "readseek-map-"));
|
|
334
|
+
const tempPath = path.join(tempDir, path.basename(filePath) || `content${path.extname(filePath)}` || "content");
|
|
335
|
+
try {
|
|
336
|
+
await writeFile(tempPath, content, "utf8");
|
|
337
|
+
const map = await readseekMap(tempPath, Buffer.byteLength(content, "utf8"));
|
|
338
|
+
if (!map) return null;
|
|
339
|
+
return { ...map, path: filePath };
|
|
340
|
+
} finally {
|
|
341
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* READSEEK error code taxonomy — single source of truth.
|
|
3
|
+
*
|
|
4
|
+
* Every error returned via `readseekValue.error.code` MUST be a key in this map.
|
|
5
|
+
* To add a new error: extend this object with a kebab-case code, a one-line
|
|
6
|
+
* description, and the trigger condition. Codes are stable: renaming is a
|
|
7
|
+
* breaking change for downstream consumers.
|
|
8
|
+
*
|
|
9
|
+
* Distinction:
|
|
10
|
+
* - errors (this file): fatal — the tool could not produce its primary result.
|
|
11
|
+
* - warnings (ReadseekWarning): non-fatal — the tool produced a result but flagged
|
|
12
|
+
* something the agent should know.
|
|
13
|
+
*/
|
|
14
|
+
export const READSEEK_ERROR_CODES = {
|
|
15
|
+
// read
|
|
16
|
+
"file-not-found": { description: "Target file does not exist", trigger: "fs ENOENT on read" },
|
|
17
|
+
"path-is-directory": { description: "Path resolves to a directory, not a file", trigger: "fs EISDIR on read" },
|
|
18
|
+
"permission-denied": { description: "Filesystem refused access", trigger: "fs EACCES or EPERM" },
|
|
19
|
+
"fs-error": { description: "Unexpected filesystem failure outside the specific classified cases", trigger: "non-ENOENT/non-EISDIR/non-EACCES/non-EPERM fs error while reading, writing, or stat'ing a path" },
|
|
20
|
+
"offset-past-end": { description: "Requested offset exceeds file length", trigger: "offset > total lines" },
|
|
21
|
+
"invalid-params-combo": { description: "Mutually exclusive parameters combined", trigger: "e.g. symbol + offset, bundle + map, map + symbol" },
|
|
22
|
+
"invalid-offset": { description: "offset is not a positive integer", trigger: "non-int or value < 1" },
|
|
23
|
+
"invalid-limit": { description: "limit is not a positive integer", trigger: "non-int or value < 1" },
|
|
24
|
+
|
|
25
|
+
// edit
|
|
26
|
+
"file-not-read": { description: "edit called on a path that was not read in this session", trigger: "wasReadInSession returned false" },
|
|
27
|
+
"hash-mismatch": { description: "edit anchors do not verify against current file contents", trigger: "applyHashlineEdits detected stale anchors" },
|
|
28
|
+
"no-op": { description: "edits produced identical content", trigger: "originalNormalized === result after applying edits" },
|
|
29
|
+
"text-not-found": { description: "replace.old_text not present in file", trigger: "replaceText returned 0 matches" },
|
|
30
|
+
"binary-file": { description: "edit refused because file is binary", trigger: "isBinaryBuffer detected NUL bytes" },
|
|
31
|
+
"invalid-edit-variant": { description: "edits[i] is not exactly one of set_line/replace_lines/insert_after/replace", trigger: "exactly-one variant check failed" },
|
|
32
|
+
|
|
33
|
+
// grep
|
|
34
|
+
"binary-file-target": { description: "grep target is a binary file", trigger: "explicit binary path supplied" },
|
|
35
|
+
"passthrough-unparsed": { description: "builtin grep result format unrecognized", trigger: "passthrough warning from underlying tool" },
|
|
36
|
+
|
|
37
|
+
// search
|
|
38
|
+
"readseek-not-installed": { description: "readseek CLI is not installed", trigger: "ENOENT on `readseek` invocation or bundled package resolution failure" },
|
|
39
|
+
"readseek-execution-error": { description: "readseek search exited with an error", trigger: "non-zero exit or invalid search output" },
|
|
40
|
+
|
|
41
|
+
// find / ls (shared shape)
|
|
42
|
+
"path-not-found": { description: "search/target path does not exist", trigger: "stat failed on path" },
|
|
43
|
+
"path-not-directory": { description: "path is a file, not a directory", trigger: "stat returned non-directory" },
|
|
44
|
+
|
|
45
|
+
// write
|
|
46
|
+
"binary-content": { description: "content written to write tool looks binary", trigger: "looksLikeBinary on supplied content" },
|
|
47
|
+
|
|
48
|
+
"syntax-regression": {
|
|
49
|
+
description: "edit introduced new tree-sitter syntax errors compared to pre-edit content",
|
|
50
|
+
trigger: "post-write validator detected net-new ERROR or MISSING nodes (block mode)",
|
|
51
|
+
},
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export type ReadseekErrorCode = keyof typeof READSEEK_ERROR_CODES;
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface ReadseekJsonSettings {
|
|
6
|
+
grep?: { maxLines?: number; maxBytes?: number };
|
|
7
|
+
mapCache?: { dir?: string; enabled?: boolean };
|
|
8
|
+
edit?: { diffDisplay?: "collapsed" | "expanded" };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReadseekSettingsWarning {
|
|
12
|
+
source: string;
|
|
13
|
+
message: string;
|
|
14
|
+
path?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ReadseekSettingsResult {
|
|
18
|
+
settings: ReadseekJsonSettings;
|
|
19
|
+
warnings: ReadseekSettingsWarning[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let pathOverride: { globalSettingsPath?: string; projectSettingsPath?: string } | null = null;
|
|
23
|
+
|
|
24
|
+
export function __setReadseekSettingsPathsForTest(paths: {
|
|
25
|
+
globalSettingsPath?: string;
|
|
26
|
+
projectSettingsPath?: string;
|
|
27
|
+
}): void {
|
|
28
|
+
pathOverride = { ...paths };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function __resetReadseekSettingsPathsForTest(): void {
|
|
32
|
+
pathOverride = null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultGlobalSettingsPath(): string {
|
|
36
|
+
return join(homedir(), ".pi/agent/readseek/settings.json");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultProjectSettingsPath(): string {
|
|
40
|
+
return join(process.cwd(), ".pi/readseek/settings.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function invalid(source: string, path: string): ReadseekSettingsWarning {
|
|
48
|
+
return { source, path, message: `Invalid readseek setting at ${path}` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readJsonObjectEnd(text: string, open: number): number {
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let inString = false;
|
|
54
|
+
let escaped = false;
|
|
55
|
+
|
|
56
|
+
for (let i = open; i < text.length; i += 1) {
|
|
57
|
+
const char = text[i];
|
|
58
|
+
if (inString) {
|
|
59
|
+
if (escaped) escaped = false;
|
|
60
|
+
else if (char === "\\") escaped = true;
|
|
61
|
+
else if (char === '"') inString = false;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (char === '"') {
|
|
66
|
+
inString = true;
|
|
67
|
+
} else if (char === "{") {
|
|
68
|
+
depth += 1;
|
|
69
|
+
} else if (char === "}") {
|
|
70
|
+
depth -= 1;
|
|
71
|
+
if (depth === 0) return i;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readJsonStringEnd(text: string, quote: number): number {
|
|
79
|
+
let escaped = false;
|
|
80
|
+
for (let i = quote + 1; i < text.length; i += 1) {
|
|
81
|
+
const char = text[i];
|
|
82
|
+
if (escaped) escaped = false;
|
|
83
|
+
else if (char === "\\") escaped = true;
|
|
84
|
+
else if (char === '"') return i;
|
|
85
|
+
}
|
|
86
|
+
return -1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readTopLevelObjectBodies(rawText: string, section: string): string[] {
|
|
90
|
+
const bodies: string[] = [];
|
|
91
|
+
let depth = 0;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < rawText.length; i += 1) {
|
|
94
|
+
const char = rawText[i];
|
|
95
|
+
if (char === '"') {
|
|
96
|
+
const end = readJsonStringEnd(rawText, i);
|
|
97
|
+
if (end < 0) return bodies;
|
|
98
|
+
|
|
99
|
+
if (depth === 1) {
|
|
100
|
+
const fieldName = rawText.slice(i + 1, end);
|
|
101
|
+
let cursor = end + 1;
|
|
102
|
+
while (/\s/.test(rawText[cursor] ?? "")) cursor += 1;
|
|
103
|
+
|
|
104
|
+
if (rawText[cursor] === ":") {
|
|
105
|
+
cursor += 1;
|
|
106
|
+
while (/\s/.test(rawText[cursor] ?? "")) cursor += 1;
|
|
107
|
+
if (fieldName === section && rawText[cursor] === "{") {
|
|
108
|
+
const objectEnd = readJsonObjectEnd(rawText, cursor);
|
|
109
|
+
if (objectEnd < 0) return bodies;
|
|
110
|
+
bodies.push(rawText.slice(cursor + 1, objectEnd));
|
|
111
|
+
i = objectEnd;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
i = end;
|
|
117
|
+
} else if (char === "{") {
|
|
118
|
+
depth += 1;
|
|
119
|
+
} else if (char === "}") {
|
|
120
|
+
depth = Math.max(0, depth - 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return bodies;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rawFieldTokens(rawText: string, path: string): string[] {
|
|
128
|
+
const [section, key] = path.split(".");
|
|
129
|
+
const bodies = readTopLevelObjectBodies(rawText, section);
|
|
130
|
+
if (bodies.length !== 1) return [];
|
|
131
|
+
|
|
132
|
+
const body = bodies[0];
|
|
133
|
+
const tokens: string[] = [];
|
|
134
|
+
let depth = 0;
|
|
135
|
+
|
|
136
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
137
|
+
const char = body[i];
|
|
138
|
+
if (char === '"') {
|
|
139
|
+
const end = readJsonStringEnd(body, i);
|
|
140
|
+
if (end < 0) return tokens;
|
|
141
|
+
|
|
142
|
+
if (depth === 0) {
|
|
143
|
+
const fieldName = body.slice(i + 1, end);
|
|
144
|
+
let cursor = end + 1;
|
|
145
|
+
while (/\s/.test(body[cursor] ?? "")) cursor += 1;
|
|
146
|
+
|
|
147
|
+
if (body[cursor] === ":") {
|
|
148
|
+
cursor += 1;
|
|
149
|
+
while (/\s/.test(body[cursor] ?? "")) cursor += 1;
|
|
150
|
+
if (fieldName === key) {
|
|
151
|
+
const valueStart = cursor;
|
|
152
|
+
while (cursor < body.length && !/[,}\s]/.test(body[cursor])) cursor += 1;
|
|
153
|
+
tokens.push(body.slice(valueStart, cursor));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
i = end;
|
|
158
|
+
} else if (char === "{" || char === "[") {
|
|
159
|
+
depth += 1;
|
|
160
|
+
} else if (char === "}" || char === "]") {
|
|
161
|
+
depth = Math.max(0, depth - 1);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return tokens;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isStrictJsonPositiveInteger(rawText: string, path: string, value: unknown): value is number {
|
|
169
|
+
const tokens = rawFieldTokens(rawText, path);
|
|
170
|
+
return (
|
|
171
|
+
typeof value === "number" &&
|
|
172
|
+
Number.isSafeInteger(value) &&
|
|
173
|
+
value > 0 &&
|
|
174
|
+
tokens.length === 1 &&
|
|
175
|
+
/^[1-9][0-9]*$/.test(tokens[0])
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readPositive(
|
|
180
|
+
raw: Record<string, unknown>,
|
|
181
|
+
key: string,
|
|
182
|
+
path: string,
|
|
183
|
+
source: string,
|
|
184
|
+
rawText: string,
|
|
185
|
+
warnings: ReadseekSettingsWarning[],
|
|
186
|
+
): number | undefined {
|
|
187
|
+
if (!(key in raw)) return undefined;
|
|
188
|
+
if (isStrictJsonPositiveInteger(rawText, path, raw[key])) return raw[key];
|
|
189
|
+
warnings.push(invalid(source, path));
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readBoolean(
|
|
194
|
+
raw: Record<string, unknown>,
|
|
195
|
+
key: string,
|
|
196
|
+
path: string,
|
|
197
|
+
source: string,
|
|
198
|
+
warnings: ReadseekSettingsWarning[],
|
|
199
|
+
): boolean | undefined {
|
|
200
|
+
if (!(key in raw)) return undefined;
|
|
201
|
+
if (typeof raw[key] === "boolean") return raw[key];
|
|
202
|
+
warnings.push(invalid(source, path));
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateSettings(raw: unknown, source: string, rawText: string): ReadseekSettingsResult {
|
|
207
|
+
const settings: ReadseekJsonSettings = {};
|
|
208
|
+
const warnings: ReadseekSettingsWarning[] = [];
|
|
209
|
+
if (!isRecord(raw)) return { settings, warnings };
|
|
210
|
+
|
|
211
|
+
if (isRecord(raw.grep)) {
|
|
212
|
+
const grep: NonNullable<ReadseekJsonSettings["grep"]> = {};
|
|
213
|
+
const maxLines = readPositive(raw.grep, "maxLines", "grep.maxLines", source, rawText, warnings);
|
|
214
|
+
if (maxLines !== undefined) grep.maxLines = maxLines;
|
|
215
|
+
const maxBytes = readPositive(raw.grep, "maxBytes", "grep.maxBytes", source, rawText, warnings);
|
|
216
|
+
if (maxBytes !== undefined) grep.maxBytes = maxBytes;
|
|
217
|
+
if (Object.keys(grep).length > 0) settings.grep = grep;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (isRecord(raw.mapCache)) {
|
|
221
|
+
const mapCache: NonNullable<ReadseekJsonSettings["mapCache"]> = {};
|
|
222
|
+
if ("dir" in raw.mapCache) {
|
|
223
|
+
if (typeof raw.mapCache.dir === "string" && raw.mapCache.dir.length > 0) mapCache.dir = raw.mapCache.dir;
|
|
224
|
+
else warnings.push(invalid(source, "mapCache.dir"));
|
|
225
|
+
}
|
|
226
|
+
const enabled = readBoolean(raw.mapCache, "enabled", "mapCache.enabled", source, warnings);
|
|
227
|
+
if (enabled !== undefined) mapCache.enabled = enabled;
|
|
228
|
+
if (Object.keys(mapCache).length > 0) settings.mapCache = mapCache;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (isRecord(raw.edit)) {
|
|
232
|
+
const edit: NonNullable<ReadseekJsonSettings["edit"]> = {};
|
|
233
|
+
if ("diffDisplay" in raw.edit) {
|
|
234
|
+
const value = raw.edit.diffDisplay;
|
|
235
|
+
if (value === "collapsed" || value === "expanded") edit.diffDisplay = value;
|
|
236
|
+
else warnings.push(invalid(source, "edit.diffDisplay"));
|
|
237
|
+
}
|
|
238
|
+
if (Object.keys(edit).length > 0) settings.edit = edit;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return { settings, warnings };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readSettingsFile(path: string): ReadseekSettingsResult {
|
|
245
|
+
if (!existsSync(path)) return { settings: {}, warnings: [] };
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
const text = readFileSync(path, "utf8");
|
|
249
|
+
return validateSettings(JSON.parse(text) as unknown, path, text);
|
|
250
|
+
} catch (error) {
|
|
251
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
252
|
+
return { settings: {}, warnings: [{ source: path, message: `Invalid JSON: ${message}` }] };
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function mergeSettings(base: ReadseekJsonSettings, override: ReadseekJsonSettings): ReadseekJsonSettings {
|
|
257
|
+
const merged: ReadseekJsonSettings = {};
|
|
258
|
+
const grep = { ...(base.grep ?? {}), ...(override.grep ?? {}) };
|
|
259
|
+
if (Object.keys(grep).length > 0) merged.grep = grep;
|
|
260
|
+
const mapCache = { ...(base.mapCache ?? {}), ...(override.mapCache ?? {}) };
|
|
261
|
+
if (Object.keys(mapCache).length > 0) merged.mapCache = mapCache;
|
|
262
|
+
const edit = { ...(base.edit ?? {}), ...(override.edit ?? {}) };
|
|
263
|
+
if (Object.keys(edit).length > 0) merged.edit = edit;
|
|
264
|
+
return merged;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function resolveReadseekJsonSettings(): ReadseekSettingsResult {
|
|
268
|
+
const globalPath = pathOverride?.globalSettingsPath ?? defaultGlobalSettingsPath();
|
|
269
|
+
const projectPath = pathOverride?.projectSettingsPath ?? defaultProjectSettingsPath();
|
|
270
|
+
const globalResult = readSettingsFile(globalPath);
|
|
271
|
+
const projectResult = readSettingsFile(projectPath);
|
|
272
|
+
return {
|
|
273
|
+
settings: mergeSettings(globalResult.settings, projectResult.settings),
|
|
274
|
+
warnings: [...globalResult.warnings, ...projectResult.warnings],
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function resolveEditDiffDisplay(env: NodeJS.ProcessEnv = process.env): "collapsed" | "expanded" {
|
|
279
|
+
const raw = env.PI_HASHLINE_EDIT_DIFF_DISPLAY;
|
|
280
|
+
if (typeof raw === "string") {
|
|
281
|
+
const normalized = raw.trim().toLowerCase();
|
|
282
|
+
if (normalized === "expanded" || normalized === "collapsed") return normalized;
|
|
283
|
+
}
|
|
284
|
+
const json = resolveReadseekJsonSettings().settings.edit?.diffDisplay;
|
|
285
|
+
if (json === "expanded" || json === "collapsed") return json;
|
|
286
|
+
return "collapsed";
|
|
287
|
+
}
|