@khanhicetea/pi-better-tool 0.2.3 → 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.
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`** is a new tool. Read a whole function, method, class, or type by containing line or exact name. With only a path, get a symbol outline.
6
+ - **`read_symbol`** reads a whole function, method, class, or type by containing line or exact name. With only a path, it gets a symbol outline.
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
@@ -95,6 +96,18 @@ Limits:
95
96
 
96
97
  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
98
 
99
+ ## Read imports before editing them
100
+
101
+ Call `read_code_imports` with a source path:
102
+
103
+ ```json
104
+ {"path":"src/server.ts"}
105
+ ```
106
+
107
+ 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.
108
+
109
+ 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.
110
+
98
111
  ## Recover from an edit failure in the next call
99
112
 
100
113
  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.3",
4
- "description": "Context-aware tools for pi: safe edit recovery and syntax-aware read_symbol for whole functions, methods, and classes by line or name",
3
+ "version": "0.2.4",
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/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 a separate syntax-aware source reader:
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
+ }
@@ -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("The source changed during the read; retry read_symbol.");
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("Verify the path and retry read_symbol; use find/ls if the parent path is wrong.");
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("Use read for ordinary text, images, or bounded line ranges; no symbol boundaries were returned.");
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
  }