@khanhicetea/pi-better-tool 0.2.2 → 0.2.4

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.
@@ -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
+ }
@@ -6,6 +6,7 @@ import {
6
6
  type ExtensionContext,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { normalizeToLF } from "./text.ts";
9
+ import { buildSymbolRead, type ReadSymbolInput } from "./read-symbol.ts";
9
10
 
10
11
  export interface ReadEvidence {
11
12
  /** 0-based, end-exclusive offsets in LF-normalized, BOM-stripped content. */
@@ -18,6 +19,8 @@ export interface ReadEvidence {
18
19
 
19
20
  interface ReadCall {
20
21
  id: string;
22
+ name: "read" | "read_symbol";
23
+ arguments: Record<string, unknown>;
21
24
  path: string;
22
25
  offset?: number;
23
26
  limit?: number;
@@ -69,11 +72,13 @@ export async function findLatestReadEvidence(
69
72
  for (const message of messages) {
70
73
  if (message.role === "assistant") {
71
74
  for (const item of (message as StoredAssistant).content) {
72
- if (item.type !== "toolCall" || item.name !== "read" || typeof item.id !== "string") continue;
75
+ if (item.type !== "toolCall" || (item.name !== "read" && item.name !== "read_symbol") || typeof item.id !== "string") continue;
73
76
  const args = item.arguments as Record<string, unknown> | undefined;
74
77
  if (!args || typeof args.path !== "string") continue;
75
78
  calls.push({
76
79
  id: item.id,
80
+ name: item.name,
81
+ arguments: args,
77
82
  path: args.path,
78
83
  offset: typeof args.offset === "number" ? args.offset : undefined,
79
84
  limit: typeof args.limit === "number" ? args.limit : undefined,
@@ -81,7 +86,7 @@ export async function findLatestReadEvidence(
81
86
  }
82
87
  } else if (message.role === "toolResult") {
83
88
  const result = message as StoredToolResult;
84
- if (result.toolName === "read") results.set(result.toolCallId, result);
89
+ if (result.toolName === "read" || result.toolName === "read_symbol") results.set(result.toolCallId, result);
85
90
  }
86
91
  }
87
92
 
@@ -100,7 +105,17 @@ export async function findLatestReadEvidence(
100
105
  // Never fall back to older intent when the newest same-file read is
101
106
  // missing, failed, malformed, or stale.
102
107
  const result = results.get(call.id);
103
- if (!result || result.isError) return null;
108
+ if (!result || result.isError || result.toolName !== call.name) return null;
109
+ if (call.name === "read_symbol") {
110
+ if (result.content.length !== 1 || result.content[0].type !== "text") return null;
111
+ try {
112
+ // Regenerate from current source and original arguments, not untrusted
113
+ // details. Snapshot, selector, envelope, and visible bytes must agree.
114
+ const expected = await buildSymbolRead(call.arguments as ReadSymbolInput, normalizedContent);
115
+ if (result.content[0].text !== expected.content[0].text) return null;
116
+ return expected.details.visible ?? null;
117
+ } catch { return null; }
118
+ }
104
119
  return evidenceFromBuiltinRead(normalizedContent, call, result.content);
105
120
  }
106
121
  return null;
@@ -155,7 +170,9 @@ export function evidenceFromBuiltinRead(
155
170
  const startLine = startIndex + 1;
156
171
  const endLine = startLine + visibleLines - 1;
157
172
  const startOffset = offsetAtLine(content, startLine);
158
- const endOffset = offsetAtLine(content, endLine + 1);
173
+ // A line-limited/truncated result omits the separator after its final
174
+ // displayed line. Do not authorize an edit anchor through unseen bytes.
175
+ const endOffset = startOffset + truncation.content.length;
159
176
  return { startOffset, endOffset, startLine, endLine };
160
177
  }
161
178
 
@@ -0,0 +1,214 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { Type, type Static } from "typebox";
4
+ import { boundCompleteOutput, fenceFor } from "./diagnostics.ts";
5
+ import { resolveToolPath } from "./paths.ts";
6
+ import { readSource, sourceReadError } from "./source-file.ts";
7
+ import { indexSymbols, languageForPath, SUPPORTED_LANGUAGES, type SourceSymbol } from "./symbols.ts";
8
+ import { getLineSpans } from "./text.ts";
9
+
10
+ export const readSymbolSchema = Type.Object({
11
+ path: Type.String({ minLength: 1, maxLength: 4096, description: "Local source file path (relative, absolute, @path, ~/path, or file URL)." }),
12
+ line: Type.Optional(Type.Integer({ minimum: 1, description: "Read the innermost symbol containing this 1-based line. Combine with symbol to disambiguate duplicate names." })),
13
+ 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: "Exact symbol name or qualified name, for example Server.run. Omit both symbol and line to list a symbol outline." })),
15
+ 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
+ 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
+ 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
+ export type ReadSymbolInput = Static<typeof readSymbolSchema>;
21
+
22
+ export interface VisibleSource {
23
+ startLine: number;
24
+ endLine: number;
25
+ startOffset: number;
26
+ endOffset: number;
27
+ }
28
+ export interface ReadSymbolResult {
29
+ content: Array<{ type: "text"; text: string }>;
30
+ details: {
31
+ mode: "symbol" | "outline";
32
+ snapshot: string;
33
+ symbol?: SourceSymbol;
34
+ visible?: VisibleSource;
35
+ complete: boolean;
36
+ nextCall?: ReadSymbolInput;
37
+ };
38
+ }
39
+ const MAX_OUTPUT_BYTES = 48 * 1024;
40
+ const MAX_OUTPUT_LINES = 1950;
41
+ const label = (text: string) => JSON.stringify(text.length > 300 ? `${text.slice(0, 297)}…` : text);
42
+ const callText = (input: ReadSymbolInput) => `read_symbol ${JSON.stringify(input)}`;
43
+
44
+ export function validateReadSymbolInput(input: ReadSymbolInput): void {
45
+ if (!input || typeof input.path !== "string" || !input.path.length || input.path.length > 4096) throw new Error("read_symbol requires a non-empty path of at most 4096 characters.");
46
+ if (input.symbol !== undefined && (typeof input.symbol !== "string" || !input.symbol.trim() || input.symbol.length > 256)) throw new Error("read_symbol symbol must be a non-empty name of at most 256 characters.");
47
+ for (const [key, min, max] of [["line", 1, Number.MAX_SAFE_INTEGER], ["column", 1, Number.MAX_SAFE_INTEGER], ["parent", 0, 20], ["context", 0, 20], ["offset", 1, Number.MAX_SAFE_INTEGER], ["limit", 1, 1800]] as const) {
48
+ const value = input[key];
49
+ 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
+ }
51
+ if (input.column !== undefined && input.line === undefined) throw new Error("read_symbol column requires line.");
52
+ if ((input.parent || input.context) && input.line === undefined && input.symbol === undefined) throw new Error("read_symbol parent/context requires a line or symbol selector; omit them to get an outline.");
53
+ }
54
+
55
+ 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 })}`;
57
+ }
58
+
59
+ /** Non-copyable preview for failed selection; never presented as a complete symbol. */
60
+ function fallbackContext(input: ReadSymbolInput, content: string, reason: string, atLine = input.line ?? 1): Error {
61
+ const lines = content.split("\n");
62
+ const center = Math.min(Math.max(atLine, 1), lines.length);
63
+ const start = Math.max(1, center - 5);
64
+ const end = Math.min(lines.length, center + 10);
65
+ const preview = lines.slice(start - 1, end).map((line, index) => `${start + index}: ${line.length > 240 ? `${line.slice(0, 240)}… [line clipped]` : line}`).join("\n");
66
+ return new Error(`${reason}\nFile has ${lines.length} lines. Preview only, not a complete symbol or retryable edit snippet:\n${preview}\nNext: read ${JSON.stringify({ path: input.path, offset: start, limit: end - start + 1 })}`);
67
+ }
68
+
69
+ function inside(symbol: SourceSymbol, line: number, column?: number): boolean {
70
+ if (line < symbol.startLine || line > symbol.endLine) return false;
71
+ if (column !== undefined && ((line === symbol.startLine && column < symbol.startColumn) || (line === symbol.endLine && column >= symbol.endColumn))) return false;
72
+ return true;
73
+ }
74
+
75
+ function selectSymbol(input: ReadSymbolInput, symbols: SourceSymbol[]): SourceSymbol {
76
+ let matches = symbols.filter((symbol) =>
77
+ (input.symbol === undefined || symbol.name === input.symbol || symbol.qualifiedName === input.symbol) &&
78
+ (input.line === undefined || inside(symbol, input.line, input.column)),
79
+ );
80
+ if (input.symbol === undefined) {
81
+ // Keep innermost declarations, but never choose arbitrarily between siblings.
82
+ const matched = new Set(matches);
83
+ const ancestors = new Set<SourceSymbol>();
84
+ // Parents precede children in the index. Propagate once in reverse,
85
+ // rather than walking every ancestor chain in deeply nested source.
86
+ for (let index = symbols.length - 1; index >= 0; index--) {
87
+ const symbol = symbols[index];
88
+ if (symbol.parent !== undefined && (matched.has(symbol) || ancestors.has(symbol))) ancestors.add(symbols[symbol.parent]);
89
+ }
90
+ matches = matches.filter((symbol) => !ancestors.has(symbol));
91
+ }
92
+ if (matches.length !== 1) {
93
+ const candidates = matches.length ? matches : [...symbols].sort((a, b) => {
94
+ if (input.line !== undefined) return Math.abs(a.startLine - input.line) - Math.abs(b.startLine - input.line);
95
+ const query = input.symbol?.toLowerCase() ?? "";
96
+ return Number(b.qualifiedName.toLowerCase().includes(query)) - Number(a.qualifiedName.toLowerCase().includes(query));
97
+ });
98
+ throw new Error([
99
+ 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
+ ...candidates.slice(0, 8).map((symbol) => candidateLine(input.path, symbol)),
101
+ ...(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 })}.`,
103
+ ].join("\n"));
104
+ }
105
+ let selected = matches[0];
106
+ for (let depth = 0; depth < (input.parent ?? 0); depth++) {
107
+ if (selected.parent === undefined) throw new Error(`No enclosing symbol at parent=${input.parent}. Outermost available: ${candidateLine(input.path, selected)}\nRetry with a smaller parent value.`);
108
+ selected = symbols[selected.parent];
109
+ }
110
+ return selected;
111
+ }
112
+
113
+ function fits(text: string): boolean {
114
+ return Buffer.byteLength(text, "utf8") <= MAX_OUTPUT_BYTES && text.split("\n").length <= MAX_OUTPUT_LINES;
115
+ }
116
+
117
+ /** Pure snapshot-to-result path, also used to verify stored read evidence. */
118
+ export async function buildSymbolRead(input: ReadSymbolInput, content: string, signal?: AbortSignal): Promise<ReadSymbolResult> {
119
+ validateReadSymbolInput(input);
120
+ const lines = content.split("\n");
121
+ if (input.line !== undefined && input.line > lines.length) throw fallbackContext(input, content, `line=${input.line} is beyond EOF. Valid lines: 1-${lines.length}.`);
122
+ if (input.column !== undefined && input.column > lines[input.line! - 1].length + 1) throw fallbackContext(input, content, `column=${input.column} is outside line ${input.line}.`);
123
+ const parserPath = resolveToolPath(input.path, "/");
124
+ if (!languageForPath(parserPath)) throw fallbackContext(input, content, `Unsupported source type. read_symbol supports ${SUPPORTED_LANGUAGES}. Use read for this file.`);
125
+ let index: Awaited<ReturnType<typeof indexSymbols>>;
126
+ try { index = await indexSymbols(parserPath, content, signal); }
127
+ catch (error) {
128
+ signal?.throwIfAborted();
129
+ throw fallbackContext(input, content, `Symbol parser unavailable or analysis limit reached: ${error instanceof Error ? error.message.slice(0, 500) : "unknown parser error"}. Use read instead.`);
130
+ }
131
+ signal?.throwIfAborted();
132
+ if (index.errorLine !== undefined) throw fallbackContext(input, content, `Syntax error or incomplete syntax near line ${index.errorLine}; complete symbol boundaries are not reliable.`, input.line ?? index.errorLine);
133
+ const snapshot = createHash("sha256").update(content).digest("hex");
134
+ const header = `Source ${label(input.path)} (${lines.length} lines). Snapshot sha256:${snapshot}`;
135
+ const offset = input.offset ?? 1;
136
+ if (input.line === undefined && input.symbol === undefined) {
137
+ const total = index.symbols.length;
138
+ 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
+ const entries: string[] = [];
140
+ let cursor = offset - 1;
141
+ for (; cursor < Math.min(total, offset - 1 + (input.limit ?? 50)); cursor++) {
142
+ const entry = candidateLine(input.path, index.symbols[cursor]);
143
+ if (!fits(`${header}\n${entries.join("\n")}\n${entry}\n${" ".repeat(6000)}`)) break;
144
+ entries.push(entry);
145
+ }
146
+ const nextCall = cursor < total ? { ...input, offset: cursor + 1 } : undefined;
147
+ const text = `${header}\nSymbol outline: ${total} declarations, ${entries.length} shown.${total === 0 ? " No symbols found; use read for source text." : ""}\n${entries.join("\n")}${nextCall ? `\nMore entries. Next: ${callText(nextCall)}` : ""}`;
148
+ return { content: [{ type: "text", text }], details: { mode: "outline", snapshot, complete: !nextCall, nextCall } };
149
+ }
150
+ if (!index.symbols.length) throw fallbackContext(input, content, "No symbol declarations found in this file.");
151
+ const selected = selectSymbol(input, index.symbols);
152
+ const start = Math.max(1, selected.startLine - (input.context ?? 0));
153
+ const end = Math.min(lines.length, selected.endLine + (input.context ?? 0));
154
+ const count = end - start + 1;
155
+ if (offset > count) throw new Error(`Symbol ${label(selected.qualifiedName)} spans lines ${selected.startLine}-${selected.endLine}; selected region has ${count} lines. offset=${offset} is outside it. Next: ${callText({ ...input, offset: 1 })}`);
156
+ const firstLine = start + offset - 1;
157
+ let lastLine = Math.min(end, firstLine + (input.limit ?? 1000) - 1);
158
+ const parents: string[] = [];
159
+ let parent = selected.parent;
160
+ while (parent !== undefined && parents.length < 20) { parents.unshift(label(index.symbols[parent].qualifiedName)); parent = index.symbols[parent].parent; }
161
+ const prefix = `${header}\nSymbol ${label(selected.qualifiedName)} (${selected.kind}), lines ${selected.startLine}-${selected.endLine}.${parents.length ? `\nEnclosing: ${parents.join(" > ")}` : ""}`;
162
+ let text = "";
163
+ let snippet = "";
164
+ let nextCall: ReadSymbolInput | undefined;
165
+ // Whole-line pages only. Account for fences, metadata, and continuation JSON.
166
+ while (lastLine >= firstLine) {
167
+ snippet = lines.slice(firstLine - 1, lastLine).join("\n");
168
+ const fence = fenceFor(snippet);
169
+ nextCall = lastLine < end ? { ...input, offset: lastLine - start + 2 } : undefined;
170
+ text = `${prefix}\nShowing file lines ${firstLine}-${lastLine} of selected lines ${start}-${end}. ${firstLine === start && lastLine === end ? "Complete selection." : "Partial selection; do not treat this page as the whole symbol."}\n\n${fence}\n${snippet}\n${fence}${nextCall ? `\n\nNext: ${callText(nextCall)}` : ""}`;
171
+ if (fits(text)) break;
172
+ // Remove a proportional chunk first; small pages shrink one line at a time.
173
+ lastLine -= Math.max(1, Math.floor((lastLine - firstLine + 1) / 4));
174
+ }
175
+ if (lastLine < firstLine) throw fallbackContext(input, content, `File line ${firstLine} cannot fit as a complete line in the 48 KiB output budget. No partial edit snippet was returned.`, firstLine);
176
+ const spans = getLineSpans(content);
177
+ const startOffset = spans[firstLine - 1]?.start ?? content.length;
178
+ return {
179
+ content: [{ type: "text", text }],
180
+ details: {
181
+ mode: "symbol", snapshot, symbol: selected,
182
+ visible: { startLine: firstLine, endLine: lastLine, startOffset, endOffset: startOffset + snippet.length },
183
+ complete: firstLine === start && lastLine === end, nextCall,
184
+ },
185
+ };
186
+ }
187
+
188
+ export async function executeReadSymbol(input: ReadSymbolInput, signal: AbortSignal | undefined, ctx: Pick<ExtensionContext, "cwd">): Promise<ReadSymbolResult> {
189
+ validateReadSymbolInput(input);
190
+ const path = resolveToolPath(input.path, ctx.cwd);
191
+ let content: string;
192
+ try { content = await readSource(path, signal); }
193
+ catch (error) { signal?.throwIfAborted(); throw new Error(boundCompleteOutput(await sourceReadError(path, error))); }
194
+ try { return await buildSymbolRead(input, content, signal); }
195
+ catch (error) {
196
+ signal?.throwIfAborted();
197
+ throw new Error(boundCompleteOutput(error instanceof Error ? error.message : String(error)));
198
+ }
199
+ }
200
+
201
+ export function registerReadSymbolTool(pi: ExtensionAPI): void {
202
+ pi.registerTool({
203
+ name: "read_symbol", label: "read_symbol",
204
+ description: `Read a complete function, method, class, or type from a local source file by containing line or exact symbol name. Supports ${SUPPORTED_LANGUAGES}. With path only, lists a symbol outline. 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.`,
205
+ promptSnippet: "Read whole source symbols by line/name, or list a file's symbol outline",
206
+ promptGuidelines: [
207
+ "Use read_symbol with path and line after grep/edit identifies a code location, instead of guessing successive read offsets to find the function boundary.",
208
+ "Use read_symbol with symbol for an exact name or qualified name; use path alone for an outline. Use parent to include an enclosing function or class.",
209
+ "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
+ ],
211
+ parameters: readSymbolSchema,
212
+ async execute(_id, input, signal, _onUpdate, ctx) { return executeReadSymbol(input, signal, ctx); },
213
+ });
214
+ }
@@ -0,0 +1,62 @@
1
+ import { constants } from "node:fs";
2
+ import { open, opendir } from "node:fs/promises";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { MAX_SOURCE_BYTES } from "./symbols.ts";
5
+ import { normalizeToLF, splitBom } from "./text.ts";
6
+
7
+ /** Bounded local source read; no silent decoding loss and no special files. */
8
+ export async function readSource(path: string, signal?: AbortSignal, toolName = "read_symbol"): Promise<string> {
9
+ signal?.throwIfAborted();
10
+ const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
11
+ try {
12
+ const stat = await handle.stat();
13
+ if (!stat.isFile()) throw new Error("The path is not a regular source file.");
14
+ if (stat.size > MAX_SOURCE_BYTES) throw new Error("Source exceeds the 2 MiB symbol analysis limit; use read with offset/limit.");
15
+ const buffer = Buffer.alloc(Math.min(stat.size + 1, MAX_SOURCE_BYTES + 1));
16
+ let length = 0;
17
+ while (length < buffer.length) {
18
+ signal?.throwIfAborted();
19
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
20
+ if (!bytesRead) break;
21
+ length += bytesRead;
22
+ }
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(`The source changed during the read; retry ${toolName}.`);
25
+ const bytes = buffer.subarray(0, length);
26
+ let content: string;
27
+ try { content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
28
+ catch { throw new Error("The source is not valid UTF-8; convert its encoding before symbol parsing."); }
29
+ if (bytes.includes(0)) throw new Error("The source contains NUL bytes; binary/UTF-16 input is not supported.");
30
+ signal?.throwIfAborted();
31
+ return splitBom(normalizeToLF(content)).text;
32
+ } finally {
33
+ await handle.close();
34
+ }
35
+ }
36
+
37
+ /** Only inspect a bounded number of siblings; never perform a hidden repo scan. */
38
+ export async function sourceReadError(path: string, error: unknown, toolName = "read_symbol"): Promise<string> {
39
+ const code = (error as NodeJS.ErrnoException)?.code;
40
+ const message = error instanceof Error ? error.message.slice(0, 1000) : String(error).slice(0, 1000);
41
+ const lines = [`Could not read source ${JSON.stringify(path)}: ${message}`];
42
+ if (code === "ENOENT" || code === "ENOTDIR") {
43
+ const names: string[] = [];
44
+ try {
45
+ const directory = await opendir(dirname(path));
46
+ let inspected = 0;
47
+ for await (const entry of directory) {
48
+ if (entry.isFile()) names.push(entry.name);
49
+ if (++inspected >= 100) break;
50
+ }
51
+ } catch { /* Parent may also be missing or inaccessible. */ }
52
+ const stem = basename(path).split(".")[0].toLowerCase();
53
+ names.sort((a, b) => Number(b.toLowerCase().includes(stem)) - Number(a.toLowerCase().includes(stem)) || a.localeCompare(b));
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(`Verify the path and retry ${toolName}; use find/ls if the parent path is wrong.`);
56
+ } else if (code === "EACCES" || code === "EPERM") {
57
+ lines.push("Check file permissions. Do not retry the same call until access changes.");
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.");
61
+ return lines.join("\n");
62
+ }