opencodekit 0.23.0 → 0.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/dist/index.js +354 -825
  2. package/dist/template/.opencode/AGENTS.md +15 -0
  3. package/dist/template/.opencode/command/init.md +198 -34
  4. package/dist/template/.opencode/context/fallow.md +137 -0
  5. package/dist/template/.opencode/dcp-prompts/overrides/compress-range.md +89 -0
  6. package/dist/template/.opencode/opencode.json +110 -315
  7. package/dist/template/.opencode/plugin/README.md +10 -0
  8. package/dist/template/.opencode/plugin/memory/compile.ts +171 -186
  9. package/dist/template/.opencode/plugin/memory/index-generator.ts +118 -133
  10. package/dist/template/.opencode/plugin/memory/lint.ts +253 -275
  11. package/dist/template/.opencode/plugin/memory/tools.ts +224 -268
  12. package/dist/template/.opencode/plugin/memory/validate.ts +154 -164
  13. package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search-preview.ts +13 -30
  14. package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search-shared.ts +25 -0
  15. package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search.ts +17 -34
  16. package/dist/template/.opencode/plugin/session-summary.ts +542 -0
  17. package/dist/template/.opencode/plugin/srcwalk.ts +775 -661
  18. package/dist/template/.opencode/skill/condition-based-waiting/example.ts +15 -2
  19. package/dist/template/.opencode/skill/fallow/SKILL.md +409 -0
  20. package/dist/template/.opencode/skill/fallow/references/cli-reference.md +1905 -0
  21. package/dist/template/.opencode/skill/fallow/references/gotchas.md +644 -0
  22. package/dist/template/.opencode/skill/fallow/references/patterns.md +791 -0
  23. package/package.json +2 -2
@@ -16,7 +16,7 @@
16
16
  * - srcwalk_impact — Heuristic blast-radius triage
17
17
  */
18
18
 
19
- import { execFile, execFileSync } from "node:child_process";
19
+ import { execFileSync } from "node:child_process";
20
20
  import { existsSync, readFileSync, statSync } from "node:fs";
21
21
  import { readdir } from "node:fs/promises";
22
22
  import path from "node:path";
@@ -31,637 +31,745 @@ const MAX_BUFFER = 5 * 1024 * 1024;
31
31
  // Helpers
32
32
  // ---------------------------------------------------------------------------
33
33
 
34
- function run(cmd: string, args: string[], cwd?: string): { stdout: string; stderr: string; code: number } {
35
- try {
36
- const result = execFileSync(cmd, args, {
37
- encoding: "utf-8",
38
- timeout: TIMEOUT_MS,
39
- maxBuffer: MAX_BUFFER,
40
- cwd: cwd ?? process.cwd(),
41
- stdio: ["ignore", "pipe", "pipe"],
42
- });
43
- return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: 0 };
44
- } catch (err: unknown) {
45
- const e = err as { stdout?: string; stderr?: string; status?: number; message?: string };
46
- return {
47
- stdout: e.stdout ?? "",
48
- stderr: e.stderr ?? "",
49
- code: e.status ?? 1,
50
- };
51
- }
34
+ function run(
35
+ cmd: string,
36
+ args: string[],
37
+ cwd?: string,
38
+ ): { stdout: string; stderr: string; code: number } {
39
+ try {
40
+ const result = execFileSync(cmd, args, {
41
+ encoding: "utf-8",
42
+ timeout: TIMEOUT_MS,
43
+ maxBuffer: MAX_BUFFER,
44
+ cwd: cwd ?? process.cwd(),
45
+ stdio: ["ignore", "pipe", "pipe"],
46
+ });
47
+ return { stdout: result.stdout ?? "", stderr: result.stderr ?? "", code: 0 };
48
+ } catch (err: unknown) {
49
+ const e = err as { stdout?: string; stderr?: string; status?: number; message?: string };
50
+ return {
51
+ stdout: e.stdout ?? "",
52
+ stderr: e.stderr ?? "",
53
+ code: e.status ?? 1,
54
+ };
55
+ }
52
56
  }
53
57
 
54
58
  function hasRg(): boolean {
55
- try {
56
- execFileSync(BIN_RG, ["--version"], { encoding: "utf-8", timeout: 1000, stdio: "ignore" });
57
- return true;
58
- } catch {
59
- return false;
60
- }
59
+ try {
60
+ execFileSync(BIN_RG, ["--version"], { encoding: "utf-8", timeout: 1000, stdio: "ignore" });
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
61
65
  }
62
66
 
63
67
  function plural(n: number, word: string): string {
64
- return `${n} ${word}${n !== 1 ? "s" : ""}`;
68
+ return `${n} ${word}${n !== 1 ? "s" : ""}`;
65
69
  }
66
70
 
67
71
  // ---------------------------------------------------------------------------
68
72
  // Plugin
69
73
  // ---------------------------------------------------------------------------
70
74
 
71
- interface PluginConfig {
72
- directory?: string;
73
- }
74
-
75
75
  const srcwalkTools = {
76
- // -----------------------------------------------------------------------
77
- // srcwalk_search: Search codebase
78
- // -----------------------------------------------------------------------
79
- "srcwalk_search": tool({
80
- description: `Search for symbols, text, or regex patterns in code.\n\nUses ripgrep (rg) when available, falls back to grep.\nSupports symbol search (definitions first), content search, and regex.`,
81
- args: {
82
- query: tool.schema.string().describe("Search query or regex pattern"),
83
- scope: tool.schema.string().optional().describe("Subdirectory scope (default: project root)"),
84
- kind: tool.schema.string().optional().describe("Search type: text (default), regex, content"),
85
- context: tool.schema.number().optional().describe("Lines of context before/after each match"),
86
- limit: tool.schema.number().optional().describe("Max results (default: 30)"),
87
- },
88
- execute: async (args, context) => {
89
- const query = String(args.query ?? "").trim();
90
- if (!query) return "❌ query is required.";
91
- const scope = args.scope ? String(args.scope) : context.directory;
92
- const limit = Math.min(args.limit ?? 30, 100);
93
- const ctxLines = args.context ?? 0;
94
- const kind = String(args.kind ?? "text");
95
-
96
- const searchDir = path.resolve(context.directory, scope);
97
-
98
- if (hasRg()) {
99
- const rgArgs = ["--no-heading", "--line-number", "--color", "never"];
100
- rgArgs.push("--max-count", String(limit * 2));
101
- if (ctxLines > 0) {
102
- rgArgs.push("-C", String(ctxLines));
103
- }
104
- if (kind === "regex") {
105
- rgArgs.push("-E", "rust");
106
- } else {
107
- rgArgs.push("-F"); // literal
108
- }
109
- rgArgs.push(query, searchDir);
110
-
111
- const result = run(BIN_RG, rgArgs);
112
- if (result.code !== 0 && result.stderr) {
113
- // Try plain grep fallback
114
- } else {
115
- const lines = result.stdout.split("\n").filter(Boolean).slice(0, limit);
116
- if (lines.length === 0) return "No matches found.";
117
- return lines.join("\n");
118
- }
119
- }
120
-
121
- // Fallback to grep
122
- const grepArgs = ["-rn", "--color=never"];
123
- if (ctxLines > 0) grepArgs.push("-C", String(ctxLines));
124
- if (kind === "regex") {
125
- grepArgs.push("-E");
126
- } else {
127
- grepArgs.push("-F");
128
- }
129
- grepArgs.push(query, searchDir);
130
-
131
- const result = run("grep", grepArgs);
132
- const lines = result.stdout.split("\n").filter(Boolean).slice(0, limit);
133
- if (lines.length === 0) return "No matches found.";
134
- return lines.join("\n");
135
- },
136
- }),
137
-
138
- // -----------------------------------------------------------------------
139
- // srcwalk_read: Read files
140
- // -----------------------------------------------------------------------
141
- "srcwalk_read": tool({
142
- description: `Read a file with optional section (line range, symbol, or path:line format).\n\nSmall files return full content; large files support outlining.\nUse path:start-end for range reads (e.g. "src/app.ts:44-89").`,
143
- args: {
144
- path: tool.schema.string().describe("File path to read (supports path:line or path:start-end)"),
145
- section: tool.schema.string().optional().describe("Line range '44-89' or heading/symbol name"),
146
- full: tool.schema.boolean().optional().describe("Force full content"),
147
- },
148
- execute: async (args, context) => {
149
- const fileArg = String(args.path);
150
- const fullFilePath = path.resolve(context.directory, fileArg);
151
-
152
- // Parse path:line or path:start-end shortcut
153
- let startLine: number | undefined;
154
- let endLine: number | undefined;
155
-
156
- const rangeMatch = fileArg.match(/^(.+?):(\d+)(?:-(\d+))?$/);
157
- if (rangeMatch) {
158
- const relPath = rangeMatch[1];
159
- const resolved = path.resolve(context.directory, relPath);
160
- startLine = parseInt(rangeMatch[2], 10);
161
- endLine = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : startLine;
162
- return readFileRange(resolved, startLine, endLine, relPath);
163
- }
164
-
165
- if (!existsSync(fullFilePath)) return `File not found: ${fileArg}`;
166
- const stats = statSync(fullFilePath);
167
-
168
- // If section specified, try grep for symbol/heading
169
- if (args.section) {
170
- const section = String(args.section);
171
- // Check if it's a line range
172
- const lineMatch = section.match(/^(\d+)(?:-(\d+))?$/);
173
- if (lineMatch) {
174
- const start = parseInt(lineMatch[1], 10);
175
- const end = lineMatch[2] ? parseInt(lineMatch[2], 10) : start + 30;
176
- return readFileRange(fullFilePath, start, end);
177
- }
178
- // Symbol/heading: use grep to find location, then read around it
179
- const grepArgs = ["-n", "--color=never", "-E", `^(function |const |let |class |interface |type |export |## |### )?.*${section}`, fullFilePath];
180
- const result = run("grep", grepArgs);
181
- if (result.stdout) {
182
- const firstMatch = result.stdout.split("\n")[0];
183
- const lineNum = parseInt(firstMatch.split(":")[0], 10);
184
- if (!isNaN(lineNum)) {
185
- return readFileRange(fullFilePath, Math.max(1, lineNum - 3), lineNum + 40);
186
- }
187
- }
188
- return `Section "${section}" not found in ${fileArg}`;
189
- }
190
-
191
- // Full content for small files
192
- if (stats.size < 50 * 1024 || args.full) {
193
- const content = readFileSync(fullFilePath, "utf-8");
194
- const lines = content.split("\n");
195
- if (lines.length > 2000)
196
- return `[File too large: ${plural(lines.length, "line")}. Showing first 2000 lines]\n\n${lines.slice(0, 2000).join("\n")}`;
197
- return content;
198
- }
199
-
200
- // Large file: outline
201
- const content = readFileSync(fullFilePath, "utf-8");
202
- const lines = content.split("\n");
203
- const headings: string[] = [];
204
- for (let i = 0; i < Math.min(lines.length, 5000); i++) {
205
- const l = lines[i].trim();
206
- if (l.match(/^(export\s+)?(function|class|interface|type|const|enum|def|struct|impl|pub\s+fn)\s/) ||
207
- l.match(/^(##|###)\s/) ||
208
- l.match(/^\w+\s*[:=]/)) {
209
- headings.push(` ${i + 1}: ${l.slice(0, 120)}`);
210
- }
211
- }
212
- const header = `[File: ${fileArg} — ${plural(lines.length, "line")}, ${(stats.size / 1024).toFixed(1)}KB]\n\n`;
213
- const outline = headings.length > 0
214
- ? `Outline (${plural(headings.length, "entry")}):\n${headings.slice(0, 50).join("\n")}\n\nUse path:line or section to read a specific range.`
215
- : `Use path:line to read a specific range (e.g., ${fileArg}:1-${Math.min(50, lines.length)}).`;
216
- return header + outline;
217
- },
218
- }),
219
-
220
- // -----------------------------------------------------------------------
221
- // srcwalk_files: Find files
222
- // -----------------------------------------------------------------------
223
- "srcwalk_files": tool({
224
- description: `Find files by glob pattern. Returns matched file paths with size estimates, grouped by directory. Respects .gitignore.`,
225
- args: {
226
- pattern: tool.schema.string().describe("Glob pattern (e.g. '*.ts', 'src/**/*.ts')"),
227
- scope: tool.schema.string().optional().describe("Directory to search (default: project root)"),
228
- },
229
- execute: async (args, context) => {
230
- const pattern = String(args.pattern ?? "").trim();
231
- if (!pattern) return "❌ pattern is required.";
232
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
233
-
234
- // Use find with simple glob pattern
235
- const findArgs: string[] = [scopeDir, "-type", "f"];
236
-
237
- // Convert simple glob to -name pattern
238
- if (pattern.includes("**")) {
239
- // find handles ** naturally with -path
240
- findArgs.push("-path", `*/${pattern.replace(/\*\*/g, "*")}`);
241
- } else if (pattern.includes("*")) {
242
- findArgs.push("-name", pattern);
243
- } else if (pattern.includes(".")) {
244
- findArgs.push("-name", pattern);
245
- } else {
246
- findArgs.push("-name", `*${pattern}*`);
247
- }
248
-
249
- // Exclude common dirs
250
- for (const dir of [".git", "node_modules", "dist", "build", "coverage", ".next", ".opencode"]) {
251
- findArgs.push("-not", "-path", `*/${dir}/*`);
252
- }
253
-
254
- const result = run("find", findArgs);
255
- const files = result.stdout.split("\n").filter(Boolean).slice(0, 200);
256
-
257
- if (files.length === 0) return `No files matching "${pattern}" found.`;
258
-
259
- // Group by directory
260
- const groups: Record<string, string[]> = {};
261
- for (const f of files) {
262
- const dir = path.dirname(f);
263
- if (!groups[dir]) groups[dir] = [];
264
- groups[dir].push(path.basename(f));
265
- }
266
-
267
- const lines: string[] = [`# Files matching "${pattern}" (${plural(files.length, "file")})\n`];
268
- for (const [dir, files] of Object.entries(groups).sort()) {
269
- const relDir = path.relative(context.directory, dir) || ".";
270
- lines.push(`${relDir}/ (${plural(files.length, "file")})`);
271
- for (const f of files) {
272
- const fp = path.join(dir, f);
273
- try {
274
- const size = statSync(fp).size;
275
- const tokenEst = Math.ceil(size / 4);
276
- lines.push(` ${f} (~${tokenEst.toLocaleString()} tokens)`);
277
- } catch {
278
- lines.push(` ${f}`);
279
- }
280
- }
281
- lines.push("");
282
- }
283
- return lines.join("\n");
284
- },
285
- }),
286
-
287
- // -----------------------------------------------------------------------
288
- // srcwalk_deps: Import analysis
289
- // -----------------------------------------------------------------------
290
- "srcwalk_deps": tool({
291
- description: `Show what imports a file (dependents) and what a file imports (dependencies).\nBlast-radius check before breaking changes.`,
292
- args: {
293
- path: tool.schema.string().describe("File path to analyze"),
294
- scope: tool.schema.string().optional().describe("Search scope (default: project root)"),
295
- },
296
- execute: async (args, context) => {
297
- const filePath = String(args.path);
298
- const absPath = path.resolve(context.directory, filePath);
299
- if (!existsSync(absPath)) return `File not found: ${filePath}`;
300
-
301
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
302
- const fileName = path.basename(filePath, path.extname(filePath));
303
-
304
- // What this file imports
305
- const content = readFileSync(absPath, "utf-8");
306
- const importLines: string[] = [];
307
- for (const line of content.split("\n")) {
308
- const m = line.match(/(?:import|require)\s+.*?from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/);
309
- if (m) importLines.push(` ${line.trim()}`);
310
- }
311
-
312
- // What imports this file (grep for the module name)
313
- const searchName = fileName;
314
- const grepResult = run("grep", [
315
- "-rn",
316
- "--color=never",
317
- "-E",
318
- `from ['"](\\./|\\.\\./|.*/)${searchName}['"]|require\\(['"](\\./|\\.\\./|.*/)${searchName}['"]`,
319
- scopeDir,
320
- "--include=*.ts",
321
- "--include=*.tsx",
322
- "--include=*.js",
323
- "--include=*.jsx",
324
- "--include=*.mjs",
325
- ]);
326
- const importers = grepResult.stdout.split("\n").filter(Boolean).slice(0, 30);
327
-
328
- const result: string[] = [`## Dependencies for ${filePath}\n`];
329
- result.push(`### Imports (${plural(importLines.length, "module")})`);
330
- if (importLines.length === 0) result.push(" (none)");
331
- else result.push(...importLines);
332
-
333
- result.push(`\n### Importers (${plural(importers.length, "file")})`);
334
- if (importers.length === 0) result.push(" (no files import this module)");
335
- else result.push(...importers.map(l => ` ${path.relative(context.directory, l.split(":")[0])}:${l.split(":")[1]}`));
336
-
337
- return result.join("\n");
338
- },
339
- }),
340
-
341
- // -----------------------------------------------------------------------
342
- // srcwalk_map: Directory overview
343
- // -----------------------------------------------------------------------
344
- "srcwalk_map": tool({
345
- description: `Token-annotated directory skeleton. Shows repo structure with file sizes and token estimates. Good for understanding codebase shape.`,
346
- args: {
347
- scope: tool.schema.string().optional().describe("Directory to map (default: project root)"),
348
- depth: tool.schema.number().optional().describe("Max directory depth (default: 3)"),
349
- },
350
- execute: async (args, context) => {
351
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
352
- const maxDepth = args.depth ?? 3;
353
-
354
- const treeArgs = ["-L", String(maxDepth), "--dirsfirst"];
355
- // Use tree if available, else ls -R
356
- const treeResult = run("tree", [
357
- ...treeArgs,
358
- "-I", ".git|node_modules|dist|build|coverage|.next",
359
- scopeDir,
360
- ]);
361
- if (treeResult.code === 0) {
362
- return treeResult.stdout.slice(0, 10_000);
363
- }
364
-
365
- // Fallback: simple directory listing
366
- const result: string[] = [`## Directory: ${path.relative(context.directory, scopeDir) || "."}\n`];
367
- await listDirRecursive(scopeDir, "", maxDepth, result, context.directory);
368
- return result.join("\n");
369
- },
370
- }),
371
-
372
- // -----------------------------------------------------------------------
373
- // srcwalk_callers: Reverse call graph
374
- // -----------------------------------------------------------------------
375
- "srcwalk_callers": tool({
376
- description: `Reverse call graph — find what calls a function.\nGrep-based: searches for symbol usage across the codebase.\nUse depth for transitive callers (multi-hop).`,
377
- args: {
378
- symbol: tool.schema.string().describe("Function/symbol name"),
379
- scope: tool.schema.string().optional().describe("Search scope"),
380
- depth: tool.schema.number().optional().describe("BFS hop depth (default: 1, max: 3)"),
381
- filter: tool.schema.string().optional().describe("Optional filter (e.g. path:api)"),
382
- },
383
- execute: async (args, context) => {
384
- const symbol = String(args.symbol ?? "").trim();
385
- if (!symbol) return "❌ symbol is required.";
386
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
387
- const depth = Math.min(args.depth ?? 1, 3);
388
-
389
- // Search for direct calls: symbol( or .symbol or symbol.
390
- const grepArgs = [
391
- "-rn",
392
- "--color=never",
393
- "-E",
394
- `[.\\s]${symbol}\\s*\\(|[.]${symbol}\\b|\\b${symbol}\\.`,
395
- scopeDir,
396
- "--include=*.ts",
397
- "--include=*.tsx",
398
- "--include=*.js",
399
- "--include=*.jsx",
400
- ];
401
-
402
- if (args.filter) {
403
- const filterStr = String(args.filter);
404
- if (filterStr.startsWith("path:")) {
405
- grepArgs.push(path.join(scopeDir, filterStr.slice(5)));
406
- }
407
- }
408
-
409
- const result = run("grep", grepArgs);
410
- const lines = result.stdout.split("\n").filter(Boolean).slice(0, 50);
411
-
412
- if (lines.length === 0) return `No callers found for "${symbol}".`;
413
-
414
- const output: string[] = [
415
- `## Callers of \`${symbol}\` (${plural(lines.length, "result")})${depth > 1 ? ` (depth: ${depth})` : ""}\n`,
416
- ];
417
- for (const line of lines) {
418
- const parts = line.split(":");
419
- if (parts.length >= 2) {
420
- const relPath = path.relative(context.directory, parts[0]);
421
- output.push(` ${relPath}:${parts[1]}: ${parts.slice(2).join(":").trim().slice(0, 150)}`);
422
- } else {
423
- output.push(` ${line.slice(0, 200)}`);
424
- }
425
- }
426
-
427
- if (depth > 1) {
428
- output.push(`\n_Note: Multi-hop depth (${depth}) requires re-running on each caller._`);
429
- }
430
- return output.join("\n");
431
- },
432
- }),
433
-
434
- // -----------------------------------------------------------------------
435
- // srcwalk_callees: Forward call graph
436
- // -----------------------------------------------------------------------
437
- "srcwalk_callees": tool({
438
- description: `Forward call graph — what does this function call?\nReads the function body and extracts call sites.`,
439
- args: {
440
- symbol: tool.schema.string().describe("Function/symbol name"),
441
- scope: tool.schema.string().optional().describe("Scope directory"),
442
- detailed: tool.schema.boolean().optional().describe("Show ordered call sites with argument slots"),
443
- },
444
- execute: async (args, context) => {
445
- const symbol = String(args.symbol ?? "").trim();
446
- if (!symbol) return " symbol is required.";
447
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
448
-
449
- // Find the function definition
450
- const defArgs = [
451
- "-rn",
452
- "--color=never",
453
- "-E",
454
- `(export\\s+)?(function|const|let|async\\s+function)\\s+${symbol}\\b|${symbol}\\s*[:=]\\s*(async\\s+)?\\(`,
455
- scopeDir,
456
- "--include=*.ts",
457
- "--include=*.tsx",
458
- "--include=*.js",
459
- "--include=*.jsx",
460
- ];
461
- const defResult = run("grep", defArgs);
462
- const defLines = defResult.stdout.split("\n").filter(Boolean).slice(0, 5);
463
-
464
- if (defLines.length === 0) {
465
- return `Definition not found for "${symbol}". Cannot trace callees without finding the function body.`;
466
- }
467
-
468
- const output: string[] = [`## Callees of \`${symbol}\`\n`];
469
-
470
- // For each definition location, show the function and extract calls
471
- for (const def of defLines) {
472
- const parts = def.split(":");
473
- if (parts.length >= 2) {
474
- const relPath = path.relative(context.directory, parts[0]);
475
- const lineNum = parseInt(parts[1], 10);
476
- output.push(`**Definition:** ${relPath}:${lineNum}`);
477
-
478
- // Read function body to extract calls
479
- const filePath = path.resolve(context.directory, parts[0]);
480
- if (existsSync(filePath)) {
481
- const fileLines = readFileSync(filePath, "utf-8").split("\n");
482
- let braceCount = 0;
483
- let inFunc = false;
484
- const calls: string[] = [];
485
-
486
- for (let i = lineNum - 1; i < Math.min(lineNum + 80, fileLines.length); i++) {
487
- const line = fileLines[i];
488
- if (!inFunc) {
489
- if (line.includes("{")) {
490
- inFunc = true;
491
- braceCount = (line.match(/{/g) || []).length;
492
- braceCount -= (line.match(/}/g) || []).length;
493
- }
494
- continue;
495
- }
496
- braceCount += (line.match(/{/g) || []).length;
497
- braceCount -= (line.match(/}/g) || []).length;
498
-
499
- // Extract function calls
500
- const callMatch = [...line.matchAll(/(?<![.\w])(\w+)\s*\(/g)];
501
- for (const m of callMatch) {
502
- const name = m[1];
503
- if (!["if", "for", "while", "switch", "catch", "typeof", "instanceof", "return", "throw", "new", "delete", "await", "yield"].includes(name)) {
504
- const argStart = line.indexOf("(", m.index!);
505
- const argEnd = line.indexOf(")", argStart);
506
- const args_s = argEnd > argStart ? line.slice(argStart + 1, argEnd).trim().slice(0, 60) : "";
507
- calls.push(` ${args.detailed ? `${name}(${args_s})` : `${name}()`}`);
508
- }
509
- }
510
-
511
- if (braceCount <= 0) break;
512
- }
513
-
514
- if (calls.length > 0) {
515
- output.push(...calls);
516
- } else {
517
- output.push(" (no internal calls found)");
518
- }
519
- output.push("");
520
- }
521
- }
522
- }
523
-
524
- return output.join("\n");
525
- },
526
- }),
527
-
528
- // -----------------------------------------------------------------------
529
- // srcwalk_flow: Compact orientation
530
- // -----------------------------------------------------------------------
531
- "srcwalk_flow": tool({
532
- description: `Compact function orientation — ordered callees + direct callers.\nQuick understanding of a function's role in the call graph.`,
533
- args: {
534
- symbol: tool.schema.string().describe("Symbol name to analyze"),
535
- scope: tool.schema.string().optional().describe("Search scope"),
536
- },
537
- execute: async (args, context) => {
538
- const symbol = String(args.symbol ?? "").trim();
539
- if (!symbol) return "❌ symbol is required.";
540
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
541
-
542
- // Get callers
543
- const callersResult = run("grep", [
544
- "-rn", "--color=never", "-E",
545
- `[.\\s]${symbol}\\s*\\(|[.]${symbol}\\b`,
546
- scopeDir,
547
- "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx",
548
- ]);
549
- const callers = callersResult.stdout.split("\n").filter(Boolean).slice(0, 15);
550
-
551
- // Get callees by reading function body
552
- const defResult = run("grep", [
553
- "-rn", "--color=never", "-E",
554
- `(function|const|let)\\s+${symbol}\\b|${symbol}\\s*[:=]\\s*(async\\s+)?\\(`,
555
- scopeDir,
556
- "--include=*.ts", "--include=*.tsx", "--include=*.js", "--include=*.jsx",
557
- ]);
558
- const defLine = defResult.stdout.split("\n").filter(Boolean)[0];
559
- let callees: string[] = [];
560
-
561
- if (defLine) {
562
- const parts = defLine.split(":");
563
- if (parts.length >= 2) {
564
- const fp = path.resolve(context.directory, parts[0]);
565
- const ln = parseInt(parts[1], 10);
566
- if (existsSync(fp)) {
567
- const fileLines = readFileSync(fp, "utf-8").split("\n");
568
- let bc = 0, inF = false;
569
- for (let i = ln - 1; i < Math.min(ln + 60, fileLines.length); i++) {
570
- const l = fileLines[i];
571
- if (!inF) { if (l.includes("{")) { inF = true; bc = (l.match(/{/g) || []).length - (l.match(/}/g) || []).length; } continue; }
572
- bc += (l.match(/{/g) || []).length - (l.match(/}/g) || []).length;
573
- for (const m of l.matchAll(/(\w+)\s*\(/g)) {
574
- if (!["if","for","while","switch","catch","typeof","instanceof","return","throw","new","delete","await","yield"].includes(m[1]))
575
- callees.push(m[1]);
576
- }
577
- if (bc <= 0) break;
578
- }
579
- }
580
- }
581
- }
582
-
583
- const output: string[] = [`## Flow: \`${symbol}\``];
584
- output.push(`\n**Callers (${plural(callers.length, "file")}):`);
585
- if (callers.length === 0) output.push(" (none)");
586
- else output.push(...callers.slice(0, 10).map(l => ` ${path.relative(context.directory, l.split(":")[0])}:${l.split(":")[1]}`));
587
-
588
- output.push(`\n**Callees (${plural(callees.length, "call")}):`);
589
- if (callees.length === 0) output.push(" (none)");
590
- else output.push(...[...new Set(callees)].slice(0, 20).map(c => ` ${c}()`));
591
- return output.join("\n");
592
- },
593
- }),
594
-
595
- // -----------------------------------------------------------------------
596
- // srcwalk_impact: Heuristic blast-radius
597
- // -----------------------------------------------------------------------
598
- "srcwalk_impact": tool({
599
- description: `Heuristic blast-radius triage — broad 'what might be affected?' starting point.\nName-matched, not proof. Use as starting point before verifying with srcwalk_callers or exact reads.`,
600
- args: {
601
- symbol: tool.schema.string().describe("Symbol name to triage"),
602
- scope: tool.schema.string().optional().describe("Search scope"),
603
- },
604
- execute: async (args, context) => {
605
- const symbol = String(args.symbol ?? "").trim();
606
- if (!symbol) return "❌ symbol is required.";
607
- const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
608
-
609
- // Count usages per directory
610
- const grepResult = run("grep", [
611
- "-rn",
612
- "--color=never",
613
- "-E",
614
- `\\b${symbol}\\b`,
615
- scopeDir,
616
- "--include=*.ts",
617
- "--include=*.tsx",
618
- "--include=*.js",
619
- "--include=*.jsx",
620
- ]);
621
- const lines = grepResult.stdout.split("\n").filter(Boolean);
622
-
623
- // Group by file
624
- const fileCounts: Record<string, number> = {};
625
- for (const line of lines) {
626
- const filePath = line.split(":")[0];
627
- fileCounts[filePath] = (fileCounts[filePath] || 0) + 1;
628
- }
629
-
630
- // Group by directory
631
- const dirCounts: Record<string, { files: number; total: number }> = {};
632
- for (const [filePath, count] of Object.entries(fileCounts)) {
633
- const dir = path.dirname(filePath);
634
- if (!dirCounts[dir]) dirCounts[dir] = { files: 0, total: 0 };
635
- dirCounts[dir].files++;
636
- dirCounts[dir].total += count;
637
- }
638
-
639
- const totalOccurrences = lines.length;
640
- const totalFiles = Object.keys(fileCounts).length;
641
-
642
- const output: string[] = [
643
- `## Impact: \`${symbol}\``,
644
- `Total: ${plural(totalOccurrences, "occurrence")} across ${plural(totalFiles, "file")}\n`,
645
- `### By directory`,
646
- ];
647
-
648
- const sortedDirs = Object.entries(dirCounts).sort((a, b) => b[1].total - a[1].total);
649
- for (const [dir, info] of sortedDirs.slice(0, 15)) {
650
- const relDir = path.relative(context.directory, dir) || ".";
651
- output.push(` ${relDir}/ ${plural(info.total, "occurrence")} in ${plural(info.files, "file")}`);
652
- }
653
-
654
- const topFiles = Object.entries(fileCounts).sort((a, b) => b[1] - a[1]).slice(0, 20);
655
- output.push(`\n### Top files`);
656
- for (const [filePath, count] of topFiles) {
657
- const relPath = path.relative(context.directory, filePath);
658
- output.push(` ${relPath} (${plural(count, "occurrence")})`);
659
- }
660
-
661
- output.push(`\n_Heuristic: name-matched, not proof. Follow up with srcwalk_callers for exact call sites._`);
662
- return output.join("\n");
663
- },
664
- }),
76
+ // -----------------------------------------------------------------------
77
+ // srcwalk_search: Search codebase
78
+ // -----------------------------------------------------------------------
79
+ srcwalk_search: tool({
80
+ description: `Search for symbols, text, or regex patterns in code.\n\nUses ripgrep (rg) when available, falls back to grep.\nSupports symbol search (definitions first), content search, and regex.`,
81
+ args: {
82
+ query: tool.schema.string().describe("Search query or regex pattern"),
83
+ scope: tool.schema.string().optional().describe("Subdirectory scope (default: project root)"),
84
+ kind: tool.schema.string().optional().describe("Search type: text (default), regex, content"),
85
+ context: tool.schema.number().optional().describe("Lines of context before/after each match"),
86
+ limit: tool.schema.number().optional().describe("Max results (default: 30)"),
87
+ },
88
+ execute: async (args, context) => {
89
+ const query = String(args.query ?? "").trim();
90
+ if (!query) return "❌ query is required.";
91
+ const scope = args.scope ? String(args.scope) : context.directory;
92
+ const limit = Math.min(args.limit ?? 30, 100);
93
+ const ctxLines = args.context ?? 0;
94
+ const kind = String(args.kind ?? "text");
95
+
96
+ const searchDir = path.resolve(context.directory, scope);
97
+
98
+ if (hasRg()) {
99
+ const rgArgs = ["--no-heading", "--line-number", "--color", "never"];
100
+ rgArgs.push("--max-count", String(limit * 2));
101
+ if (ctxLines > 0) {
102
+ rgArgs.push("-C", String(ctxLines));
103
+ }
104
+ if (kind === "regex") {
105
+ rgArgs.push("-E", "rust");
106
+ } else {
107
+ rgArgs.push("-F"); // literal
108
+ }
109
+ rgArgs.push(query, searchDir);
110
+
111
+ const result = run(BIN_RG, rgArgs);
112
+ if (result.code !== 0 && result.stderr) {
113
+ // Try plain grep fallback
114
+ } else {
115
+ const lines = result.stdout.split("\n").filter(Boolean).slice(0, limit);
116
+ if (lines.length === 0) return "No matches found.";
117
+ return lines.join("\n");
118
+ }
119
+ }
120
+
121
+ // Fallback to grep
122
+ const grepArgs = ["-rn", "--color=never"];
123
+ if (ctxLines > 0) grepArgs.push("-C", String(ctxLines));
124
+ if (kind === "regex") {
125
+ grepArgs.push("-E");
126
+ } else {
127
+ grepArgs.push("-F");
128
+ }
129
+ grepArgs.push(query, searchDir);
130
+
131
+ const result = run("grep", grepArgs);
132
+ const lines = result.stdout.split("\n").filter(Boolean).slice(0, limit);
133
+ if (lines.length === 0) return "No matches found.";
134
+ return lines.join("\n");
135
+ },
136
+ }),
137
+
138
+ // -----------------------------------------------------------------------
139
+ // srcwalk_read: Read files
140
+ // -----------------------------------------------------------------------
141
+ srcwalk_read: tool({
142
+ description: `Read a file with optional section (line range, symbol, or path:line format).\n\nSmall files return full content; large files support outlining.\nUse path:start-end for range reads (e.g. "src/app.ts:44-89").`,
143
+ args: {
144
+ path: tool.schema
145
+ .string()
146
+ .describe("File path to read (supports path:line or path:start-end)"),
147
+ section: tool.schema
148
+ .string()
149
+ .optional()
150
+ .describe("Line range '44-89' or heading/symbol name"),
151
+ full: tool.schema.boolean().optional().describe("Force full content"),
152
+ },
153
+ execute: async (args, context) => {
154
+ const fileArg = String(args.path);
155
+ const fullFilePath = path.resolve(context.directory, fileArg);
156
+
157
+ // Parse path:line or path:start-end shortcut
158
+ let startLine: number | undefined;
159
+ let endLine: number | undefined;
160
+
161
+ const rangeMatch = fileArg.match(/^(.+?):(\d+)(?:-(\d+))?$/);
162
+ if (rangeMatch) {
163
+ const relPath = rangeMatch[1];
164
+ const resolved = path.resolve(context.directory, relPath);
165
+ startLine = parseInt(rangeMatch[2], 10);
166
+ endLine = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : startLine;
167
+ return readFileRange(resolved, startLine, endLine, relPath);
168
+ }
169
+
170
+ if (!existsSync(fullFilePath)) return `File not found: ${fileArg}`;
171
+ const stats = statSync(fullFilePath);
172
+
173
+ // If section specified, try grep for symbol/heading
174
+ if (args.section) {
175
+ const section = String(args.section);
176
+ // Check if it's a line range
177
+ const lineMatch = section.match(/^(\d+)(?:-(\d+))?$/);
178
+ if (lineMatch) {
179
+ const start = parseInt(lineMatch[1], 10);
180
+ const end = lineMatch[2] ? parseInt(lineMatch[2], 10) : start + 30;
181
+ return readFileRange(fullFilePath, start, end);
182
+ }
183
+ // Symbol/heading: use grep to find location, then read around it
184
+ const grepArgs = [
185
+ "-n",
186
+ "--color=never",
187
+ "-E",
188
+ `^(function |const |let |class |interface |type |export |## |### )?.*${section}`,
189
+ fullFilePath,
190
+ ];
191
+ const result = run("grep", grepArgs);
192
+ if (result.stdout) {
193
+ const firstMatch = result.stdout.split("\n")[0];
194
+ const lineNum = parseInt(firstMatch.split(":")[0], 10);
195
+ if (!isNaN(lineNum)) {
196
+ return readFileRange(fullFilePath, Math.max(1, lineNum - 3), lineNum + 40);
197
+ }
198
+ }
199
+ return `Section "${section}" not found in ${fileArg}`;
200
+ }
201
+
202
+ // Full content for small files
203
+ if (stats.size < 50 * 1024 || args.full) {
204
+ const content = readFileSync(fullFilePath, "utf-8");
205
+ const lines = content.split("\n");
206
+ if (lines.length > 2000)
207
+ return `[File too large: ${plural(lines.length, "line")}. Showing first 2000 lines]\n\n${lines.slice(0, 2000).join("\n")}`;
208
+ return content;
209
+ }
210
+
211
+ // Large file: outline
212
+ const content = readFileSync(fullFilePath, "utf-8");
213
+ const lines = content.split("\n");
214
+ const headings: string[] = [];
215
+ for (let i = 0; i < Math.min(lines.length, 5000); i++) {
216
+ const l = lines[i].trim();
217
+ if (
218
+ l.match(
219
+ /^(export\s+)?(function|class|interface|type|const|enum|def|struct|impl|pub\s+fn)\s/,
220
+ ) ||
221
+ l.match(/^(##|###)\s/) ||
222
+ l.match(/^\w+\s*[:=]/)
223
+ ) {
224
+ headings.push(` ${i + 1}: ${l.slice(0, 120)}`);
225
+ }
226
+ }
227
+ const header = `[File: ${fileArg} — ${plural(lines.length, "line")}, ${(stats.size / 1024).toFixed(1)}KB]\n\n`;
228
+ const outline =
229
+ headings.length > 0
230
+ ? `Outline (${plural(headings.length, "entry")}):\n${headings.slice(0, 50).join("\n")}\n\nUse path:line or section to read a specific range.`
231
+ : `Use path:line to read a specific range (e.g., ${fileArg}:1-${Math.min(50, lines.length)}).`;
232
+ return header + outline;
233
+ },
234
+ }),
235
+
236
+ // -----------------------------------------------------------------------
237
+ // srcwalk_files: Find files
238
+ // -----------------------------------------------------------------------
239
+ srcwalk_files: tool({
240
+ description: `Find files by glob pattern. Returns matched file paths with size estimates, grouped by directory. Respects .gitignore.`,
241
+ args: {
242
+ pattern: tool.schema.string().describe("Glob pattern (e.g. '*.ts', 'src/**/*.ts')"),
243
+ scope: tool.schema
244
+ .string()
245
+ .optional()
246
+ .describe("Directory to search (default: project root)"),
247
+ },
248
+ execute: async (args, context) => {
249
+ const pattern = String(args.pattern ?? "").trim();
250
+ if (!pattern) return " pattern is required.";
251
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
252
+
253
+ // Use find with simple glob pattern
254
+ const findArgs: string[] = [scopeDir, "-type", "f"];
255
+
256
+ // Convert simple glob to -name pattern
257
+ if (pattern.includes("**")) {
258
+ // find handles ** naturally with -path
259
+ findArgs.push("-path", `*/${pattern.replace(/\*\*/g, "*")}`);
260
+ } else if (pattern.includes("*")) {
261
+ findArgs.push("-name", pattern);
262
+ } else if (pattern.includes(".")) {
263
+ findArgs.push("-name", pattern);
264
+ } else {
265
+ findArgs.push("-name", `*${pattern}*`);
266
+ }
267
+
268
+ // Exclude common dirs
269
+ for (const dir of [
270
+ ".git",
271
+ "node_modules",
272
+ "dist",
273
+ "build",
274
+ "coverage",
275
+ ".next",
276
+ ".opencode",
277
+ ]) {
278
+ findArgs.push("-not", "-path", `*/${dir}/*`);
279
+ }
280
+
281
+ const result = run("find", findArgs);
282
+ const files = result.stdout.split("\n").filter(Boolean).slice(0, 200);
283
+
284
+ if (files.length === 0) return `No files matching "${pattern}" found.`;
285
+
286
+ // Group by directory
287
+ const groups: Record<string, string[]> = {};
288
+ for (const f of files) {
289
+ const dir = path.dirname(f);
290
+ if (!groups[dir]) groups[dir] = [];
291
+ groups[dir].push(path.basename(f));
292
+ }
293
+
294
+ const lines: string[] = [`# Files matching "${pattern}" (${plural(files.length, "file")})\n`];
295
+ for (const [dir, files] of Object.entries(groups).sort()) {
296
+ const relDir = path.relative(context.directory, dir) || ".";
297
+ lines.push(`${relDir}/ (${plural(files.length, "file")})`);
298
+ for (const f of files) {
299
+ const fp = path.join(dir, f);
300
+ try {
301
+ const size = statSync(fp).size;
302
+ const tokenEst = Math.ceil(size / 4);
303
+ lines.push(` ${f} (~${tokenEst.toLocaleString()} tokens)`);
304
+ } catch {
305
+ lines.push(` ${f}`);
306
+ }
307
+ }
308
+ lines.push("");
309
+ }
310
+ return lines.join("\n");
311
+ },
312
+ }),
313
+
314
+ // -----------------------------------------------------------------------
315
+ // srcwalk_deps: Import analysis
316
+ // -----------------------------------------------------------------------
317
+ srcwalk_deps: tool({
318
+ description: `Show what imports a file (dependents) and what a file imports (dependencies).\nBlast-radius check before breaking changes.`,
319
+ args: {
320
+ path: tool.schema.string().describe("File path to analyze"),
321
+ scope: tool.schema.string().optional().describe("Search scope (default: project root)"),
322
+ },
323
+ execute: async (args, context) => {
324
+ const filePath = String(args.path);
325
+ const absPath = path.resolve(context.directory, filePath);
326
+ if (!existsSync(absPath)) return `File not found: ${filePath}`;
327
+
328
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
329
+ const fileName = path.basename(filePath, path.extname(filePath));
330
+
331
+ // What this file imports
332
+ const content = readFileSync(absPath, "utf-8");
333
+ const importLines: string[] = [];
334
+ for (const line of content.split("\n")) {
335
+ const m = line.match(
336
+ /(?:import|require)\s+.*?from\s+['"]([^'"]+)['"]|import\s+['"]([^'"]+)['"]/,
337
+ );
338
+ if (m) importLines.push(` ${line.trim()}`);
339
+ }
340
+
341
+ // What imports this file (grep for the module name)
342
+ const searchName = fileName;
343
+ const grepResult = run("grep", [
344
+ "-rn",
345
+ "--color=never",
346
+ "-E",
347
+ `from ['"](\\./|\\.\\./|.*/)${searchName}['"]|require\\(['"](\\./|\\.\\./|.*/)${searchName}['"]`,
348
+ scopeDir,
349
+ "--include=*.ts",
350
+ "--include=*.tsx",
351
+ "--include=*.js",
352
+ "--include=*.jsx",
353
+ "--include=*.mjs",
354
+ ]);
355
+ const importers = grepResult.stdout.split("\n").filter(Boolean).slice(0, 30);
356
+
357
+ const result: string[] = [`## Dependencies for ${filePath}\n`];
358
+ result.push(`### Imports (${plural(importLines.length, "module")})`);
359
+ if (importLines.length === 0) result.push(" (none)");
360
+ else result.push(...importLines);
361
+
362
+ result.push(`\n### Importers (${plural(importers.length, "file")})`);
363
+ if (importers.length === 0) result.push(" (no files import this module)");
364
+ else
365
+ result.push(
366
+ ...importers.map(
367
+ (l) => ` ${path.relative(context.directory, l.split(":")[0])}:${l.split(":")[1]}`,
368
+ ),
369
+ );
370
+
371
+ return result.join("\n");
372
+ },
373
+ }),
374
+
375
+ // -----------------------------------------------------------------------
376
+ // srcwalk_map: Directory overview
377
+ // -----------------------------------------------------------------------
378
+ srcwalk_map: tool({
379
+ description: `Token-annotated directory skeleton. Shows repo structure with file sizes and token estimates. Good for understanding codebase shape.`,
380
+ args: {
381
+ scope: tool.schema.string().optional().describe("Directory to map (default: project root)"),
382
+ depth: tool.schema.number().optional().describe("Max directory depth (default: 3)"),
383
+ },
384
+ execute: async (args, context) => {
385
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
386
+ const maxDepth = args.depth ?? 3;
387
+
388
+ const treeArgs = ["-L", String(maxDepth), "--dirsfirst"];
389
+ // Use tree if available, else ls -R
390
+ const treeResult = run("tree", [
391
+ ...treeArgs,
392
+ "-I",
393
+ ".git|node_modules|dist|build|coverage|.next",
394
+ scopeDir,
395
+ ]);
396
+ if (treeResult.code === 0) {
397
+ return treeResult.stdout.slice(0, 10_000);
398
+ }
399
+
400
+ // Fallback: simple directory listing
401
+ const result: string[] = [
402
+ `## Directory: ${path.relative(context.directory, scopeDir) || "."}\n`,
403
+ ];
404
+ await listDirRecursive(scopeDir, "", maxDepth, result);
405
+ return result.join("\n");
406
+ },
407
+ }),
408
+
409
+ // -----------------------------------------------------------------------
410
+ // srcwalk_callers: Reverse call graph
411
+ // -----------------------------------------------------------------------
412
+ srcwalk_callers: tool({
413
+ description: `Reverse call graph — find what calls a function.\nGrep-based: searches for symbol usage across the codebase.\nUse depth for transitive callers (multi-hop).`,
414
+ args: {
415
+ symbol: tool.schema.string().describe("Function/symbol name"),
416
+ scope: tool.schema.string().optional().describe("Search scope"),
417
+ depth: tool.schema.number().optional().describe("BFS hop depth (default: 1, max: 3)"),
418
+ filter: tool.schema.string().optional().describe("Optional filter (e.g. path:api)"),
419
+ },
420
+ execute: async (args, context) => {
421
+ const symbol = String(args.symbol ?? "").trim();
422
+ if (!symbol) return "❌ symbol is required.";
423
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
424
+ const depth = Math.min(args.depth ?? 1, 3);
425
+
426
+ // Search for direct calls: symbol( or .symbol or symbol.
427
+ const grepArgs = [
428
+ "-rn",
429
+ "--color=never",
430
+ "-E",
431
+ `[.\\s]${symbol}\\s*\\(|[.]${symbol}\\b|\\b${symbol}\\.`,
432
+ scopeDir,
433
+ "--include=*.ts",
434
+ "--include=*.tsx",
435
+ "--include=*.js",
436
+ "--include=*.jsx",
437
+ ];
438
+
439
+ if (args.filter) {
440
+ const filterStr = String(args.filter);
441
+ if (filterStr.startsWith("path:")) {
442
+ grepArgs.push(path.join(scopeDir, filterStr.slice(5)));
443
+ }
444
+ }
445
+
446
+ const result = run("grep", grepArgs);
447
+ const lines = result.stdout.split("\n").filter(Boolean).slice(0, 50);
448
+
449
+ if (lines.length === 0) return `No callers found for "${symbol}".`;
450
+
451
+ const output: string[] = [
452
+ `## Callers of \`${symbol}\` (${plural(lines.length, "result")})${depth > 1 ? ` (depth: ${depth})` : ""}\n`,
453
+ ];
454
+ for (const line of lines) {
455
+ const parts = line.split(":");
456
+ if (parts.length >= 2) {
457
+ const relPath = path.relative(context.directory, parts[0]);
458
+ output.push(` ${relPath}:${parts[1]}: ${parts.slice(2).join(":").trim().slice(0, 150)}`);
459
+ } else {
460
+ output.push(` ${line.slice(0, 200)}`);
461
+ }
462
+ }
463
+
464
+ if (depth > 1) {
465
+ output.push(`\n_Note: Multi-hop depth (${depth}) requires re-running on each caller._`);
466
+ }
467
+ return output.join("\n");
468
+ },
469
+ }),
470
+
471
+ // -----------------------------------------------------------------------
472
+ // srcwalk_callees: Forward call graph
473
+ // -----------------------------------------------------------------------
474
+ srcwalk_callees: tool({
475
+ description: `Forward call graph — what does this function call?\nReads the function body and extracts call sites.`,
476
+ args: {
477
+ symbol: tool.schema.string().describe("Function/symbol name"),
478
+ scope: tool.schema.string().optional().describe("Scope directory"),
479
+ detailed: tool.schema
480
+ .boolean()
481
+ .optional()
482
+ .describe("Show ordered call sites with argument slots"),
483
+ },
484
+ execute: async (args, context) => {
485
+ const symbol = String(args.symbol ?? "").trim();
486
+ if (!symbol) return "❌ symbol is required.";
487
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
488
+
489
+ // Find the function definition
490
+ const defArgs = [
491
+ "-rn",
492
+ "--color=never",
493
+ "-E",
494
+ `(export\\s+)?(function|const|let|async\\s+function)\\s+${symbol}\\b|${symbol}\\s*[:=]\\s*(async\\s+)?\\(`,
495
+ scopeDir,
496
+ "--include=*.ts",
497
+ "--include=*.tsx",
498
+ "--include=*.js",
499
+ "--include=*.jsx",
500
+ ];
501
+ const defResult = run("grep", defArgs);
502
+ const defLines = defResult.stdout.split("\n").filter(Boolean).slice(0, 5);
503
+
504
+ if (defLines.length === 0) {
505
+ return `Definition not found for "${symbol}". Cannot trace callees without finding the function body.`;
506
+ }
507
+
508
+ const output: string[] = [`## Callees of \`${symbol}\`\n`];
509
+
510
+ // For each definition location, show the function and extract calls
511
+ for (const def of defLines) {
512
+ const parts = def.split(":");
513
+ if (parts.length >= 2) {
514
+ const relPath = path.relative(context.directory, parts[0]);
515
+ const lineNum = parseInt(parts[1], 10);
516
+ output.push(`**Definition:** ${relPath}:${lineNum}`);
517
+
518
+ // Read function body to extract calls
519
+ const filePath = path.resolve(context.directory, parts[0]);
520
+ if (existsSync(filePath)) {
521
+ const fileLines = readFileSync(filePath, "utf-8").split("\n");
522
+ let braceCount = 0;
523
+ let inFunc = false;
524
+ const calls: string[] = [];
525
+
526
+ for (let i = lineNum - 1; i < Math.min(lineNum + 80, fileLines.length); i++) {
527
+ const line = fileLines[i];
528
+ if (!inFunc) {
529
+ if (line.includes("{")) {
530
+ inFunc = true;
531
+ braceCount = (line.match(/{/g) || []).length;
532
+ braceCount -= (line.match(/}/g) || []).length;
533
+ }
534
+ continue;
535
+ }
536
+ braceCount += (line.match(/{/g) || []).length;
537
+ braceCount -= (line.match(/}/g) || []).length;
538
+
539
+ // Extract function calls
540
+ const callMatch = [...line.matchAll(/(?<![.\w])(\w+)\s*\(/g)];
541
+ for (const m of callMatch) {
542
+ const name = m[1];
543
+ if (
544
+ ![
545
+ "if",
546
+ "for",
547
+ "while",
548
+ "switch",
549
+ "catch",
550
+ "typeof",
551
+ "instanceof",
552
+ "return",
553
+ "throw",
554
+ "new",
555
+ "delete",
556
+ "await",
557
+ "yield",
558
+ ].includes(name)
559
+ ) {
560
+ const argStart = line.indexOf("(", m.index!);
561
+ const argEnd = line.indexOf(")", argStart);
562
+ const args_s =
563
+ argEnd > argStart
564
+ ? line
565
+ .slice(argStart + 1, argEnd)
566
+ .trim()
567
+ .slice(0, 60)
568
+ : "";
569
+ calls.push(` ${args.detailed ? `${name}(${args_s})` : `${name}()`}`);
570
+ }
571
+ }
572
+
573
+ if (braceCount <= 0) break;
574
+ }
575
+
576
+ if (calls.length > 0) {
577
+ output.push(...calls);
578
+ } else {
579
+ output.push(" (no internal calls found)");
580
+ }
581
+ output.push("");
582
+ }
583
+ }
584
+ }
585
+
586
+ return output.join("\n");
587
+ },
588
+ }),
589
+
590
+ // -----------------------------------------------------------------------
591
+ // srcwalk_flow: Compact orientation
592
+ // -----------------------------------------------------------------------
593
+ srcwalk_flow: tool({
594
+ description: `Compact function orientation — ordered callees + direct callers.\nQuick understanding of a function's role in the call graph.`,
595
+ args: {
596
+ symbol: tool.schema.string().describe("Symbol name to analyze"),
597
+ scope: tool.schema.string().optional().describe("Search scope"),
598
+ },
599
+ execute: async (args, context) => {
600
+ const symbol = String(args.symbol ?? "").trim();
601
+ if (!symbol) return " symbol is required.";
602
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
603
+
604
+ // Get callers
605
+ const callersResult = run("grep", [
606
+ "-rn",
607
+ "--color=never",
608
+ "-E",
609
+ `[.\\s]${symbol}\\s*\\(|[.]${symbol}\\b`,
610
+ scopeDir,
611
+ "--include=*.ts",
612
+ "--include=*.tsx",
613
+ "--include=*.js",
614
+ "--include=*.jsx",
615
+ ]);
616
+ const callers = callersResult.stdout.split("\n").filter(Boolean).slice(0, 15);
617
+
618
+ // Get callees by reading function body
619
+ const defResult = run("grep", [
620
+ "-rn",
621
+ "--color=never",
622
+ "-E",
623
+ `(function|const|let)\\s+${symbol}\\b|${symbol}\\s*[:=]\\s*(async\\s+)?\\(`,
624
+ scopeDir,
625
+ "--include=*.ts",
626
+ "--include=*.tsx",
627
+ "--include=*.js",
628
+ "--include=*.jsx",
629
+ ]);
630
+ const defLine = defResult.stdout.split("\n").filter(Boolean)[0];
631
+ let callees: string[] = [];
632
+
633
+ if (defLine) {
634
+ const parts = defLine.split(":");
635
+ if (parts.length >= 2) {
636
+ const fp = path.resolve(context.directory, parts[0]);
637
+ const ln = parseInt(parts[1], 10);
638
+ if (existsSync(fp)) {
639
+ const fileLines = readFileSync(fp, "utf-8").split("\n");
640
+ let bc = 0,
641
+ inF = false;
642
+ for (let i = ln - 1; i < Math.min(ln + 60, fileLines.length); i++) {
643
+ const l = fileLines[i];
644
+ if (!inF) {
645
+ if (l.includes("{")) {
646
+ inF = true;
647
+ bc = (l.match(/{/g) || []).length - (l.match(/}/g) || []).length;
648
+ }
649
+ continue;
650
+ }
651
+ bc += (l.match(/{/g) || []).length - (l.match(/}/g) || []).length;
652
+ for (const m of l.matchAll(/(\w+)\s*\(/g)) {
653
+ if (
654
+ ![
655
+ "if",
656
+ "for",
657
+ "while",
658
+ "switch",
659
+ "catch",
660
+ "typeof",
661
+ "instanceof",
662
+ "return",
663
+ "throw",
664
+ "new",
665
+ "delete",
666
+ "await",
667
+ "yield",
668
+ ].includes(m[1])
669
+ )
670
+ callees.push(m[1]);
671
+ }
672
+ if (bc <= 0) break;
673
+ }
674
+ }
675
+ }
676
+ }
677
+
678
+ const output: string[] = [`## Flow: \`${symbol}\``];
679
+ output.push(`\n**Callers (${plural(callers.length, "file")}):`);
680
+ if (callers.length === 0) output.push(" (none)");
681
+ else
682
+ output.push(
683
+ ...callers
684
+ .slice(0, 10)
685
+ .map(
686
+ (l) => ` ${path.relative(context.directory, l.split(":")[0])}:${l.split(":")[1]}`,
687
+ ),
688
+ );
689
+
690
+ output.push(`\n**Callees (${plural(callees.length, "call")}):`);
691
+ if (callees.length === 0) output.push(" (none)");
692
+ else output.push(...[...new Set(callees)].slice(0, 20).map((c) => ` ${c}()`));
693
+ return output.join("\n");
694
+ },
695
+ }),
696
+
697
+ // -----------------------------------------------------------------------
698
+ // srcwalk_impact: Heuristic blast-radius
699
+ // -----------------------------------------------------------------------
700
+ srcwalk_impact: tool({
701
+ description: `Heuristic blast-radius triage — broad 'what might be affected?' starting point.\nName-matched, not proof. Use as starting point before verifying with srcwalk_callers or exact reads.`,
702
+ args: {
703
+ symbol: tool.schema.string().describe("Symbol name to triage"),
704
+ scope: tool.schema.string().optional().describe("Search scope"),
705
+ },
706
+ execute: async (args, context) => {
707
+ const symbol = String(args.symbol ?? "").trim();
708
+ if (!symbol) return "❌ symbol is required.";
709
+ const scopeDir = path.resolve(context.directory, args.scope ? String(args.scope) : "");
710
+
711
+ // Count usages per directory
712
+ const grepResult = run("grep", [
713
+ "-rn",
714
+ "--color=never",
715
+ "-E",
716
+ `\\b${symbol}\\b`,
717
+ scopeDir,
718
+ "--include=*.ts",
719
+ "--include=*.tsx",
720
+ "--include=*.js",
721
+ "--include=*.jsx",
722
+ ]);
723
+ const lines = grepResult.stdout.split("\n").filter(Boolean);
724
+
725
+ // Group by file
726
+ const fileCounts: Record<string, number> = {};
727
+ for (const line of lines) {
728
+ const filePath = line.split(":")[0];
729
+ fileCounts[filePath] = (fileCounts[filePath] || 0) + 1;
730
+ }
731
+
732
+ // Group by directory
733
+ const dirCounts: Record<string, { files: number; total: number }> = {};
734
+ for (const [filePath, count] of Object.entries(fileCounts)) {
735
+ const dir = path.dirname(filePath);
736
+ if (!dirCounts[dir]) dirCounts[dir] = { files: 0, total: 0 };
737
+ dirCounts[dir].files++;
738
+ dirCounts[dir].total += count;
739
+ }
740
+
741
+ const totalOccurrences = lines.length;
742
+ const totalFiles = Object.keys(fileCounts).length;
743
+
744
+ const output: string[] = [
745
+ `## Impact: \`${symbol}\``,
746
+ `Total: ${plural(totalOccurrences, "occurrence")} across ${plural(totalFiles, "file")}\n`,
747
+ `### By directory`,
748
+ ];
749
+
750
+ const sortedDirs = Object.entries(dirCounts).sort((a, b) => b[1].total - a[1].total);
751
+ for (const [dir, info] of sortedDirs.slice(0, 15)) {
752
+ const relDir = path.relative(context.directory, dir) || ".";
753
+ output.push(
754
+ ` ${relDir}/ — ${plural(info.total, "occurrence")} in ${plural(info.files, "file")}`,
755
+ );
756
+ }
757
+
758
+ const topFiles = Object.entries(fileCounts)
759
+ .sort((a, b) => b[1] - a[1])
760
+ .slice(0, 20);
761
+ output.push(`\n### Top files`);
762
+ for (const [filePath, count] of topFiles) {
763
+ const relPath = path.relative(context.directory, filePath);
764
+ output.push(` ${relPath} (${plural(count, "occurrence")})`);
765
+ }
766
+
767
+ output.push(
768
+ `\n_Heuristic: name-matched, not proof. Follow up with srcwalk_callers for exact call sites._`,
769
+ );
770
+ return output.join("\n");
771
+ },
772
+ }),
665
773
  };
666
774
 
667
775
  // ---------------------------------------------------------------------------
@@ -669,53 +777,59 @@ const srcwalkTools = {
669
777
  // ---------------------------------------------------------------------------
670
778
 
671
779
  function readFileRange(filePath: string, start: number, end: number, displayPath?: string): string {
672
- try {
673
- const content = readFileSync(filePath, "utf-8");
674
- const lines = content.split("\n");
675
- const from = Math.max(0, start - 1);
676
- const to = Math.min(lines.length, end);
677
- const result: string[] = [`File: ${displayPath ?? filePath} (lines ${start}-${end}):\n`];
678
- for (let i = from; i < to; i++) {
679
- result.push(`${i + 1}: ${lines[i]}`);
680
- }
681
- return result.join("\n");
682
- } catch (err) {
683
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
684
- }
780
+ try {
781
+ const content = readFileSync(filePath, "utf-8");
782
+ const lines = content.split("\n");
783
+ const from = Math.max(0, start - 1);
784
+ const to = Math.min(lines.length, end);
785
+ const result: string[] = [`File: ${displayPath ?? filePath} (lines ${start}-${end}):\n`];
786
+ for (let i = from; i < to; i++) {
787
+ result.push(`${i + 1}: ${lines[i]}`);
788
+ }
789
+ return result.join("\n");
790
+ } catch (err) {
791
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
792
+ }
685
793
  }
686
794
 
687
- async function listDirRecursive(dir: string, prefix: string, maxDepth: number, output: string[], rootDir: string): Promise<void> {
688
- if (maxDepth <= 0) return;
689
- try {
690
- const entries = await readdir(dir, { withFileTypes: true });
691
- const skipDirs = new Set([".git", "node_modules", "dist", "build", "coverage", ".next"]);
692
- const dirs = entries.filter(e => e.isDirectory() && !skipDirs.has(e.name)).sort((a, b) => a.name.localeCompare(b.name));
693
- const files = entries.filter(e => e.isFile()).sort((a, b) => a.name.localeCompare(b.name));
694
-
695
- for (const d of dirs) {
696
- const relPath = path.relative(rootDir, path.join(dir, d.name));
697
- output.push(`${prefix}${d.name}/`);
698
- await listDirRecursive(path.join(dir, d.name), prefix + " ", maxDepth - 1, output, rootDir);
699
- }
700
- for (const f of files) {
701
- const fp = path.join(dir, f.name);
702
- try {
703
- const size = statSync(fp).size;
704
- const tokenEst = Math.ceil(size / 4);
705
- output.push(`${prefix}${f.name} (~${tokenEst.toLocaleString()} tokens)`);
706
- } catch {
707
- output.push(`${prefix}${f.name}`);
708
- }
709
- }
710
- } catch {
711
- // Permission denied, skip
712
- }
795
+ async function listDirRecursive(
796
+ dir: string,
797
+ prefix: string,
798
+ maxDepth: number,
799
+ output: string[],
800
+ ): Promise<void> {
801
+ if (maxDepth <= 0) return;
802
+ try {
803
+ const entries = await readdir(dir, { withFileTypes: true });
804
+ const skipDirs = new Set([".git", "node_modules", "dist", "build", "coverage", ".next"]);
805
+ const dirs = entries
806
+ .filter((e) => e.isDirectory() && !skipDirs.has(e.name))
807
+ .sort((a, b) => a.name.localeCompare(b.name));
808
+ const files = entries.filter((e) => e.isFile()).sort((a, b) => a.name.localeCompare(b.name));
809
+
810
+ for (const d of dirs) {
811
+ output.push(`${prefix}${d.name}/`);
812
+ await listDirRecursive(path.join(dir, d.name), prefix + " ", maxDepth - 1, output);
813
+ }
814
+ for (const f of files) {
815
+ const fp = path.join(dir, f.name);
816
+ try {
817
+ const size = statSync(fp).size;
818
+ const tokenEst = Math.ceil(size / 4);
819
+ output.push(`${prefix}${f.name} (~${tokenEst.toLocaleString()} tokens)`);
820
+ } catch {
821
+ output.push(`${prefix}${f.name}`);
822
+ }
823
+ }
824
+ } catch {
825
+ // Permission denied, skip
826
+ }
713
827
  }
714
828
 
715
829
  export const SrcwalkPlugin: Plugin = async () => {
716
- return {
717
- tool: srcwalkTools,
718
- };
830
+ return {
831
+ tool: srcwalkTools,
832
+ };
719
833
  };
720
834
 
721
835
  export default SrcwalkPlugin;