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.
Files changed (67) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +41 -0
  3. package/index.ts +142 -0
  4. package/package.json +73 -0
  5. package/prompts/edit.md +113 -0
  6. package/prompts/find.md +19 -0
  7. package/prompts/grep.md +26 -0
  8. package/prompts/ls.md +11 -0
  9. package/prompts/read.md +33 -0
  10. package/prompts/sg.md +25 -0
  11. package/prompts/write.md +46 -0
  12. package/src/binary-detect.ts +22 -0
  13. package/src/binary-resolution.ts +77 -0
  14. package/src/coerce-obvious-int.ts +39 -0
  15. package/src/context-application.ts +70 -0
  16. package/src/context-hygiene.ts +503 -0
  17. package/src/diff-data.ts +303 -0
  18. package/src/doom-loop-suggestions.ts +42 -0
  19. package/src/doom-loop.ts +216 -0
  20. package/src/edit-classify.ts +190 -0
  21. package/src/edit-diff.ts +354 -0
  22. package/src/edit-output.ts +107 -0
  23. package/src/edit-render-helpers.ts +141 -0
  24. package/src/edit-syntax-validate.ts +120 -0
  25. package/src/edit.ts +725 -0
  26. package/src/find-parsers.ts +89 -0
  27. package/src/find-stat.ts +36 -0
  28. package/src/find.ts +613 -0
  29. package/src/grep-budget.ts +79 -0
  30. package/src/grep-output.ts +197 -0
  31. package/src/grep-render-helpers.ts +77 -0
  32. package/src/grep-symbol-scope.ts +197 -0
  33. package/src/grep.ts +792 -0
  34. package/src/hashline.ts +747 -0
  35. package/src/ls.ts +293 -0
  36. package/src/map-cache.ts +152 -0
  37. package/src/path-utils.ts +24 -0
  38. package/src/pending-diff-preview.ts +269 -0
  39. package/src/persistent-map-cache.ts +251 -0
  40. package/src/read-local-bundle.ts +87 -0
  41. package/src/read-output.ts +212 -0
  42. package/src/read-render-helpers.ts +104 -0
  43. package/src/read.ts +748 -0
  44. package/src/readseek/constants.ts +21 -0
  45. package/src/readseek/enums.ts +38 -0
  46. package/src/readseek/formatter.ts +431 -0
  47. package/src/readseek/language-detect.ts +29 -0
  48. package/src/readseek/mapper.ts +69 -0
  49. package/src/readseek/parser-errors.ts +22 -0
  50. package/src/readseek/parser-loader.ts +83 -0
  51. package/src/readseek/symbol-error-format.ts +18 -0
  52. package/src/readseek/symbol-lookup.ts +294 -0
  53. package/src/readseek/types.ts +79 -0
  54. package/src/readseek-client.ts +343 -0
  55. package/src/readseek-error-codes.ts +54 -0
  56. package/src/readseek-settings.ts +287 -0
  57. package/src/readseek-value.ts +144 -0
  58. package/src/replace-symbol.ts +74 -0
  59. package/src/runtime.ts +3 -0
  60. package/src/sg-output.ts +88 -0
  61. package/src/sg.ts +308 -0
  62. package/src/syntax-validate-mode.ts +25 -0
  63. package/src/tool-prompt-metadata.ts +76 -0
  64. package/src/tui-diff-component.ts +86 -0
  65. package/src/tui-diff-renderer.ts +92 -0
  66. package/src/tui-render-utils.ts +129 -0
  67. package/src/write.ts +532 -0
@@ -0,0 +1,144 @@
1
+ import { computeLineHash, escapeControlCharsForDisplay } from "./hashline.js";
2
+ import type { DiffData } from "./diff-data.js";
3
+
4
+ export interface ReadseekLine {
5
+ line: number;
6
+ hash: string;
7
+ anchor: string;
8
+ raw: string;
9
+ display: string;
10
+ }
11
+
12
+ export interface ReadseekWarningSymbol {
13
+ name: string;
14
+ kind: string;
15
+ startLine: number;
16
+ endLine: number;
17
+ parentName?: string;
18
+ }
19
+ export interface ReadseekWarning {
20
+ code: string;
21
+ message: string;
22
+ tier?: "camelCase" | "substring";
23
+ symbol?: ReadseekWarningSymbol;
24
+ otherCandidates?: ReadseekWarningSymbol[];
25
+ }
26
+
27
+ export interface ReadseekError {
28
+ code: string;
29
+ message: string;
30
+ hint?: string;
31
+ details?: unknown;
32
+ }
33
+
34
+ export interface ReadseekRange {
35
+ startLine: number;
36
+ endLine: number;
37
+ totalLines?: number;
38
+ }
39
+
40
+ export interface ReadseekFileGroup {
41
+ path: string;
42
+ ranges: ReadseekRange[];
43
+ lines: ReadseekLine[];
44
+ }
45
+
46
+ export interface SemanticSummary {
47
+ classification: "no-op" | "whitespace-only" | "semantic" | "mixed";
48
+ difftasticAvailable: boolean;
49
+ movedBlocks?: number;
50
+ }
51
+ export interface ReadseekEditResult {
52
+ tool: "edit";
53
+ ok: boolean;
54
+ path: string;
55
+ summary: string;
56
+ diff: string;
57
+ diffData?: DiffData;
58
+ firstChangedLine: number | undefined;
59
+ warnings: string[];
60
+ noopEdits: unknown[];
61
+ semanticSummary?: SemanticSummary;
62
+ }
63
+
64
+ export function buildReadseekLine(line: number, raw: string): ReadseekLine {
65
+ const hash = computeLineHash(line, raw);
66
+ return {
67
+ line,
68
+ hash,
69
+ anchor: `${line}:${hash}`,
70
+ raw,
71
+ display: escapeControlCharsForDisplay(raw),
72
+ };
73
+ }
74
+
75
+ export function buildReadseekLines(startLine: number, rawLines: string[]): ReadseekLine[] {
76
+ return rawLines.map((raw, index) => buildReadseekLine(startLine + index, raw));
77
+ }
78
+
79
+ export function renderReadseekLine(line: ReadseekLine): string {
80
+ return `${line.anchor}|${line.display}`;
81
+ }
82
+
83
+ export function renderReadseekLines(lines: ReadseekLine[]): string {
84
+ return lines.map(renderReadseekLine).join("\n");
85
+ }
86
+
87
+ export function buildReadseekWarning(
88
+ code: string,
89
+ message: string,
90
+ metadata: Omit<ReadseekWarning, "code" | "message"> = {},
91
+ ): ReadseekWarning {
92
+ return { code, message, ...metadata };
93
+ }
94
+
95
+ export function buildReadseekError(
96
+ code: string,
97
+ message: string,
98
+ hint?: string,
99
+ details?: unknown,
100
+ ): ReadseekError {
101
+ return {
102
+ code,
103
+ message,
104
+ ...(hint !== undefined ? { hint } : {}),
105
+ ...(details !== undefined ? { details } : {}),
106
+ };
107
+ }
108
+
109
+ export function buildReadseekRange(startLine: number, endLine: number, totalLines?: number): ReadseekRange {
110
+ return totalLines === undefined ? { startLine, endLine } : { startLine, endLine, totalLines };
111
+ }
112
+
113
+ export function buildReadseekFileGroup(path: string, ranges: ReadseekRange[], lines: ReadseekLine[]): ReadseekFileGroup {
114
+ return {
115
+ path,
116
+ ranges: ranges.map((range) => ({ ...range })),
117
+ lines: lines.map((line) => ({ ...line })),
118
+ };
119
+ }
120
+
121
+ export function buildReadseekEditResult(input: {
122
+ ok?: boolean;
123
+ path: string;
124
+ summary: string;
125
+ diff: string;
126
+ diffData?: DiffData;
127
+ firstChangedLine: number | undefined;
128
+ warnings: string[];
129
+ noopEdits: unknown[];
130
+ semanticSummary?: SemanticSummary;
131
+ }): ReadseekEditResult {
132
+ return {
133
+ tool: "edit",
134
+ ok: input.ok ?? true,
135
+ path: input.path,
136
+ summary: input.summary,
137
+ diff: input.diff,
138
+ ...(input.diffData ? { diffData: input.diffData } : {}),
139
+ firstChangedLine: input.firstChangedLine,
140
+ warnings: [...input.warnings],
141
+ noopEdits: [...input.noopEdits],
142
+ ...(input.semanticSummary ? { semanticSummary: input.semanticSummary } : {}),
143
+ };
144
+ }
@@ -0,0 +1,74 @@
1
+ import { generateMapFromContent } from "./readseek/mapper.js";
2
+ import { findSymbol } from "./readseek/symbol-lookup.js";
3
+ import { formatAmbiguous, formatNotFound } from "./readseek/symbol-error-format.js";
4
+
5
+ export interface ReplaceSymbolInput {
6
+ filePath: string;
7
+ content: string;
8
+ symbol: string;
9
+ newBody: string;
10
+ }
11
+
12
+ export type ReplaceSymbolResult =
13
+ | { type: "ok"; content: string; replacement: string; warnings: string[]; range: { start: number; end: number } }
14
+ | { type: "not-found"; message: string }
15
+ | { type: "ambiguous"; message: string }
16
+ | { type: "unsupported"; message: string };
17
+
18
+ function detectIndent(line: string): string {
19
+ return line.match(/^\s*/)?.[0] ?? "";
20
+ }
21
+
22
+ function dedent(text: string): string {
23
+ const lines = text.split("\n");
24
+ const nonEmpty = lines.filter((l) => l.trim().length);
25
+ if (!nonEmpty.length) return text;
26
+ const minIndent = Math.min(...nonEmpty.map((l) => l.match(/^\s*/)?.[0].length ?? 0));
27
+ return lines.map((l) => l.slice(minIndent)).join("\n");
28
+ }
29
+
30
+ function reindent(text: string, indent: string): string {
31
+ return text.split("\n").map((l) => (l.length ? indent + l : l)).join("\n");
32
+ }
33
+
34
+ export async function replaceSymbol(input: ReplaceSymbolInput): Promise<ReplaceSymbolResult> {
35
+ const map = await generateMapFromContent(input.filePath, input.content);
36
+ if (!map) {
37
+ return {
38
+ type: "unsupported",
39
+ message: `Unsupported file for replace_symbol: readseek could not map '${input.filePath}'. Use anchored edits for this file type.`,
40
+ };
41
+ }
42
+ const lookup = findSymbol(map, input.symbol);
43
+ if (lookup.type === "not-found") {
44
+ return { type: "not-found", message: formatNotFound(input.symbol, map) };
45
+ }
46
+ if (lookup.type === "ambiguous") {
47
+ return { type: "ambiguous", message: formatAmbiguous(input.symbol, lookup.candidates) };
48
+ }
49
+ const sym = lookup.symbol;
50
+ const lines = input.content.split("\n");
51
+ const sigLine = lines[sym.startLine - 1] ?? "";
52
+ const indent = detectIndent(sigLine);
53
+ const reindented = reindent(dedent(input.newBody), indent);
54
+ const warnings: string[] = [];
55
+ const leaf = input.symbol.replace(/@\d+$/, "").split(".").pop() ?? "";
56
+ const firstDeclName =
57
+ reindented.match(/\b(?:function|class|method|const|let|var)\s+([A-Za-z_$][\w$]*)/)?.[1]
58
+ ?? reindented.match(/^\s*(?:[\w$<>,?\s]+\s+)?([A-Za-z_$][\w$]*)\s*\(/)?.[1];
59
+ if (leaf && firstDeclName && firstDeclName !== leaf) {
60
+ warnings.push(`name-mismatch: expected ${leaf}, got ${firstDeclName}`);
61
+ }
62
+ const before = lines.slice(0, sym.startLine - 1).join("\n");
63
+ const after = lines.slice(sym.endLine).join("\n");
64
+ const beforePart = before.length ? before + "\n" : "";
65
+ const afterPart = after.length ? "\n" + after : "";
66
+ const newContent = beforePart + reindented + afterPart;
67
+ return {
68
+ type: "ok",
69
+ content: newContent,
70
+ replacement: reindented,
71
+ warnings,
72
+ range: { start: sym.startLine, end: sym.endLine },
73
+ };
74
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,3 @@
1
+ export function throwIfAborted(signal?: AbortSignal): void {
2
+ if (signal?.aborted) throw new Error("Operation aborted");
3
+ }
@@ -0,0 +1,88 @@
1
+ import type { ReadseekLine, ReadseekRange } from "./readseek-value.js";
2
+ import {
3
+ buildContextHygieneMetadata,
4
+ buildFileResource,
5
+ buildSymbolResource,
6
+ type ContextHygieneMetadata,
7
+ type ContextHygieneRehydrateDescriptor,
8
+ type ContextHygieneResource,
9
+ } from "./context-hygiene.js";
10
+
11
+ export interface SgOutputFile {
12
+ displayPath: string;
13
+ path: string;
14
+ ranges: ReadseekRange[];
15
+ lines: ReadseekLine[];
16
+ symbols?: Array<{ name: string; kind?: string }>;
17
+ }
18
+
19
+ export interface BuildSgOutputInput {
20
+ pattern: string;
21
+ files: SgOutputFile[];
22
+ rehydrate?: ContextHygieneRehydrateDescriptor | null;
23
+ }
24
+
25
+ export interface SgOutputResult {
26
+ text: string;
27
+ readseekValue: {
28
+ tool: "search";
29
+ files: Array<{
30
+ path: string;
31
+ ranges: ReadseekRange[];
32
+ lines: ReadseekLine[];
33
+ }>;
34
+ };
35
+ contextHygiene: ContextHygieneMetadata;
36
+ }
37
+
38
+ export function buildSgOutput(input: BuildSgOutputInput): SgOutputResult {
39
+ if (input.files.length === 0) {
40
+ return {
41
+ text: `No matches found for pattern: ${input.pattern}`,
42
+ readseekValue: {
43
+ tool: "search",
44
+ files: [],
45
+ },
46
+ contextHygiene: buildContextHygieneMetadata({
47
+ tool: "search",
48
+ classification: "search-context",
49
+ resources: [],
50
+ rehydrate: input.rehydrate ?? undefined,
51
+ }),
52
+ };
53
+ }
54
+
55
+ const blocks: string[] = [];
56
+ for (const file of input.files) {
57
+ blocks.push(`--- ${file.displayPath} ---`);
58
+ for (const line of file.lines) {
59
+ blocks.push(`>>${line.anchor}|${line.display}`);
60
+ }
61
+ }
62
+
63
+ const contextHygieneResources: ContextHygieneResource[] = [];
64
+ for (const file of input.files) {
65
+ contextHygieneResources.push(buildFileResource(file.path));
66
+ for (const symbol of file.symbols ?? []) {
67
+ contextHygieneResources.push(buildSymbolResource(file.path, symbol.name, symbol.kind));
68
+ }
69
+ }
70
+
71
+ return {
72
+ text: blocks.join("\n"),
73
+ readseekValue: {
74
+ tool: "search",
75
+ files: input.files.map((file) => ({
76
+ path: file.path,
77
+ ranges: file.ranges.map((range) => ({ ...range })),
78
+ lines: file.lines.map((line) => ({ ...line })),
79
+ })),
80
+ },
81
+ contextHygiene: buildContextHygieneMetadata({
82
+ tool: "search",
83
+ classification: "search-context",
84
+ resources: contextHygieneResources,
85
+ rehydrate: input.rehydrate ?? undefined,
86
+ }),
87
+ };
88
+ }
package/src/sg.ts ADDED
@@ -0,0 +1,308 @@
1
+ import type { ExtensionAPI, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "@sinclair/typebox";
4
+ import path from "node:path";
5
+ import { stat as fsStat } from "node:fs/promises";
6
+ import { defineToolPromptMetadata } from "./tool-prompt-metadata.js";
7
+ import { ensureHashInit } from "./hashline.js";
8
+ import { buildReadseekError, buildReadseekLine, type ReadseekLine } from "./readseek-value.js";
9
+ import { resolveToCwd } from "./path-utils.js";
10
+ import { isReadseekAvailable, readseekSearch, type ReadseekSearchFileOutput } from "./readseek-client.js";
11
+ import { buildSgOutput } from "./sg-output.js";
12
+ import { buildSearchRehydrateDescriptor } from "./context-hygiene.js";
13
+ import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, renderToolLabel, summaryLine } from "./tui-render-utils.js";
14
+
15
+ type SgParams = { pattern: string; lang?: string; path?: string };
16
+
17
+ export interface SgRange {
18
+ startLine: number;
19
+ endLine: number;
20
+ }
21
+
22
+ export interface SgEnclosingSymbol {
23
+ name: string;
24
+ kind: string;
25
+ }
26
+
27
+ export function mergeRanges(ranges: SgRange[]): SgRange[] {
28
+ if (ranges.length === 0) return [];
29
+ if (ranges.length === 1) return [{ ...ranges[0] }];
30
+
31
+ const sorted = [...ranges].sort((a, b) => a.startLine - b.startLine);
32
+ const merged: SgRange[] = [{ ...sorted[0] }];
33
+
34
+ for (let i = 1; i < sorted.length; i++) {
35
+ const current = sorted[i];
36
+ const last = merged[merged.length - 1];
37
+ if (current.startLine <= last.endLine + 2) {
38
+ last.endLine = Math.max(last.endLine, current.endLine);
39
+ } else {
40
+ merged.push({ ...current });
41
+ }
42
+ }
43
+
44
+ return merged;
45
+ }
46
+
47
+ const SG_PROMPT_METADATA = defineToolPromptMetadata({
48
+ promptUrl: new URL("../prompts/sg.md", import.meta.url),
49
+ promptSnippet: "Search code structurally with readseek and return edit-ready anchors",
50
+ promptGuidelines: [
51
+ "Use search when text search is too broad or brittle and the query depends on code shape.",
52
+ "Use search for calls, imports, declarations, JSX, and similar syntax patterns.",
53
+ "Use grep instead of search for plain text search.",
54
+ ],
55
+ });
56
+
57
+ export function isSgAvailable(): boolean {
58
+ return isReadseekAvailable();
59
+ }
60
+
61
+ interface SgToolOptions {
62
+ onFileAnchored?: (absolutePath: string) => void;
63
+ }
64
+
65
+ function readseekLineFromSearch(line: { line: number; text: string }): ReadseekLine {
66
+ return buildReadseekLine(line.line, line.text);
67
+ }
68
+
69
+ function linesFromSearchResult(result: ReadseekSearchFileOutput, ranges: SgRange[]): ReadseekLine[] {
70
+ const lineMap = new Map<number, ReadseekLine>();
71
+ for (const match of result.matches) {
72
+ for (const line of match.hashlines) {
73
+ lineMap.set(line.line, readseekLineFromSearch(line));
74
+ }
75
+ }
76
+
77
+ const lines: ReadseekLine[] = [];
78
+ const seen = new Set<number>();
79
+ for (const range of ranges) {
80
+ for (let line = range.startLine; line <= range.endLine; line++) {
81
+ if (seen.has(line)) continue;
82
+ const readseekLine = lineMap.get(line);
83
+ if (!readseekLine) continue;
84
+ seen.add(line);
85
+ lines.push(readseekLine);
86
+ }
87
+ }
88
+ return lines;
89
+ }
90
+
91
+ function readseekLanguageForPath(language: string | undefined, searchPath: string, isFile: boolean): string | undefined {
92
+ if (language === "typescript" && isFile && path.extname(searchPath).toLowerCase() === ".tsx") return "tsx";
93
+ return language;
94
+ }
95
+
96
+ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
97
+ const toolConfig = {
98
+ callable: true,
99
+ enabled: true,
100
+ policy: "read-only" as const,
101
+ readOnly: true,
102
+ pythonName: "search",
103
+ defaultExposure: "opt-in" as const,
104
+ };
105
+
106
+ const tool = {
107
+ name: "search",
108
+ label: "Structural Search",
109
+ description: SG_PROMPT_METADATA.description,
110
+ promptSnippet: SG_PROMPT_METADATA.promptSnippet,
111
+ promptGuidelines: SG_PROMPT_METADATA.promptGuidelines,
112
+ parameters: Type.Object({
113
+ pattern: Type.String({ description: "AST pattern" }),
114
+ lang: Type.Optional(Type.String({ description: "Language hint" })),
115
+ path: Type.Optional(Type.String({ description: "Search path" })),
116
+ }),
117
+ ptc: toolConfig,
118
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
119
+ await ensureHashInit();
120
+ const p = params as SgParams;
121
+ const rehydrate = buildSearchRehydrateDescriptor({
122
+ pattern: p.pattern,
123
+ lang: p.lang,
124
+ path: p.path,
125
+ });
126
+
127
+ const searchPath = resolveToCwd(p.path ?? ".", ctx.cwd);
128
+ let searchPathIsFile = false;
129
+
130
+ try {
131
+ const stat = await fsStat(searchPath);
132
+ searchPathIsFile = stat.isFile();
133
+ } catch (err: any) {
134
+ if (err?.code === "ENOENT") {
135
+ const message = `Error: path '${p.path ?? "."}' does not exist`;
136
+ return {
137
+ content: [{ type: "text", text: message }],
138
+ isError: true,
139
+ details: {
140
+ readseekValue: {
141
+ tool: "search",
142
+ ok: false,
143
+ path: p.path ?? searchPath,
144
+ error: buildReadseekError("path-not-found", message),
145
+ },
146
+ },
147
+ };
148
+ }
149
+ if (err?.code === "EACCES" || err?.code === "EPERM") {
150
+ const message = `Error: permission denied for path '${p.path ?? "."}'`;
151
+ return {
152
+ content: [{ type: "text", text: message }],
153
+ isError: true,
154
+ details: {
155
+ readseekValue: {
156
+ tool: "search",
157
+ ok: false,
158
+ path: p.path ?? searchPath,
159
+ error: buildReadseekError("permission-denied", message),
160
+ },
161
+ },
162
+ };
163
+ }
164
+ const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
165
+ return {
166
+ content: [{ type: "text", text: message }],
167
+ isError: true,
168
+ details: {
169
+ readseekValue: {
170
+ tool: "search",
171
+ ok: false,
172
+ path: p.path ?? searchPath,
173
+ error: buildReadseekError("fs-error", message, undefined, { fsCode: err?.code, fsMessage: err?.message }),
174
+ },
175
+ },
176
+ };
177
+ }
178
+
179
+ try {
180
+ const effectiveLang = readseekLanguageForPath(p.lang, searchPath, searchPathIsFile);
181
+ const results = await readseekSearch(searchPath, p.pattern, effectiveLang, signal);
182
+ if (results.length === 0) {
183
+ const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [], rehydrate });
184
+ return {
185
+ content: [{ type: "text", text: emptyOutput.text }],
186
+ details: {
187
+ readseekValue: emptyOutput.readseekValue,
188
+ contextHygiene: emptyOutput.contextHygiene,
189
+ },
190
+ };
191
+ }
192
+
193
+ const readseekFiles: Array<{
194
+ displayPath: string;
195
+ path: string;
196
+ ranges: SgRange[];
197
+ lines: ReadseekLine[];
198
+ symbols?: SgEnclosingSymbol[];
199
+ }> = [];
200
+
201
+ for (const result of results) {
202
+ const abs = path.isAbsolute(result.file) ? result.file : path.resolve(ctx.cwd, result.file);
203
+ const display = path.relative(ctx.cwd, abs) || abs;
204
+ const ranges = result.matches.map((match) => ({ startLine: match.start_line, endLine: match.end_line }));
205
+ const mergedRanges = mergeRanges(ranges);
206
+ const lines = linesFromSearchResult(result, mergedRanges);
207
+ if (lines.length === 0) continue;
208
+ readseekFiles.push({
209
+ displayPath: display,
210
+ path: abs,
211
+ ranges: mergedRanges.map((range) => ({ ...range })),
212
+ lines,
213
+ });
214
+ }
215
+
216
+ if (readseekFiles.length === 0) {
217
+ const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [], rehydrate });
218
+ return {
219
+ content: [{ type: "text", text: emptyOutput.text }],
220
+ details: {
221
+ readseekValue: emptyOutput.readseekValue,
222
+ contextHygiene: emptyOutput.contextHygiene,
223
+ },
224
+ };
225
+ }
226
+
227
+ const builtOutput = buildSgOutput({
228
+ pattern: p.pattern,
229
+ files: readseekFiles,
230
+ rehydrate,
231
+ });
232
+ for (const readseekFile of readseekFiles) {
233
+ options.onFileAnchored?.(readseekFile.path);
234
+ }
235
+ return {
236
+ content: [{ type: "text", text: builtOutput.text }],
237
+ details: {
238
+ readseekValue: builtOutput.readseekValue,
239
+ contextHygiene: builtOutput.contextHygiene,
240
+ },
241
+ };
242
+ } catch (err: any) {
243
+ const message = String(err?.message || err);
244
+ const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
245
+ return {
246
+ content: [{ type: "text", text: message }],
247
+ isError: true,
248
+ details: {
249
+ readseekValue: {
250
+ tool: "search",
251
+ ok: false,
252
+ error: missingReadseek
253
+ ? buildReadseekError("readseek-not-installed", message, "Run npm install to install @jarkkojs/readseek.")
254
+ : buildReadseekError("readseek-execution-error", message),
255
+ },
256
+ },
257
+ };
258
+ }
259
+ },
260
+ renderCall(args: any, theme: any, ...rest: any[]) {
261
+ const context = rest[0] ?? {};
262
+ let text = `${renderToolLabel(theme, "search")} ${theme.fg("accent", `/${args.pattern}/`)}`;
263
+ text += theme.fg("dim", ` in ${args.path ?? "."}`);
264
+ if (args.lang) text += theme.fg("dim", ` (${args.lang})`);
265
+ return new Text(clampLineToWidth(text, context.width), 0, 0);
266
+ },
267
+ renderResult(result: any, options: ToolRenderResultOptions, theme: any, ...rest: any[]) {
268
+ const context: { isPartial?: boolean; isError?: boolean; expanded?: boolean; cwd?: string; width?: number } =
269
+ rest[0] ?? options ?? {};
270
+ const isPartial = context.isPartial ?? (options as any)?.isPartial ?? false;
271
+ const isError = context.isError ?? false;
272
+ const expanded = isRendererExpanded(options as any, context as any);
273
+ const cwd = context.cwd ?? process.cwd();
274
+ const width = (context as any).width ?? (options as any)?.width;
275
+
276
+ if (isPartial) return new Text(clampLinesToWidth([summaryLine("pending search")], width).join("\n"), 0, 0);
277
+
278
+ const content = result.content?.[0];
279
+ const textContent = content?.type === "text" ? content.text : "";
280
+ if (isError || result.isError) {
281
+ const firstLine = textContent.split("\n")[0] || "Error";
282
+ const body = expanded && textContent ? textContent : firstLine;
283
+ return new Text(clampLinesToWidth(summaryLine(body).split("\n"), width).join("\n"), 0, 0);
284
+ }
285
+ const readseekValue = (result.details as any)?.readseekValue as
286
+ | { tool: "search"; files: Array<{ path: string; lines: any[] }> }
287
+ | undefined;
288
+ const files = readseekValue?.files ?? [];
289
+ if (files.length === 0) return new Text(summaryLine("no matches"), 0, 0);
290
+ const fileCount = files.length;
291
+ const totalMatches = files.reduce((sum: number, f: any) => sum + f.lines.length, 0);
292
+ const matchWord = totalMatches === 1 ? "match" : "matches";
293
+ const fileWord = fileCount === 1 ? "file" : "files";
294
+ let text = summaryLine(`${totalMatches} ${matchWord} in ${fileCount} ${fileWord}`, { hidden: files.length > 0 && !expanded });
295
+ if (expanded) {
296
+ for (const file of files.slice(0, 20)) {
297
+ const display = path.relative(cwd, file.path) || file.path;
298
+ text += "\n" + theme.fg("dim", ` ${display} (${file.lines.length})`);
299
+ }
300
+ if (files.length > 20) text += "\n" + theme.fg("muted", ` … and ${files.length - 20} more files`);
301
+ }
302
+ return new Text(clampLinesToWidth(text.split("\n"), width).join("\n"), 0, 0);
303
+ },
304
+ } satisfies Parameters<ExtensionAPI["registerTool"]>[0] & { ptc: typeof toolConfig };
305
+
306
+ pi.registerTool(tool);
307
+ return tool;
308
+ }
@@ -0,0 +1,25 @@
1
+ export type SyntaxValidateMode = "warn" | "block" | "off";
2
+
3
+ export interface SyntaxValidateOptions {
4
+ syntaxValidate?: SyntaxValidateMode;
5
+ }
6
+
7
+ const VALID = new Set<SyntaxValidateMode>(["warn", "block", "off"]);
8
+ const DEFAULT: SyntaxValidateMode = "warn";
9
+
10
+ function coerce(value: unknown): SyntaxValidateMode | undefined {
11
+ if (typeof value !== "string") return undefined;
12
+ return VALID.has(value as SyntaxValidateMode)
13
+ ? (value as SyntaxValidateMode)
14
+ : undefined;
15
+ }
16
+
17
+ export function resolveSyntaxValidateMode(
18
+ opts: SyntaxValidateOptions,
19
+ ): SyntaxValidateMode {
20
+ const fromOpt = coerce(opts.syntaxValidate);
21
+ if (fromOpt) return fromOpt;
22
+ const fromEnv = coerce(process.env.PI_HASHLINE_SYNTAX_VALIDATE);
23
+ if (fromEnv) return fromEnv;
24
+ return DEFAULT;
25
+ }