@khanhicetea/pi-better-tool 0.2.3 → 0.2.5
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/README.md +20 -6
- package/package.json +3 -2
- package/src/diagnostics.ts +2 -2
- package/src/index.ts +6 -1
- package/src/read-code-imports.ts +157 -0
- package/src/read-symbol.ts +19 -12
- package/src/source-file.ts +7 -5
package/README.md
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Context-aware tools for the [Pi coding agent](https://github.com/earendil-works/pi-mono):
|
|
4
4
|
|
|
5
5
|
- **`edit`** replaces the built-in edit tool. Failures explain what failed, what was not written, and what to do next.
|
|
6
|
-
- **`read_symbol`**
|
|
6
|
+
- **`read_symbol`** reads a whole function, method, class, or type by containing line or exact name. Its required `mode` makes symbol reads and outline listings explicit.
|
|
7
|
+
- **`read_code_imports`** reads the first dependency import zone as exact raw source lines, including comments and blank lines between adjacent imports.
|
|
7
8
|
- **`read` stays unchanged** for ordinary text, images, and explicit line ranges.
|
|
8
9
|
|
|
9
10
|
## Install
|
|
@@ -25,25 +26,25 @@ The root `package.json` also registers the extension. After local changes, use `
|
|
|
25
26
|
After grep identifies a location:
|
|
26
27
|
|
|
27
28
|
```json
|
|
28
|
-
{"path":"src/server.ts","line":142}
|
|
29
|
+
{"path":"src/server.ts","mode":"symbol","line":142}
|
|
29
30
|
```
|
|
30
31
|
|
|
31
32
|
Call `read_symbol` with an exact name instead:
|
|
32
33
|
|
|
33
34
|
```json
|
|
34
|
-
{"path":"src/server.ts","symbol":"Server.handleRequest"}
|
|
35
|
+
{"path":"src/server.ts","mode":"symbol","symbol":"Server.handleRequest"}
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
Get the enclosing class or function:
|
|
38
39
|
|
|
39
40
|
```json
|
|
40
|
-
{"path":"src/server.ts","line":142,"parent":1}
|
|
41
|
+
{"path":"src/server.ts","mode":"symbol","line":142,"parent":1}
|
|
41
42
|
```
|
|
42
43
|
|
|
43
44
|
Discover names and ranges without reading every body:
|
|
44
45
|
|
|
45
46
|
```json
|
|
46
|
-
{"path":"src/server.ts"}
|
|
47
|
+
{"path":"src/server.ts","mode":"outline"}
|
|
47
48
|
```
|
|
48
49
|
|
|
49
50
|
### Arguments
|
|
@@ -51,6 +52,7 @@ Discover names and ranges without reading every body:
|
|
|
51
52
|
| Argument | Meaning |
|
|
52
53
|
| --- | --- |
|
|
53
54
|
| `path` | Local relative/absolute path; supports `@path`, `~/path`, and file URLs. |
|
|
55
|
+
| `mode` | Required: `symbol` reads one declaration and requires `symbol` or `line`; `outline` only lists names and ranges. |
|
|
54
56
|
| `line` | 1-based file line. Select the innermost declaration containing it. |
|
|
55
57
|
| `column` | Optional 1-based UTF-16 column with `line`, to distinguish same-line symbols. |
|
|
56
58
|
| `symbol` | Exact, case-sensitive name or qualified name such as `Server.run`. Combine with `line` for duplicate names. |
|
|
@@ -59,7 +61,7 @@ Discover names and ranges without reading every body:
|
|
|
59
61
|
| `offset` | 1-based position **within the selection**, not a file line. For an outline, the entry position. |
|
|
60
62
|
| `limit` | Maximum source lines (default 1000) or outline entries (default 50); maximum 1800. |
|
|
61
63
|
|
|
62
|
-
|
|
64
|
+
Set `mode` to `outline` for an outline. Named selection never silently chooses the first duplicate. Line selection never silently chooses between same-line siblings. Candidate lists include concrete calls with names and positions.
|
|
63
65
|
|
|
64
66
|
### Languages and boundaries
|
|
65
67
|
|
|
@@ -95,6 +97,18 @@ Limits:
|
|
|
95
97
|
|
|
96
98
|
Parser packages are runtime dependencies. Common platforms use prebuilt native binaries. If a grammar is unavailable on a platform, the tool gives read guidance; it never runs repository code, installs a compiler, or builds a grammar during a tool call. The edit tool can still work without loading symbol parsers.
|
|
97
99
|
|
|
100
|
+
## Read imports before editing them
|
|
101
|
+
|
|
102
|
+
Call `read_code_imports` with a source path:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{"path":"src/server.ts"}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The tool uses bounded regular-expression detection and returns the first contiguous import zone in an unnumbered source fence. It supports JavaScript/TypeScript, Python, Go, Rust, C/C++, C#, Java, Kotlin, PHP, Ruby, Swift, and shell files. It recognizes each language's common `import`, `include`, `use`, `require`, or `source` form, including common multiline forms.
|
|
109
|
+
|
|
110
|
+
The result includes the exact file-line range and a SHA-256 snapshot. Comments and blank lines between adjacent imports remain in the raw output. The zone stops when code separates later import statements. The tool does not resolve dependencies, execute code, or claim syntax-level accuracy. Use `read` when conditional imports, generated files, uncommon macros, or unsupported syntax need more context.
|
|
111
|
+
|
|
98
112
|
## Recover from an edit failure in the next call
|
|
99
113
|
|
|
100
114
|
The `edit` input remains:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@khanhicetea/pi-better-tool",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Context-aware tools for pi: safe
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Context-aware tools for pi: safe edits, syntax-aware symbol reads, and raw source import-zone reads",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"edit",
|
|
20
20
|
"read",
|
|
21
21
|
"symbols",
|
|
22
|
+
"imports",
|
|
22
23
|
"tree-sitter",
|
|
23
24
|
"tools"
|
|
24
25
|
],
|
package/src/diagnostics.ts
CHANGED
|
@@ -147,7 +147,7 @@ export function formatEditFailure(opts: FormatFailureOptions): string {
|
|
|
147
147
|
function nextContextCall(path: string, start: number, end = start): string {
|
|
148
148
|
const read = `read ${JSON.stringify({ path, offset: Math.max(1, start - 3), limit: Math.min(1800, end - start + 7) })}`;
|
|
149
149
|
return languageForPath(path)
|
|
150
|
-
? `Next context call: read_symbol ${JSON.stringify({ path, line: start })} for the enclosing symbol; or ${read} for exact line context.`
|
|
150
|
+
? `Next context call: read_symbol ${JSON.stringify({ path, mode: "symbol", line: start })} for the enclosing symbol; or ${read} for exact line context.`
|
|
151
151
|
: `Next context call: ${read}.`;
|
|
152
152
|
}
|
|
153
153
|
|
|
@@ -456,7 +456,7 @@ function formatNotFound(opts: FormatFailureOptions, oldText: string): string {
|
|
|
456
456
|
} else {
|
|
457
457
|
lines.push("No reliable similar region was found within the bounded diagnostic search.");
|
|
458
458
|
lines.push("If you expected this text to exist, read the file around the expected location and retry.");
|
|
459
|
-
lines.push(languageForPath(opts.path) ? `Next context call: read_symbol ${JSON.stringify({ path: opts.path })} to locate the intended symbol without guessing line ranges.` : nextContextCall(opts.path, 1));
|
|
459
|
+
lines.push(languageForPath(opts.path) ? `Next context call: read_symbol ${JSON.stringify({ path: opts.path, mode: "outline" })} to locate the intended symbol without guessing line ranges.` : nextContextCall(opts.path, 1));
|
|
460
460
|
}
|
|
461
461
|
|
|
462
462
|
if (causes.length > 0) {
|
package/src/index.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-better-tool — better built-in tools for the pi coding agent.
|
|
3
3
|
*
|
|
4
|
-
* Ships a safe edit override and
|
|
4
|
+
* Ships a safe edit override and focused source readers:
|
|
5
5
|
* - `edit` — exact replacement with actionable recovery context.
|
|
6
6
|
* - `read_symbol` — whole symbols by containing line or exact name.
|
|
7
|
+
* - `read_code_imports` — raw dependency import zones for common languages.
|
|
7
8
|
* Built-in `read` remains available for text, images, and explicit line ranges.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
12
|
import { registerBetterEditTool } from "./tool.ts";
|
|
12
13
|
import { registerReadSymbolTool } from "./read-symbol.ts";
|
|
14
|
+
import { registerReadCodeImportsTool } from "./read-code-imports.ts";
|
|
13
15
|
|
|
16
|
+
export { buildCodeImportsRead, executeReadCodeImports, registerReadCodeImportsTool, readCodeImportsSchema, validateReadCodeImportsInput } from "./read-code-imports.ts";
|
|
17
|
+
export type { ReadCodeImportsInput, ReadCodeImportsResult } from "./read-code-imports.ts";
|
|
14
18
|
export { executeReadSymbol, registerReadSymbolTool, readSymbolSchema } from "./read-symbol.ts";
|
|
15
19
|
export type { ReadSymbolInput, ReadSymbolResult } from "./read-symbol.ts";
|
|
16
20
|
|
|
@@ -34,4 +38,5 @@ export type { AnalyzeOptions, EditFailure, EditOp, EditAnalysis } from "./apply.
|
|
|
34
38
|
export default function (pi: ExtensionAPI) {
|
|
35
39
|
registerBetterEditTool(pi);
|
|
36
40
|
registerReadSymbolTool(pi);
|
|
41
|
+
registerReadCodeImportsTool(pi);
|
|
37
42
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { basename, extname } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { Type, type Static } from "typebox";
|
|
5
|
+
import { boundCompleteOutput, fenceFor } from "./diagnostics.ts";
|
|
6
|
+
import { resolveToolPath } from "./paths.ts";
|
|
7
|
+
import { readSource, sourceReadError } from "./source-file.ts";
|
|
8
|
+
|
|
9
|
+
export const readCodeImportsSchema = Type.Object({
|
|
10
|
+
path: Type.String({ minLength: 1, maxLength: 4096, description: "Local source file path (relative, absolute, @path, ~/path, or file URL)." }),
|
|
11
|
+
});
|
|
12
|
+
export type ReadCodeImportsInput = Static<typeof readCodeImportsSchema>;
|
|
13
|
+
|
|
14
|
+
type ImportLanguage = "javascript" | "python" | "go" | "rust" | "c" | "csharp" | "java" | "kotlin" | "php" | "ruby" | "swift" | "shell";
|
|
15
|
+
interface LineRange { start: number; end: number }
|
|
16
|
+
export interface ReadCodeImportsResult {
|
|
17
|
+
content: Array<{ type: "text"; text: string }>;
|
|
18
|
+
details: {
|
|
19
|
+
language: ImportLanguage;
|
|
20
|
+
snapshot: string;
|
|
21
|
+
found: boolean;
|
|
22
|
+
visible?: { startLine: number; endLine: number };
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MAX_OUTPUT_BYTES = 48 * 1024;
|
|
27
|
+
const MAX_OUTPUT_LINES = 1950;
|
|
28
|
+
const EXTENSIONS: Record<string, ImportLanguage> = {
|
|
29
|
+
".js": "javascript", ".jsx": "javascript", ".mjs": "javascript", ".cjs": "javascript",
|
|
30
|
+
".ts": "javascript", ".tsx": "javascript", ".mts": "javascript", ".cts": "javascript",
|
|
31
|
+
".py": "python", ".pyi": "python", ".go": "go", ".rs": "rust",
|
|
32
|
+
".c": "c", ".h": "c", ".cc": "c", ".cpp": "c", ".cxx": "c", ".hpp": "c", ".m": "c", ".mm": "c",
|
|
33
|
+
".cs": "csharp", ".java": "java", ".kt": "kotlin", ".kts": "kotlin", ".php": "php",
|
|
34
|
+
".rb": "ruby", ".swift": "swift", ".sh": "shell", ".bash": "shell", ".zsh": "shell", ".fish": "shell",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function importLanguage(path: string): ImportLanguage | undefined {
|
|
38
|
+
const name = basename(path).toLowerCase();
|
|
39
|
+
if (name === "gemfile" || name === "rakefile") return "ruby";
|
|
40
|
+
return EXTENSIONS[extname(name)];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isImportStart(line: string, language: ImportLanguage): boolean {
|
|
44
|
+
switch (language) {
|
|
45
|
+
case "javascript": return /^(?:import(?:\s|["'])|export\s+(?:type\s+)?(?:\*|\{)[\s\S]*\bfrom\b)/.test(line);
|
|
46
|
+
case "python": return /^(?:from\s+[\w.]+\s+import\b|import\s+[\w.])/.test(line);
|
|
47
|
+
case "go": return /^import(?:\s|\()/.test(line);
|
|
48
|
+
case "rust": return /^(?:(?:pub(?:\([^)]*\))?\s+)?use\b|extern\s+crate\b)/.test(line);
|
|
49
|
+
case "c": return /^\s*#\s*(?:include|import)\b/.test(line);
|
|
50
|
+
case "csharp": return /^(?:(?:global\s+)?using\b|extern\s+alias\b)/.test(line);
|
|
51
|
+
case "java": case "kotlin": return /^import\b/.test(line);
|
|
52
|
+
case "php": return /^(?:use\b|(?:require|include)(?:_once)?\b)/.test(line);
|
|
53
|
+
case "ruby": return /^(?:require|require_relative|load)\b/.test(line);
|
|
54
|
+
case "swift": return /^(?:@\w+(?:\([^)]*\))?\s+)?import\b/.test(line);
|
|
55
|
+
case "shell": return /^(?:source\b|\.\s+\S)/.test(line);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function statementEnd(lines: string[], start: number, language: ImportLanguage): number {
|
|
60
|
+
if (language === "go" && /^import\s*\(/.test(lines[start])) {
|
|
61
|
+
for (let index = start; index < lines.length; index++) if (/^\s*\)/.test(lines[index])) return index;
|
|
62
|
+
return start;
|
|
63
|
+
}
|
|
64
|
+
if (language === "python") {
|
|
65
|
+
let depth = 0;
|
|
66
|
+
for (let index = start; index < lines.length; index++) {
|
|
67
|
+
const code = lines[index].replace(/#.*$/, "");
|
|
68
|
+
depth += (code.match(/[([{]/g) ?? []).length - (code.match(/[)\]}]/g) ?? []).length;
|
|
69
|
+
if (depth <= 0 && !/\\\s*$/.test(code)) return index;
|
|
70
|
+
}
|
|
71
|
+
return start;
|
|
72
|
+
}
|
|
73
|
+
if (language === "javascript") {
|
|
74
|
+
for (let index = start; index < lines.length; index++) {
|
|
75
|
+
const code = lines[index].replace(/\/\/.*$/, "").trimEnd();
|
|
76
|
+
if (/;\s*$/.test(code) || /(?:from\s+)?["'][^"']+["']\s*(?:(?:with|assert)\s*\{.*\})?\s*$/.test(code)) return index;
|
|
77
|
+
}
|
|
78
|
+
return start;
|
|
79
|
+
}
|
|
80
|
+
if (language === "rust" || language === "csharp" || language === "java" || language === "kotlin" || language === "php") {
|
|
81
|
+
for (let index = start; index < lines.length; index++) if (/;\s*(?:\/\/.*)?$/.test(lines[index])) return index;
|
|
82
|
+
return start;
|
|
83
|
+
}
|
|
84
|
+
if (language === "c") {
|
|
85
|
+
let index = start;
|
|
86
|
+
while (index + 1 < lines.length && /\\\s*$/.test(lines[index])) index++;
|
|
87
|
+
return index;
|
|
88
|
+
}
|
|
89
|
+
return start;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isTrivia(line: string): boolean {
|
|
93
|
+
return /^\s*(?:$|\/\/|\/\*|\*|#(?!\s*(?:include|import)\b)|<!--|-->|\{\/\*)/.test(line);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function findImportZone(lines: string[], language: ImportLanguage): LineRange | undefined {
|
|
97
|
+
const statements: LineRange[] = [];
|
|
98
|
+
for (let index = 0; index < lines.length; index++) {
|
|
99
|
+
if (!isImportStart(lines[index], language)) continue;
|
|
100
|
+
const end = statementEnd(lines, index, language);
|
|
101
|
+
statements.push({ start: index, end });
|
|
102
|
+
index = end;
|
|
103
|
+
}
|
|
104
|
+
if (!statements.length) return undefined;
|
|
105
|
+
const zone = { ...statements[0] };
|
|
106
|
+
for (const statement of statements.slice(1)) {
|
|
107
|
+
if (!lines.slice(zone.end + 1, statement.start).every(isTrivia)) break;
|
|
108
|
+
zone.end = statement.end;
|
|
109
|
+
}
|
|
110
|
+
return zone;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function validateReadCodeImportsInput(input: ReadCodeImportsInput): void {
|
|
114
|
+
if (!input || typeof input.path !== "string" || !input.path.length || input.path.length > 4096) throw new Error("read_code_imports requires a non-empty path of at most 4096 characters.");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function buildCodeImportsRead(input: ReadCodeImportsInput, content: string): ReadCodeImportsResult {
|
|
118
|
+
validateReadCodeImportsInput(input);
|
|
119
|
+
const language = importLanguage(resolveToolPath(input.path, "/"));
|
|
120
|
+
if (!language) throw new Error("Unsupported source type. read_code_imports supports JavaScript/TypeScript, Python, Go, Rust, C/C++, C#, Java, Kotlin, PHP, Ruby, Swift, and shell source files. Use read for this file.");
|
|
121
|
+
const lines = content.split("\n");
|
|
122
|
+
const zone = findImportZone(lines, language);
|
|
123
|
+
const snapshot = createHash("sha256").update(content).digest("hex");
|
|
124
|
+
const header = `Source ${JSON.stringify(input.path)} (${lines.length} lines). Snapshot sha256:${snapshot}`;
|
|
125
|
+
if (!zone) return { content: [{ type: "text", text: `${header}\nNo dependency import statements found.` }], details: { language, snapshot, found: false } };
|
|
126
|
+
const snippet = lines.slice(zone.start, zone.end + 1).join("\n");
|
|
127
|
+
const fence = fenceFor(snippet);
|
|
128
|
+
const text = `${header}\nImport zone: file lines ${zone.start + 1}-${zone.end + 1}. Raw source lines; regex-detected, not resolved.\n\n${fence}\n${snippet}\n${fence}`;
|
|
129
|
+
if (Buffer.byteLength(text, "utf8") > MAX_OUTPUT_BYTES || text.split("\n").length > MAX_OUTPUT_LINES) throw new Error(`The complete import zone exceeds the 48 KiB / 1,950-line output limit (lines ${zone.start + 1}-${zone.end + 1}). Use read with an explicit offset/limit.`);
|
|
130
|
+
return { content: [{ type: "text", text }], details: { language, snapshot, found: true, visible: { startLine: zone.start + 1, endLine: zone.end + 1 } } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function executeReadCodeImports(input: ReadCodeImportsInput, signal: AbortSignal | undefined, ctx: Pick<ExtensionContext, "cwd">): Promise<ReadCodeImportsResult> {
|
|
134
|
+
validateReadCodeImportsInput(input);
|
|
135
|
+
const path = resolveToolPath(input.path, ctx.cwd);
|
|
136
|
+
let content: string;
|
|
137
|
+
try { content = await readSource(path, signal, "read_code_imports"); }
|
|
138
|
+
catch (error) { signal?.throwIfAborted(); throw new Error(boundCompleteOutput(await sourceReadError(path, error, "read_code_imports"))); }
|
|
139
|
+
try { return buildCodeImportsRead(input, content); }
|
|
140
|
+
catch (error) { throw new Error(boundCompleteOutput(error instanceof Error ? error.message : String(error))); }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function registerReadCodeImportsTool(pi: ExtensionAPI): void {
|
|
144
|
+
pi.registerTool({
|
|
145
|
+
name: "read_code_imports", label: "read_code_imports",
|
|
146
|
+
description: "Read the dependency import zone from a common-language source file as exact raw lines. Uses bounded regex-based detection for imports/includes/use/require statements, preserves comments and blank lines between adjacent imports, and reports the file range and snapshot. It does not resolve dependencies or replace read for arbitrary ranges.",
|
|
147
|
+
promptSnippet: "Read a source file's raw dependency import zone",
|
|
148
|
+
promptGuidelines: [
|
|
149
|
+
"When preparing an edit that adds code which uses a dependency, use read_code_imports if you are not sure whether the required import already exists.",
|
|
150
|
+
"When the target code also needs inspection, batch read_code_imports with that independent code read; inspect both results before making the dependent edit.",
|
|
151
|
+
"Treat read_code_imports output as regex-detected source text; inspect more context when conditional or generated imports can affect the edit.",
|
|
152
|
+
"Use the raw lines from read_code_imports to decide whether an import edit is needed, then include all required import and code changes in one edit call when possible.",
|
|
153
|
+
],
|
|
154
|
+
parameters: readCodeImportsSchema,
|
|
155
|
+
async execute(_id, input, signal, _onUpdate, ctx) { return executeReadCodeImports(input, signal, ctx); },
|
|
156
|
+
});
|
|
157
|
+
}
|
package/src/read-symbol.ts
CHANGED
|
@@ -9,15 +9,18 @@ import { getLineSpans } from "./text.ts";
|
|
|
9
9
|
|
|
10
10
|
export const readSymbolSchema = Type.Object({
|
|
11
11
|
path: Type.String({ minLength: 1, maxLength: 4096, description: "Local source file path (relative, absolute, @path, ~/path, or file URL)." }),
|
|
12
|
-
|
|
12
|
+
mode: Type.Union([Type.Literal("symbol"), Type.Literal("outline")], { description: 'Required operation. Use "symbol" to read one declaration (and provide symbol or line); use "outline" only to list declaration names and ranges.' }),
|
|
13
|
+
line: Type.Optional(Type.Integer({ minimum: 1, description: 'With mode="symbol", read the innermost symbol containing this 1-based line. Combine with symbol to disambiguate duplicate names.' })),
|
|
13
14
|
column: Type.Optional(Type.Integer({ minimum: 1, description: "Optional 1-based UTF-16 column with line, to distinguish symbols on the same line." })),
|
|
14
|
-
symbol: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: "
|
|
15
|
+
symbol: Type.Optional(Type.String({ minLength: 1, maxLength: 256, description: 'With mode="symbol", the exact symbol name or qualified name, for example Server.run. A symbol or line selector is required in symbol mode.' })),
|
|
15
16
|
parent: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, description: "Move outward this many enclosing symbols after selection (default 0)." })),
|
|
16
|
-
context: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, description: "Extra whole lines before and after the selected symbol (default 0)." })),
|
|
17
|
+
context: Type.Optional(Type.Integer({ minimum: 0, maximum: 20, description: "Extra whole lines before and after the selected symbol (default 0, max 20)." })),
|
|
17
18
|
offset: Type.Optional(Type.Integer({ minimum: 1, description: "1-based position within the selected symbol/context or outline, not a file line. Use the exact continuation call from a partial result." })),
|
|
18
19
|
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1800, description: "Maximum source lines (default 1000) or outline entries (default 50). Output also has a 48 KiB hard limit." })),
|
|
19
20
|
});
|
|
20
|
-
|
|
21
|
+
type RegisteredReadSymbolInput = Static<typeof readSymbolSchema>;
|
|
22
|
+
/** mode stays optional here so stored calls from releases before explicit modes remain verifiable. */
|
|
23
|
+
export type ReadSymbolInput = Omit<RegisteredReadSymbolInput, "mode"> & { mode?: RegisteredReadSymbolInput["mode"] };
|
|
21
24
|
|
|
22
25
|
export interface VisibleSource {
|
|
23
26
|
startLine: number;
|
|
@@ -48,12 +51,15 @@ export function validateReadSymbolInput(input: ReadSymbolInput): void {
|
|
|
48
51
|
const value = input[key];
|
|
49
52
|
if (value !== undefined && (!Number.isSafeInteger(value) || value < min || value > max)) throw new Error(`read_symbol ${key} must be an integer from ${min} to ${max}.`);
|
|
50
53
|
}
|
|
54
|
+
if (input.mode !== undefined && input.mode !== "symbol" && input.mode !== "outline") throw new Error('read_symbol mode must be "symbol" or "outline".');
|
|
51
55
|
if (input.column !== undefined && input.line === undefined) throw new Error("read_symbol column requires line.");
|
|
52
|
-
if (
|
|
56
|
+
if (input.mode === "symbol" && input.line === undefined && input.symbol === undefined) throw new Error('read_symbol mode="symbol" requires a symbol name or containing line.');
|
|
57
|
+
if (input.mode === "outline" && (input.line !== undefined || input.symbol !== undefined || input.parent !== undefined || input.context !== undefined)) throw new Error('read_symbol mode="outline" does not accept symbol, line, parent, or context.');
|
|
58
|
+
if ((input.parent || input.context) && input.line === undefined && input.symbol === undefined) throw new Error("read_symbol parent/context requires a line or symbol selector.");
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
function candidateLine(path: string, symbol: SourceSymbol): string {
|
|
56
|
-
return `- ${label(symbol.qualifiedName)} (${symbol.kind}), lines ${symbol.startLine}-${symbol.endLine}. ${callText({ path, line: symbol.startLine, column: symbol.startColumn, symbol: symbol.qualifiedName.length <= 256 ? symbol.qualifiedName : undefined })}`;
|
|
62
|
+
return `- ${label(symbol.qualifiedName)} (${symbol.kind}), lines ${symbol.startLine}-${symbol.endLine}. ${callText({ path, mode: "symbol", line: symbol.startLine, column: symbol.startColumn, symbol: symbol.qualifiedName.length <= 256 ? symbol.qualifiedName : undefined })}`;
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
/** Non-copyable preview for failed selection; never presented as a complete symbol. */
|
|
@@ -99,7 +105,7 @@ function selectSymbol(input: ReadSymbolInput, symbols: SourceSymbol[]): SourceSy
|
|
|
99
105
|
matches.length ? `Ambiguous symbol selection: ${matches.length} candidates. No symbol was selected.` : "No matching symbol. Names are exact and case-sensitive; no nearby symbol was selected automatically.",
|
|
100
106
|
...candidates.slice(0, 8).map((symbol) => candidateLine(input.path, symbol)),
|
|
101
107
|
...(candidates.length > 8 ? [`${candidates.length - 8} more candidates omitted.`] : []),
|
|
102
|
-
`Next: choose a candidate call above, or list the outline with ${callText({ path: input.path })}.`,
|
|
108
|
+
`Next: choose a candidate call above, or list the outline with ${callText({ path: input.path, mode: "outline" })}.`,
|
|
103
109
|
].join("\n"));
|
|
104
110
|
}
|
|
105
111
|
let selected = matches[0];
|
|
@@ -133,7 +139,7 @@ export async function buildSymbolRead(input: ReadSymbolInput, content: string, s
|
|
|
133
139
|
const snapshot = createHash("sha256").update(content).digest("hex");
|
|
134
140
|
const header = `Source ${label(input.path)} (${lines.length} lines). Snapshot sha256:${snapshot}`;
|
|
135
141
|
const offset = input.offset ?? 1;
|
|
136
|
-
if (input.line === undefined && input.symbol === undefined) {
|
|
142
|
+
if (input.mode === "outline" || (input.mode === undefined && input.line === undefined && input.symbol === undefined)) {
|
|
137
143
|
const total = index.symbols.length;
|
|
138
144
|
if (offset > Math.max(1, total)) throw new Error(`Outline offset=${offset} is beyond ${total} entries. Next: ${callText({ ...input, offset: Math.max(1, total - 49) })}`);
|
|
139
145
|
const entries: string[] = [];
|
|
@@ -201,11 +207,12 @@ export async function executeReadSymbol(input: ReadSymbolInput, signal: AbortSig
|
|
|
201
207
|
export function registerReadSymbolTool(pi: ExtensionAPI): void {
|
|
202
208
|
pi.registerTool({
|
|
203
209
|
name: "read_symbol", label: "read_symbol",
|
|
204
|
-
description: `Read a complete function, method, class, or type from a local source file
|
|
205
|
-
promptSnippet: "Read whole source
|
|
210
|
+
description: `Read a complete function, method, class, or type from a local source file. Always set mode: use mode="symbol" with an exact symbol name or containing line; use mode="outline" only to list names and ranges. Supports ${SUPPORTED_LANGUAGES}. Includes enclosing names, exact source ranges, and actionable failure context. Output is bounded to 48 KiB/1950 lines; partial results include an exact continuation call. Does not replace read for ordinary text or images.`,
|
|
211
|
+
promptSnippet: "Read one whole source symbol by line/name; set mode=outline only to list declarations",
|
|
206
212
|
promptGuidelines: [
|
|
207
|
-
"
|
|
208
|
-
"Use read_symbol with symbol
|
|
213
|
+
"Always set read_symbol mode. Use mode=\"symbol\" with a symbol name or containing line to read source; mode=\"outline\" only lists declarations and does not read their bodies.",
|
|
214
|
+
"Use read_symbol with mode=\"symbol\", path, and line after grep/edit identifies a code location, instead of guessing successive read offsets to find the function boundary.",
|
|
215
|
+
"Use read_symbol with mode=\"symbol\" and symbol for an exact name or qualified name. Use parent to include an enclosing function or class.",
|
|
209
216
|
"For read_symbol partial output, use the supplied continuation arguments. offset is relative to the selection, not a file line. Only displayed source is evidence for edit.",
|
|
210
217
|
],
|
|
211
218
|
parameters: readSymbolSchema,
|
package/src/source-file.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { MAX_SOURCE_BYTES } from "./symbols.ts";
|
|
|
5
5
|
import { normalizeToLF, splitBom } from "./text.ts";
|
|
6
6
|
|
|
7
7
|
/** Bounded local source read; no silent decoding loss and no special files. */
|
|
8
|
-
export async function readSource(path: string, signal?: AbortSignal): Promise<string> {
|
|
8
|
+
export async function readSource(path: string, signal?: AbortSignal, toolName = "read_symbol"): Promise<string> {
|
|
9
9
|
signal?.throwIfAborted();
|
|
10
10
|
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
11
11
|
try {
|
|
@@ -21,7 +21,7 @@ export async function readSource(path: string, signal?: AbortSignal): Promise<st
|
|
|
21
21
|
length += bytesRead;
|
|
22
22
|
}
|
|
23
23
|
const after = await handle.stat();
|
|
24
|
-
if (length !== stat.size || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs || stat.ctimeMs !== after.ctimeMs) throw new Error(
|
|
24
|
+
if (length !== stat.size || stat.size !== after.size || stat.mtimeMs !== after.mtimeMs || stat.ctimeMs !== after.ctimeMs) throw new Error(`The source changed during the read; retry ${toolName}.`);
|
|
25
25
|
const bytes = buffer.subarray(0, length);
|
|
26
26
|
let content: string;
|
|
27
27
|
try { content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
@@ -35,7 +35,7 @@ export async function readSource(path: string, signal?: AbortSignal): Promise<st
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/** Only inspect a bounded number of siblings; never perform a hidden repo scan. */
|
|
38
|
-
export async function sourceReadError(path: string, error: unknown): Promise<string> {
|
|
38
|
+
export async function sourceReadError(path: string, error: unknown, toolName = "read_symbol"): Promise<string> {
|
|
39
39
|
const code = (error as NodeJS.ErrnoException)?.code;
|
|
40
40
|
const message = error instanceof Error ? error.message.slice(0, 1000) : String(error).slice(0, 1000);
|
|
41
41
|
const lines = [`Could not read source ${JSON.stringify(path)}: ${message}`];
|
|
@@ -52,9 +52,11 @@ export async function sourceReadError(path: string, error: unknown): Promise<str
|
|
|
52
52
|
const stem = basename(path).split(".")[0].toLowerCase();
|
|
53
53
|
names.sort((a, b) => Number(b.toLowerCase().includes(stem)) - Number(a.toLowerCase().includes(stem)) || a.localeCompare(b));
|
|
54
54
|
if (names.length) lines.push("Nearby file candidates (bounded listing, not automatic path corrections):", ...names.slice(0, 8).map((name) => ` ${JSON.stringify(join(dirname(path), name))}`));
|
|
55
|
-
lines.push(
|
|
55
|
+
lines.push(`Verify the path and retry ${toolName}; use find/ls if the parent path is wrong.`);
|
|
56
56
|
} else if (code === "EACCES" || code === "EPERM") {
|
|
57
57
|
lines.push("Check file permissions. Do not retry the same call until access changes.");
|
|
58
|
-
} else lines.push(
|
|
58
|
+
} else lines.push(toolName === "read_symbol"
|
|
59
|
+
? "Use read for ordinary text, images, or bounded line ranges; no symbol boundaries were returned."
|
|
60
|
+
: "Use read for ordinary text, images, or bounded line ranges; no import lines were returned.");
|
|
59
61
|
return lines.join("\n");
|
|
60
62
|
}
|