pi-readseek 0.3.20 → 0.3.22
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/index.ts +11 -11
- package/package.json +2 -2
- package/src/edit.ts +3 -2
- package/src/grep.ts +3 -2
- package/src/read.ts +3 -2
- package/src/readseek-client.ts +65 -4
- package/src/refs.ts +79 -60
- package/src/session-anchors.ts +20 -0
- package/src/sg.ts +119 -100
- package/src/tool-types.ts +9 -0
- package/src/write.ts +2 -1
package/index.ts
CHANGED
|
@@ -5,22 +5,22 @@ import { registerGrepTool } from "./src/grep.js";
|
|
|
5
5
|
import { registerSgTool, isSgAvailable } from "./src/sg.js";
|
|
6
6
|
import { registerRefsTool } from "./src/refs.js";
|
|
7
7
|
import { registerWriteTool } from "./src/write.js";
|
|
8
|
+
import { SessionAnchors } from "./src/session-anchors.js";
|
|
9
|
+
|
|
8
10
|
export default function piReadseekExtension(pi: ExtensionAPI): void {
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
};
|
|
13
|
-
const wasReadInSession = (absolutePath: string) => readPaths.has(absolutePath);
|
|
11
|
+
const sessionAnchors = new SessionAnchors();
|
|
12
|
+
const markAnchored = (absolutePath: string) => sessionAnchors.markAnchored(absolutePath);
|
|
13
|
+
const hasFreshAnchors = (absolutePath: string) => sessionAnchors.hasFreshAnchors(absolutePath);
|
|
14
14
|
|
|
15
|
-
registerReadTool(pi, { onSuccessfulRead:
|
|
16
|
-
registerEditTool(pi, { wasReadInSession });
|
|
15
|
+
registerReadTool(pi, { onSuccessfulRead: markAnchored });
|
|
16
|
+
registerEditTool(pi, { wasReadInSession: hasFreshAnchors });
|
|
17
17
|
const sgAvailable = isSgAvailable();
|
|
18
18
|
const searchGuideline = sgAvailable
|
|
19
19
|
? "Use grep summary for counts; use search for structural code patterns."
|
|
20
20
|
: "Use grep summary for counts; install @jarkkojs/readseek to enable search.";
|
|
21
21
|
|
|
22
|
-
registerGrepTool(pi, { searchGuideline, onFileAnchored:
|
|
23
|
-
registerSgTool(pi, { onFileAnchored:
|
|
24
|
-
registerRefsTool(pi, { onFileAnchored:
|
|
25
|
-
registerWriteTool(pi, { onFileAnchored:
|
|
22
|
+
registerGrepTool(pi, { searchGuideline, onFileAnchored: markAnchored });
|
|
23
|
+
registerSgTool(pi, { onFileAnchored: markAnchored });
|
|
24
|
+
registerRefsTool(pi, { onFileAnchored: markAnchored });
|
|
25
|
+
registerWriteTool(pi, { onFileAnchored: markAnchored });
|
|
26
26
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-readseek",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.22",
|
|
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.3.
|
|
42
|
+
"@jarkkojs/readseek": "^0.3.20",
|
|
43
43
|
"diff": "^8.0.3",
|
|
44
44
|
"ignore": "^7.0.5",
|
|
45
45
|
"picomatch": "^4.0.4",
|
package/src/edit.ts
CHANGED
|
@@ -22,6 +22,7 @@ import { buildEditPreviewKey, buildPendingEditPreviewData, resolvePendingDiffPre
|
|
|
22
22
|
import { buildDiffData, type DiffBlockRange } from "./diff-data.js";
|
|
23
23
|
import { clampLineToWidth, clampLinesToWidth, linkToolPath, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
24
24
|
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
25
|
+
import type { FreshAnchorsPredicate } from "./tool-types.js";
|
|
25
26
|
|
|
26
27
|
import { resolveEditDiffDisplay } from "./readseek-settings.js";
|
|
27
28
|
|
|
@@ -134,7 +135,7 @@ function mapEditFileError(err: any, filePath: string, displayPath: string, phase
|
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
export interface EditToolOptions {
|
|
137
|
-
wasReadInSession?:
|
|
138
|
+
wasReadInSession?: FreshAnchorsPredicate;
|
|
138
139
|
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
139
140
|
}
|
|
140
141
|
|
|
@@ -142,7 +143,7 @@ export interface ExecuteEditOptions {
|
|
|
142
143
|
params: unknown;
|
|
143
144
|
signal: AbortSignal | undefined;
|
|
144
145
|
cwd: string;
|
|
145
|
-
wasReadInSession?:
|
|
146
|
+
wasReadInSession?: FreshAnchorsPredicate;
|
|
146
147
|
syntaxValidate?: SyntaxValidateOptions["syntaxValidate"];
|
|
147
148
|
}
|
|
148
149
|
|
package/src/grep.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
18
18
|
import { formatGrepCallText, formatGrepResultText } from "./grep-render-helpers.js";
|
|
19
19
|
import { coerceObviousBase10Int } from "./coerce-obvious-int.js";
|
|
20
20
|
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine } from "./tui-render-utils.js";
|
|
21
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
21
22
|
|
|
22
23
|
const GREP_PROMPT_METADATA = defineToolPromptMetadata({
|
|
23
24
|
promptUrl: new URL("../prompts/grep.md", import.meta.url),
|
|
@@ -180,7 +181,7 @@ function escapeForRegex(s: string): string {
|
|
|
180
181
|
|
|
181
182
|
interface GrepToolOptions {
|
|
182
183
|
searchGuideline?: string;
|
|
183
|
-
onFileAnchored?:
|
|
184
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
187
|
export interface ExecuteGrepOptions {
|
|
@@ -189,7 +190,7 @@ export interface ExecuteGrepOptions {
|
|
|
189
190
|
signal: AbortSignal | undefined;
|
|
190
191
|
onUpdate: any;
|
|
191
192
|
cwd: string;
|
|
192
|
-
onFileAnchored?:
|
|
193
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
export async function executeGrep(opts: ExecuteGrepOptions): Promise<any> {
|
package/src/read.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { readseekRead } from "./readseek-client.js";
|
|
|
27
27
|
import { Text } from "@earendil-works/pi-tui";
|
|
28
28
|
import { formatReadCallText, formatReadResultText } from "./read-render-helpers.js";
|
|
29
29
|
import { clampLineToWidth, clampLinesToWidth, linkToolPath, renderToolLabel, resolveRenderResultContext, summaryLine, wrapReadHashlinesForWidth } from "./tui-render-utils.js";
|
|
30
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
30
31
|
|
|
31
32
|
const READ_PROMPT_METADATA = defineToolPromptMetadata({
|
|
32
33
|
promptUrl: new URL("../prompts/read.md", import.meta.url),
|
|
@@ -43,7 +44,7 @@ interface ReadParams {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
interface ReadToolOptions {
|
|
46
|
-
onSuccessfulRead?:
|
|
47
|
+
onSuccessfulRead?: FileAnchoredCallback;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export interface ExecuteReadOptions {
|
|
@@ -52,7 +53,7 @@ export interface ExecuteReadOptions {
|
|
|
52
53
|
signal: AbortSignal | undefined;
|
|
53
54
|
onUpdate: any;
|
|
54
55
|
cwd: string;
|
|
55
|
-
onSuccessfulRead?:
|
|
56
|
+
onSuccessfulRead?: FileAnchoredCallback;
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
export async function executeRead(opts: ExecuteReadOptions): Promise<AgentToolResult<any>> {
|
package/src/readseek-client.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn, type StdioOptions } from "node:child_process";
|
|
2
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
3
|
import { createRequire } from "node:module";
|
|
4
|
+
import { homedir } from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
4
6
|
|
|
5
7
|
import { DetailLevel } from "./readseek/enums.js";
|
|
@@ -165,6 +167,8 @@ function symbolsFromReadseek(symbols: ReadseekSymbol[]): FileSymbol[] {
|
|
|
165
167
|
}
|
|
166
168
|
|
|
167
169
|
const require = createRequire(import.meta.url);
|
|
170
|
+
const READSEEK_REPO_PACKAGE_NAMES = new Set(["@jarkkojs/readseek", "readseek"]);
|
|
171
|
+
let defaultReadseekDirInit: Promise<string | null> | undefined;
|
|
168
172
|
|
|
169
173
|
function readseekPackageDir(): string {
|
|
170
174
|
return path.dirname(require.resolve("@jarkkojs/readseek/package.json"));
|
|
@@ -199,12 +203,39 @@ export function isReadseekAvailable(): boolean {
|
|
|
199
203
|
}
|
|
200
204
|
}
|
|
201
205
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
function directoryExists(dirPath: string): boolean {
|
|
207
|
+
try {
|
|
208
|
+
return statSync(dirPath).isDirectory();
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
205
212
|
}
|
|
206
213
|
|
|
207
|
-
|
|
214
|
+
function isOwnReadseekRepository(cwd = process.cwd()): boolean {
|
|
215
|
+
let dir = path.resolve(cwd);
|
|
216
|
+
while (true) {
|
|
217
|
+
const packageJsonPath = path.join(dir, "package.json");
|
|
218
|
+
if (existsSync(packageJsonPath)) {
|
|
219
|
+
try {
|
|
220
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
|
|
221
|
+
if (typeof packageJson.name === "string" && READSEEK_REPO_PACKAGE_NAMES.has(packageJson.name)) return true;
|
|
222
|
+
} catch {
|
|
223
|
+
// Ignore unreadable or invalid package manifests while walking up.
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const parent = path.dirname(dir);
|
|
228
|
+
if (parent === dir) return false;
|
|
229
|
+
dir = parent;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function defaultReadseekDir(): string | null {
|
|
234
|
+
const home = homedir();
|
|
235
|
+
return home ? path.join(home, ".pi", "readseek") : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function spawnReadseekRaw(args: string[], options: RunReadseekOptions = {}): Promise<string> {
|
|
208
239
|
return new Promise<string>((resolve, reject) => {
|
|
209
240
|
let settled = false;
|
|
210
241
|
const fail = (error: Error): void => {
|
|
@@ -265,6 +296,36 @@ async function runReadseekRaw(args: string[], options: RunReadseekOptions = {}):
|
|
|
265
296
|
});
|
|
266
297
|
}
|
|
267
298
|
|
|
299
|
+
async function ensureDefaultReadseekDir(): Promise<string | null> {
|
|
300
|
+
const dir = defaultReadseekDir();
|
|
301
|
+
if (!dir) return null;
|
|
302
|
+
if (directoryExists(dir)) return dir;
|
|
303
|
+
|
|
304
|
+
defaultReadseekDirInit ??= spawnReadseekRaw(["--readseek-dir", dir, "init"])
|
|
305
|
+
.then(() => (directoryExists(dir) ? dir : null))
|
|
306
|
+
.catch(() => null)
|
|
307
|
+
.finally(() => {
|
|
308
|
+
defaultReadseekDirInit = undefined;
|
|
309
|
+
});
|
|
310
|
+
return defaultReadseekDirInit;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function readseekInvocationArgs(args: string[]): Promise<string[]> {
|
|
314
|
+
if (isOwnReadseekRepository()) return args;
|
|
315
|
+
|
|
316
|
+
const readseekDir = await ensureDefaultReadseekDir();
|
|
317
|
+
return readseekDir ? ["--readseek-dir", readseekDir, ...args] : args;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface RunReadseekOptions {
|
|
321
|
+
signal?: AbortSignal;
|
|
322
|
+
stdin?: string;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function runReadseekRaw(args: string[], options: RunReadseekOptions = {}): Promise<string> {
|
|
326
|
+
return spawnReadseekRaw(await readseekInvocationArgs(args), options);
|
|
327
|
+
}
|
|
328
|
+
|
|
268
329
|
async function runReadseek(args: string[], options: RunReadseekOptions = {}): Promise<unknown> {
|
|
269
330
|
const stdout = await runReadseekRaw(args, options);
|
|
270
331
|
return JSON.parse(stdout) as unknown;
|
package/src/refs.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { buildReadseekLineWithHash, buildToolErrorResult } from "./readseek-valu
|
|
|
8
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
9
9
|
import { isReadseekAvailable, readseekRefs, type ReadseekReference } from "./readseek-client.js";
|
|
10
10
|
import { buildRefsOutput, type RefsOutputFile, type RefsOutputLine } from "./refs-output.js";
|
|
11
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
11
12
|
|
|
12
13
|
import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
|
|
13
14
|
|
|
@@ -33,7 +34,17 @@ export function isRefsAvailable(): boolean {
|
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
interface RefsToolOptions {
|
|
36
|
-
onFileAnchored?:
|
|
37
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inputs for executing the references tool without registering it.
|
|
42
|
+
*/
|
|
43
|
+
export interface ExecuteRefsOptions {
|
|
44
|
+
params: unknown;
|
|
45
|
+
signal: AbortSignal | undefined;
|
|
46
|
+
cwd: string;
|
|
47
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
37
48
|
}
|
|
38
49
|
|
|
39
50
|
function refsLine(reference: ReadseekReference): RefsOutputLine {
|
|
@@ -61,6 +72,72 @@ function groupReferences(references: ReadseekReference[], cwd: string): RefsOutp
|
|
|
61
72
|
return [...files.values()];
|
|
62
73
|
}
|
|
63
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Executes identifier reference lookup and returns readseek-anchored matches.
|
|
77
|
+
*/
|
|
78
|
+
export async function executeRefs(opts: ExecuteRefsOptions): Promise<any> {
|
|
79
|
+
const { params, signal, cwd, onFileAnchored } = opts;
|
|
80
|
+
const p = params as RefsParams;
|
|
81
|
+
if (p.ignored && !p.others) {
|
|
82
|
+
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'ignored' requires 'others'");
|
|
83
|
+
}
|
|
84
|
+
if (p.scope && p.line === undefined) {
|
|
85
|
+
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
|
|
86
|
+
}
|
|
87
|
+
const searchPath = resolveToCwd(p.path ?? ".", cwd);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await fsStat(searchPath);
|
|
91
|
+
} catch (err: any) {
|
|
92
|
+
if (err?.code === "ENOENT") {
|
|
93
|
+
return buildToolErrorResult("refs", "path-not-found", `Error: path '${p.path ?? "."}' does not exist`, {
|
|
94
|
+
path: p.path ?? searchPath,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
98
|
+
return buildToolErrorResult("refs", "permission-denied", `Error: permission denied for path '${p.path ?? "."}'`, {
|
|
99
|
+
path: p.path ?? searchPath,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
103
|
+
return buildToolErrorResult("refs", "fs-error", message, {
|
|
104
|
+
path: p.path ?? searchPath,
|
|
105
|
+
details: { fsCode: err?.code, fsMessage: err?.message },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const references = await readseekRefs(searchPath, p.name, {
|
|
111
|
+
scope: p.scope,
|
|
112
|
+
line: p.line,
|
|
113
|
+
column: p.column,
|
|
114
|
+
language: p.lang,
|
|
115
|
+
cached: p.cached,
|
|
116
|
+
others: p.others,
|
|
117
|
+
ignored: p.ignored,
|
|
118
|
+
signal,
|
|
119
|
+
});
|
|
120
|
+
const files = groupReferences(references, cwd);
|
|
121
|
+
const builtOutput = buildRefsOutput({ name: p.name, files });
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
onFileAnchored?.(file.path);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: "text", text: builtOutput.text }],
|
|
127
|
+
details: { readseekValue: builtOutput.readseekValue },
|
|
128
|
+
};
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
const message = String(err?.message || err);
|
|
131
|
+
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
132
|
+
return buildToolErrorResult(
|
|
133
|
+
"refs",
|
|
134
|
+
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
135
|
+
message,
|
|
136
|
+
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
64
141
|
export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}) {
|
|
65
142
|
const toolConfig = {
|
|
66
143
|
callable: true,
|
|
@@ -90,65 +167,7 @@ export function registerRefsTool(pi: ExtensionAPI, options: RefsToolOptions = {}
|
|
|
90
167
|
}),
|
|
91
168
|
ptc: toolConfig,
|
|
92
169
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
93
|
-
|
|
94
|
-
if (p.ignored && !p.others) {
|
|
95
|
-
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'ignored' requires 'others'");
|
|
96
|
-
}
|
|
97
|
-
if (p.scope && p.line === undefined) {
|
|
98
|
-
return buildToolErrorResult("refs", "invalid-parameter", "Error: refs parameter 'scope' requires 'line'");
|
|
99
|
-
}
|
|
100
|
-
const searchPath = resolveToCwd(p.path ?? ".", ctx.cwd);
|
|
101
|
-
|
|
102
|
-
try {
|
|
103
|
-
await fsStat(searchPath);
|
|
104
|
-
} catch (err: any) {
|
|
105
|
-
if (err?.code === "ENOENT") {
|
|
106
|
-
return buildToolErrorResult("refs", "path-not-found", `Error: path '${p.path ?? "."}' does not exist`, {
|
|
107
|
-
path: p.path ?? searchPath,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
111
|
-
return buildToolErrorResult("refs", "permission-denied", `Error: permission denied for path '${p.path ?? "."}'`, {
|
|
112
|
-
path: p.path ?? searchPath,
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
116
|
-
return buildToolErrorResult("refs", "fs-error", message, {
|
|
117
|
-
path: p.path ?? searchPath,
|
|
118
|
-
details: { fsCode: err?.code, fsMessage: err?.message },
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const references = await readseekRefs(searchPath, p.name, {
|
|
124
|
-
scope: p.scope,
|
|
125
|
-
line: p.line,
|
|
126
|
-
column: p.column,
|
|
127
|
-
language: p.lang,
|
|
128
|
-
cached: p.cached,
|
|
129
|
-
others: p.others,
|
|
130
|
-
ignored: p.ignored,
|
|
131
|
-
signal,
|
|
132
|
-
});
|
|
133
|
-
const files = groupReferences(references, ctx.cwd);
|
|
134
|
-
const builtOutput = buildRefsOutput({ name: p.name, files });
|
|
135
|
-
for (const file of files) {
|
|
136
|
-
options.onFileAnchored?.(file.path);
|
|
137
|
-
}
|
|
138
|
-
return {
|
|
139
|
-
content: [{ type: "text", text: builtOutput.text }],
|
|
140
|
-
details: { readseekValue: builtOutput.readseekValue },
|
|
141
|
-
};
|
|
142
|
-
} catch (err: any) {
|
|
143
|
-
const message = String(err?.message || err);
|
|
144
|
-
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
145
|
-
return buildToolErrorResult(
|
|
146
|
-
"refs",
|
|
147
|
-
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
148
|
-
message,
|
|
149
|
-
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
150
|
-
);
|
|
151
|
-
}
|
|
170
|
+
return executeRefs({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
|
|
152
171
|
},
|
|
153
172
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
154
173
|
const context = rest[0] ?? {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks files that have fresh hashline anchors in the current extension session.
|
|
3
|
+
*/
|
|
4
|
+
export class SessionAnchors {
|
|
5
|
+
readonly #paths = new Set<string>();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Records that a file has produced anchors usable by later edit calls.
|
|
9
|
+
*/
|
|
10
|
+
markAnchored(absolutePath: string): void {
|
|
11
|
+
this.#paths.add(absolutePath);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns whether a file has fresh anchors in the current session.
|
|
16
|
+
*/
|
|
17
|
+
hasFreshAnchors(absolutePath: string): boolean {
|
|
18
|
+
return this.#paths.has(absolutePath);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/sg.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { buildReadseekLineWithHash, buildToolErrorResult, type ReadseekLine } fr
|
|
|
8
8
|
import { resolveToCwd } from "./path-utils.js";
|
|
9
9
|
import { isReadseekAvailable, readseekSearch, type ReadseekHashline, type ReadseekSearchFileOutput } from "./readseek-client.js";
|
|
10
10
|
import { buildSgOutput } from "./sg-output.js";
|
|
11
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
11
12
|
|
|
12
13
|
import { clampLineToWidth, renderAnchoredFilesResult, renderToolLabel } from "./tui-render-utils.js";
|
|
13
14
|
|
|
@@ -53,7 +54,17 @@ export function isSgAvailable(): boolean {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
interface SgToolOptions {
|
|
56
|
-
onFileAnchored?:
|
|
57
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Inputs for executing the structural search tool without registering it.
|
|
62
|
+
*/
|
|
63
|
+
export interface ExecuteSgOptions {
|
|
64
|
+
params: unknown;
|
|
65
|
+
signal: AbortSignal | undefined;
|
|
66
|
+
cwd: string;
|
|
67
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
57
68
|
}
|
|
58
69
|
|
|
59
70
|
function readseekLineFromSearch(line: ReadseekHashline): ReadseekLine {
|
|
@@ -87,6 +98,112 @@ function readseekLanguageForPath(language: string | undefined, searchPath: strin
|
|
|
87
98
|
return language;
|
|
88
99
|
}
|
|
89
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Executes structural search and returns readseek-anchored matches.
|
|
103
|
+
*/
|
|
104
|
+
export async function executeSg(opts: ExecuteSgOptions): Promise<any> {
|
|
105
|
+
const { params, signal, cwd, onFileAnchored } = opts;
|
|
106
|
+
const p = params as SgParams;
|
|
107
|
+
if (p.ignored && !p.others) {
|
|
108
|
+
const message = "Error: search parameter 'ignored' requires 'others'";
|
|
109
|
+
return buildToolErrorResult("search", "invalid-parameter", message);
|
|
110
|
+
}
|
|
111
|
+
const searchPath = resolveToCwd(p.path ?? ".", cwd);
|
|
112
|
+
let searchPathIsFile = false;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const stat = await fsStat(searchPath);
|
|
116
|
+
searchPathIsFile = stat.isFile();
|
|
117
|
+
} catch (err: any) {
|
|
118
|
+
if (err?.code === "ENOENT") {
|
|
119
|
+
const message = `Error: path '${p.path ?? "."}' does not exist`;
|
|
120
|
+
return buildToolErrorResult("search", "path-not-found", message, { path: p.path ?? searchPath });
|
|
121
|
+
}
|
|
122
|
+
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
123
|
+
const message = `Error: permission denied for path '${p.path ?? "."}'`;
|
|
124
|
+
return buildToolErrorResult("search", "permission-denied", message, { path: p.path ?? searchPath });
|
|
125
|
+
}
|
|
126
|
+
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
127
|
+
return buildToolErrorResult("search", "fs-error", message, { path: p.path ?? searchPath, details: { fsCode: err?.code, fsMessage: err?.message } });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const effectiveLang = readseekLanguageForPath(p.lang, searchPath, searchPathIsFile);
|
|
132
|
+
const results = await readseekSearch(searchPath, p.pattern, {
|
|
133
|
+
language: effectiveLang,
|
|
134
|
+
cached: p.cached,
|
|
135
|
+
others: p.others,
|
|
136
|
+
ignored: p.ignored,
|
|
137
|
+
signal,
|
|
138
|
+
});
|
|
139
|
+
if (results.length === 0) {
|
|
140
|
+
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
141
|
+
return {
|
|
142
|
+
content: [{ type: "text", text: emptyOutput.text }],
|
|
143
|
+
details: {
|
|
144
|
+
readseekValue: emptyOutput.readseekValue,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const readseekFiles: Array<{
|
|
150
|
+
displayPath: string;
|
|
151
|
+
path: string;
|
|
152
|
+
ranges: SgRange[];
|
|
153
|
+
lines: ReadseekLine[];
|
|
154
|
+
symbols?: SgEnclosingSymbol[];
|
|
155
|
+
}> = [];
|
|
156
|
+
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
const abs = path.isAbsolute(result.file) ? result.file : path.resolve(cwd, result.file);
|
|
159
|
+
const display = path.relative(cwd, abs) || abs;
|
|
160
|
+
const ranges = result.matches.map((match) => ({ startLine: match.start_line, endLine: match.end_line }));
|
|
161
|
+
const mergedRanges = mergeRanges(ranges);
|
|
162
|
+
const lines = linesFromSearchResult(result, mergedRanges);
|
|
163
|
+
if (lines.length === 0) continue;
|
|
164
|
+
readseekFiles.push({
|
|
165
|
+
displayPath: display,
|
|
166
|
+
path: abs,
|
|
167
|
+
ranges: mergedRanges.map((range) => ({ ...range })),
|
|
168
|
+
lines,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (readseekFiles.length === 0) {
|
|
173
|
+
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text: emptyOutput.text }],
|
|
176
|
+
details: {
|
|
177
|
+
readseekValue: emptyOutput.readseekValue,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const builtOutput = buildSgOutput({
|
|
183
|
+
pattern: p.pattern,
|
|
184
|
+
files: readseekFiles,
|
|
185
|
+
});
|
|
186
|
+
for (const readseekFile of readseekFiles) {
|
|
187
|
+
onFileAnchored?.(readseekFile.path);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
content: [{ type: "text", text: builtOutput.text }],
|
|
191
|
+
details: {
|
|
192
|
+
readseekValue: builtOutput.readseekValue,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
const message = String(err?.message || err);
|
|
197
|
+
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
198
|
+
return buildToolErrorResult(
|
|
199
|
+
"search",
|
|
200
|
+
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
201
|
+
message,
|
|
202
|
+
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
90
207
|
export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
91
208
|
const toolConfig = {
|
|
92
209
|
callable: true,
|
|
@@ -113,105 +230,7 @@ export function registerSgTool(pi: ExtensionAPI, options: SgToolOptions = {}) {
|
|
|
113
230
|
}),
|
|
114
231
|
ptc: toolConfig,
|
|
115
232
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
116
|
-
|
|
117
|
-
if (p.ignored && !p.others) {
|
|
118
|
-
const message = "Error: search parameter 'ignored' requires 'others'";
|
|
119
|
-
return buildToolErrorResult("search", "invalid-parameter", message);
|
|
120
|
-
}
|
|
121
|
-
const searchPath = resolveToCwd(p.path ?? ".", ctx.cwd);
|
|
122
|
-
let searchPathIsFile = false;
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
const stat = await fsStat(searchPath);
|
|
126
|
-
searchPathIsFile = stat.isFile();
|
|
127
|
-
} catch (err: any) {
|
|
128
|
-
if (err?.code === "ENOENT") {
|
|
129
|
-
const message = `Error: path '${p.path ?? "."}' does not exist`;
|
|
130
|
-
return buildToolErrorResult("search", "path-not-found", message, { path: p.path ?? searchPath });
|
|
131
|
-
}
|
|
132
|
-
if (err?.code === "EACCES" || err?.code === "EPERM") {
|
|
133
|
-
const message = `Error: permission denied for path '${p.path ?? "."}'`;
|
|
134
|
-
return buildToolErrorResult("search", "permission-denied", message, { path: p.path ?? searchPath });
|
|
135
|
-
}
|
|
136
|
-
const message = `Error: could not access path '${p.path ?? "."}': ${err?.message ?? String(err)}`;
|
|
137
|
-
return buildToolErrorResult("search", "fs-error", message, { path: p.path ?? searchPath, details: { fsCode: err?.code, fsMessage: err?.message } });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
try {
|
|
141
|
-
const effectiveLang = readseekLanguageForPath(p.lang, searchPath, searchPathIsFile);
|
|
142
|
-
const results = await readseekSearch(searchPath, p.pattern, {
|
|
143
|
-
language: effectiveLang,
|
|
144
|
-
cached: p.cached,
|
|
145
|
-
others: p.others,
|
|
146
|
-
ignored: p.ignored,
|
|
147
|
-
signal,
|
|
148
|
-
});
|
|
149
|
-
if (results.length === 0) {
|
|
150
|
-
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
151
|
-
return {
|
|
152
|
-
content: [{ type: "text", text: emptyOutput.text }],
|
|
153
|
-
details: {
|
|
154
|
-
readseekValue: emptyOutput.readseekValue,
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
const readseekFiles: Array<{
|
|
160
|
-
displayPath: string;
|
|
161
|
-
path: string;
|
|
162
|
-
ranges: SgRange[];
|
|
163
|
-
lines: ReadseekLine[];
|
|
164
|
-
symbols?: SgEnclosingSymbol[];
|
|
165
|
-
}> = [];
|
|
166
|
-
|
|
167
|
-
for (const result of results) {
|
|
168
|
-
const abs = path.isAbsolute(result.file) ? result.file : path.resolve(ctx.cwd, result.file);
|
|
169
|
-
const display = path.relative(ctx.cwd, abs) || abs;
|
|
170
|
-
const ranges = result.matches.map((match) => ({ startLine: match.start_line, endLine: match.end_line }));
|
|
171
|
-
const mergedRanges = mergeRanges(ranges);
|
|
172
|
-
const lines = linesFromSearchResult(result, mergedRanges);
|
|
173
|
-
if (lines.length === 0) continue;
|
|
174
|
-
readseekFiles.push({
|
|
175
|
-
displayPath: display,
|
|
176
|
-
path: abs,
|
|
177
|
-
ranges: mergedRanges.map((range) => ({ ...range })),
|
|
178
|
-
lines,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (readseekFiles.length === 0) {
|
|
183
|
-
const emptyOutput = buildSgOutput({ pattern: p.pattern, files: [] });
|
|
184
|
-
return {
|
|
185
|
-
content: [{ type: "text", text: emptyOutput.text }],
|
|
186
|
-
details: {
|
|
187
|
-
readseekValue: emptyOutput.readseekValue,
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const builtOutput = buildSgOutput({
|
|
193
|
-
pattern: p.pattern,
|
|
194
|
-
files: readseekFiles,
|
|
195
|
-
});
|
|
196
|
-
for (const readseekFile of readseekFiles) {
|
|
197
|
-
options.onFileAnchored?.(readseekFile.path);
|
|
198
|
-
}
|
|
199
|
-
return {
|
|
200
|
-
content: [{ type: "text", text: builtOutput.text }],
|
|
201
|
-
details: {
|
|
202
|
-
readseekValue: builtOutput.readseekValue,
|
|
203
|
-
},
|
|
204
|
-
};
|
|
205
|
-
} catch (err: any) {
|
|
206
|
-
const message = String(err?.message || err);
|
|
207
|
-
const missingReadseek = err?.code === "ENOENT" || /Cannot find package|Cannot find module|no such file/i.test(message);
|
|
208
|
-
return buildToolErrorResult(
|
|
209
|
-
"search",
|
|
210
|
-
missingReadseek ? "readseek-not-installed" : "readseek-execution-error",
|
|
211
|
-
message,
|
|
212
|
-
missingReadseek ? { hint: "Run npm install to install @jarkkojs/readseek." } : {},
|
|
213
|
-
);
|
|
214
|
-
}
|
|
233
|
+
return executeSg({ params, signal, cwd: ctx.cwd, onFileAnchored: options.onFileAnchored });
|
|
215
234
|
},
|
|
216
235
|
renderCall(args: any, theme: any, ...rest: any[]) {
|
|
217
236
|
const context = rest[0] ?? {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Callback used when a tool returns fresh anchors for a file path.
|
|
3
|
+
*/
|
|
4
|
+
export type FileAnchoredCallback = (absolutePath: string) => void;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Predicate used by mutating tools to check whether a file has session-fresh anchors.
|
|
8
|
+
*/
|
|
9
|
+
export type FreshAnchorsPredicate = (absolutePath: string) => boolean;
|
package/src/write.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { generateCompactOrFullDiff, normalizeToLF, hasBareCarriageReturn } from
|
|
|
16
16
|
import { buildDiffData, type DiffData } from "./diff-data.js";
|
|
17
17
|
import { clampLineToWidth, clampLinesToWidth, isRendererExpanded, linkToolPath, renderToolLabel, summaryLine } from "./tui-render-utils.js";
|
|
18
18
|
import { DiffPreviewComponent } from "./tui-diff-component.js";
|
|
19
|
+
import type { FileAnchoredCallback } from "./tool-types.js";
|
|
19
20
|
|
|
20
21
|
const WRITE_PENDING_PREVIEW_STATE_KEY = "hashline-write-pending-preview";
|
|
21
22
|
|
|
@@ -113,7 +114,7 @@ function generateWriteDiff(previousContent: string, nextContent: string): { diff
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
export interface WriteToolOptions {
|
|
116
|
-
onFileAnchored?:
|
|
117
|
+
onFileAnchored?: FileAnchoredCallback;
|
|
117
118
|
}
|
|
118
119
|
|
|
119
120
|
type MappedFsError = {
|