techunter 0.1.10 → 0.1.11

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 (3) hide show
  1. package/dist/index.js +216 -190
  2. package/dist/mcp.js +185 -185
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -882,7 +882,7 @@ AI model set to: ${val.trim()}
882
882
  init_github();
883
883
 
884
884
  // src/lib/agent.ts
885
- import ora15 from "ora";
885
+ import ora14 from "ora";
886
886
  import chalk13 from "chalk";
887
887
 
888
888
  // src/tools/pick/index.ts
@@ -1148,7 +1148,7 @@ ${issue.body ?? "(none)"}
1148
1148
 
1149
1149
  Diff:
1150
1150
  ${diff || "(no changes)"}`,
1151
- ["run_command", "read_file", "get_diff"]
1151
+ ["run_command", "grep_code", "get_diff"]
1152
1152
  );
1153
1153
  }
1154
1154
 
@@ -1662,9 +1662,9 @@ Previous guide:
1662
1662
  ${revise.previousGuide}` : `Write an implementation guide for this task: "${title}"`;
1663
1663
  return runSubAgentLoop(
1664
1664
  config,
1665
- "You are a senior engineer writing a brief task guide for a developer. Use scan_project and read_file to identify which files are relevant. Do NOT include code snippets or implementation details. When you have enough context, write the guide.\n\n" + GUIDE_FORMAT,
1665
+ "You are a senior engineer writing a brief task guide for a developer. Use list_files then grep_code to identify which files are relevant. Do NOT include code snippets or implementation details. When you have enough context, write the guide.\n\n" + GUIDE_FORMAT,
1666
1666
  userMessage,
1667
- ["scan_project", "read_file", "run_command", "ask_user"]
1667
+ ["list_files", "grep_code", "run_command", "ask_user"]
1668
1668
  );
1669
1669
  }
1670
1670
 
@@ -2034,10 +2034,10 @@ Clear instruction on what to fix and how to re-submit (via /submit).
2034
2034
  async function generateRejectionComment(config, issueNumber, userFeedback) {
2035
2035
  return runSubAgentLoop(
2036
2036
  config,
2037
- "You are a senior engineer writing a structured code review rejection comment. Use get_task to read the acceptance criteria, get_diff or read_file to inspect the implementation, and get_comments to see prior discussion. Then write the rejection comment.\n\n" + REJECTION_FORMAT,
2037
+ "You are a senior engineer writing a structured code review rejection comment. Use get_task to read the acceptance criteria, get_diff or grep_code to inspect the implementation, and get_comments to see prior discussion. Then write the rejection comment.\n\n" + REJECTION_FORMAT,
2038
2038
  `Write a rejection comment for issue #${issueNumber}.
2039
2039
  Reviewer feedback: ${userFeedback}`,
2040
- ["get_task", "get_comments", "get_diff", "read_file"]
2040
+ ["get_task", "get_comments", "get_diff", "grep_code"]
2041
2041
  );
2042
2042
  }
2043
2043
 
@@ -2580,20 +2580,34 @@ ${out || e.message}`;
2580
2580
  }
2581
2581
  }
2582
2582
 
2583
- // src/tools/scan-project/index.ts
2584
- var scan_project_exports = {};
2585
- __export(scan_project_exports, {
2583
+ // src/tools/list-files/index.ts
2584
+ var list_files_exports = {};
2585
+ __export(list_files_exports, {
2586
2586
  definition: () => definition17,
2587
2587
  execute: () => execute17
2588
2588
  });
2589
- import ora14 from "ora";
2590
-
2591
- // src/lib/project.ts
2592
2589
  import { readFile as readFile2 } from "fs/promises";
2593
2590
  import { existsSync } from "fs";
2594
2591
  import path2 from "path";
2595
2592
  import { globby } from "globby";
2596
2593
  import ignore from "ignore";
2594
+ var definition17 = {
2595
+ type: "function",
2596
+ function: {
2597
+ name: "list_files",
2598
+ description: "List file paths in the project. Use this first to orient yourself before searching or reading.",
2599
+ parameters: {
2600
+ type: "object",
2601
+ properties: {
2602
+ glob: {
2603
+ type: "string",
2604
+ description: 'Glob pattern to filter results, e.g. "src/**/*.ts". Defaults to all text files.'
2605
+ }
2606
+ },
2607
+ required: []
2608
+ }
2609
+ }
2610
+ };
2597
2611
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2598
2612
  ".png",
2599
2613
  ".jpg",
@@ -2606,213 +2620,199 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2606
2620
  ".zip",
2607
2621
  ".tar",
2608
2622
  ".gz",
2609
- ".bz2",
2610
- ".rar",
2611
2623
  ".exe",
2612
2624
  ".dll",
2613
- ".so",
2614
- ".dylib",
2615
2625
  ".woff",
2616
2626
  ".woff2",
2617
2627
  ".ttf",
2618
- ".otf",
2619
- ".eot",
2620
2628
  ".mp3",
2621
2629
  ".mp4",
2622
- ".wav",
2623
- ".avi",
2624
- ".mov",
2625
2630
  ".db",
2626
2631
  ".sqlite",
2627
2632
  ".lock"
2628
2633
  ]);
2629
- var ALWAYS_READ = [
2630
- "README.md",
2631
- "README.txt",
2632
- "README",
2633
- "package.json",
2634
- "pyproject.toml",
2635
- "go.mod",
2636
- "Cargo.toml",
2637
- "tsconfig.json",
2638
- "vite.config.ts",
2639
- "vite.config.js",
2640
- "webpack.config.js",
2641
- "rollup.config.js",
2642
- ".env.example",
2643
- "docker-compose.yml",
2644
- "Dockerfile"
2645
- ];
2646
- var MAX_TOTAL_BYTES = 8e4;
2647
- var MAX_FILE_BYTES = 15e3;
2648
- async function buildIgnoreFilter(cwd) {
2634
+ async function execute17(input3, _config) {
2635
+ const glob = input3["glob"] ?? "**/*";
2636
+ const cwd = process.cwd();
2649
2637
  const ig = ignore();
2650
2638
  const gitignorePath = path2.join(cwd, ".gitignore");
2651
2639
  if (existsSync(gitignorePath)) {
2652
- const content = await readFile2(gitignorePath, "utf-8");
2653
- ig.add(content);
2654
- }
2655
- ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "*.pyc", "build", "coverage"]);
2656
- return ig;
2657
- }
2658
- async function safeReadFile(filePath, maxBytes = MAX_FILE_BYTES) {
2659
- try {
2660
- const content = await readFile2(filePath, "utf-8");
2661
- if (content.length > maxBytes) {
2662
- return content.slice(0, maxBytes) + `
2663
- ... (truncated at ${maxBytes} chars)`;
2664
- }
2665
- return content;
2666
- } catch {
2667
- return null;
2668
- }
2669
- }
2670
- function isBinaryFile(filePath) {
2671
- const ext = path2.extname(filePath).toLowerCase();
2672
- return BINARY_EXTENSIONS.has(ext);
2673
- }
2674
- function buildFileTree(files) {
2675
- const tree = {};
2676
- for (const file of files) {
2677
- const dir = path2.dirname(file);
2678
- if (!tree[dir]) tree[dir] = [];
2679
- tree[dir].push(path2.basename(file));
2680
- }
2681
- const lines = [];
2682
- const rootFiles = tree["."] ?? [];
2683
- for (const f of rootFiles) lines.push(f);
2684
- const dirs = Object.keys(tree).filter((d) => d !== ".").sort();
2685
- for (const dir of dirs) {
2686
- lines.push(`${dir}/`);
2687
- for (const f of tree[dir]) {
2688
- lines.push(` ${f}`);
2689
- }
2690
- }
2691
- return lines.join("\n");
2692
- }
2693
- function scoreRelevance(filePath, keywords) {
2694
- const lower = filePath.toLowerCase();
2695
- let score = 0;
2696
- for (const kw of keywords) {
2697
- if (lower.includes(kw)) score += 1;
2698
- }
2699
- return score;
2700
- }
2701
- async function buildProjectContext(cwd, issueTitle, issueBody) {
2702
- const ig = await buildIgnoreFilter(cwd);
2703
- const allFiles = await globby("**/*", {
2704
- cwd,
2705
- gitignore: false,
2706
- // We handle ignore ourselves
2707
- dot: false,
2708
- onlyFiles: true
2709
- });
2710
- const filtered = allFiles.filter((f) => !ig.ignores(f) && !isBinaryFile(f));
2711
- const fileTree = buildFileTree(filtered);
2712
- const issueText = `${issueTitle} ${issueBody}`.toLowerCase();
2713
- const keywords = issueText.replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3);
2714
- const keyFiles = {};
2715
- let totalBytes = 0;
2716
- for (const always of ALWAYS_READ) {
2717
- if (totalBytes >= MAX_TOTAL_BYTES) break;
2718
- const fullPath = path2.join(cwd, always);
2719
- if (!existsSync(fullPath)) continue;
2720
- const content = await safeReadFile(fullPath);
2721
- if (content !== null) {
2722
- keyFiles[always] = content;
2723
- totalBytes += content.length;
2724
- }
2725
- }
2726
- const scored = filtered.filter((f) => !ALWAYS_READ.includes(f) && !ALWAYS_READ.includes(path2.basename(f))).map((f) => ({ file: f, score: scoreRelevance(f, keywords) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
2727
- for (const { file } of scored) {
2728
- if (totalBytes >= MAX_TOTAL_BYTES) break;
2729
- const fullPath = path2.join(cwd, file);
2730
- const content = await safeReadFile(fullPath);
2731
- if (content !== null) {
2732
- keyFiles[file] = content;
2733
- totalBytes += content.length;
2734
- }
2735
- }
2736
- return { fileTree, keyFiles };
2737
- }
2738
-
2739
- // src/tools/scan-project/index.ts
2740
- var definition17 = {
2741
- type: "function",
2742
- function: {
2743
- name: "scan_project",
2744
- description: "Scan the current project directory: returns the file tree and contents of the most relevant files. Call this when creating a new task to understand the codebase before writing the task body and guide.",
2745
- parameters: {
2746
- type: "object",
2747
- properties: {
2748
- focus: {
2749
- type: "string",
2750
- description: "Keywords describing the task. Used to prioritise which files to read."
2751
- }
2752
- },
2753
- required: []
2754
- }
2755
- }
2756
- };
2757
- async function execute17(input3, _config) {
2758
- const focus = input3["focus"] ?? "";
2759
- const spinner = ora14("Scanning project...").start();
2760
- try {
2761
- const cwd = process.cwd();
2762
- const context = await buildProjectContext(cwd, focus, "");
2763
- spinner.stop();
2764
- const fileCount = context.fileTree.split("\n").filter(Boolean).length;
2765
- const readCount = Object.keys(context.keyFiles).length;
2766
- const totalBytes = Object.values(context.keyFiles).reduce((s, c) => s + c.length, 0);
2767
- const summary = `Scanned ${fileCount} files \xB7 ${readCount} read \xB7 ${(totalBytes / 1024).toFixed(1)} KB`;
2768
- const parts = [summary, `## File tree
2769
- \`\`\`
2770
- ${context.fileTree}
2771
- \`\`\``];
2772
- for (const [filePath, content] of Object.entries(context.keyFiles)) {
2773
- parts.push(`## ${filePath}
2774
- \`\`\`
2775
- ${content}
2776
- \`\`\``);
2777
- }
2778
- return parts.join("\n\n");
2779
- } catch (err) {
2780
- spinner.stop();
2781
- throw err;
2640
+ ig.add(await readFile2(gitignorePath, "utf-8"));
2782
2641
  }
2642
+ ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "build", "coverage"]);
2643
+ const files = await globby(glob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2644
+ const filtered = files.filter((f) => !ig.ignores(f) && !BINARY_EXTENSIONS.has(path2.extname(f).toLowerCase()));
2645
+ if (filtered.length === 0) return `No files matched: ${glob}`;
2646
+ return `${filtered.length} file(s):
2647
+ ${filtered.join("\n")}`;
2783
2648
  }
2784
2649
 
2785
- // src/tools/read-file/index.ts
2786
- var read_file_exports = {};
2787
- __export(read_file_exports, {
2650
+ // src/tools/grep-code/index.ts
2651
+ var grep_code_exports = {};
2652
+ __export(grep_code_exports, {
2788
2653
  definition: () => definition18,
2789
2654
  execute: () => execute18
2790
2655
  });
2791
2656
  import { readFile as readFile3 } from "fs/promises";
2657
+ import { existsSync as existsSync2 } from "fs";
2792
2658
  import path3 from "path";
2659
+ import { globby as globby2 } from "globby";
2660
+ import ignore2 from "ignore";
2793
2661
  var definition18 = {
2794
2662
  type: "function",
2795
2663
  function: {
2796
- name: "read_file",
2797
- description: "Read the full contents of a specific file in the project.",
2664
+ name: "grep_code",
2665
+ description: "Search for a pattern across files, or read a specific line range from a file.\n- Search mode: provide `pattern` \u2014 returns matching lines with context.\n- Read-range mode: provide `file_glob` (single file) + `start_line` + `end_line`, no `pattern` \u2014 read an exact section. Use after grep has given you line numbers.",
2798
2666
  parameters: {
2799
2667
  type: "object",
2800
2668
  properties: {
2801
- path: { type: "string", description: "File path relative to the project root" }
2669
+ pattern: {
2670
+ type: "string",
2671
+ description: "Regex or plain text to search for (case-insensitive). Omit for read-range mode."
2672
+ },
2673
+ file_glob: {
2674
+ type: "string",
2675
+ description: 'Glob to restrict which files to search or read, e.g. "src/**/*.ts" or "src/lib/agent.ts". Defaults to all text files.'
2676
+ },
2677
+ context_lines: {
2678
+ type: "number",
2679
+ description: "Lines of context before/after each match (search mode only). Default: 2."
2680
+ },
2681
+ max_results: {
2682
+ type: "number",
2683
+ description: "Max matches to return (search mode only). Default: 50."
2684
+ },
2685
+ start_line: {
2686
+ type: "number",
2687
+ description: "First line to read, 1-based (read-range mode). Requires file_glob pointing to a single file."
2688
+ },
2689
+ end_line: {
2690
+ type: "number",
2691
+ description: "Last line to read, 1-based (read-range mode)."
2692
+ }
2802
2693
  },
2803
- required: ["path"]
2694
+ required: []
2804
2695
  }
2805
2696
  }
2806
2697
  };
2698
+ async function buildIgnore(cwd) {
2699
+ const ig = ignore2();
2700
+ const gitignorePath = path3.join(cwd, ".gitignore");
2701
+ if (existsSync2(gitignorePath)) {
2702
+ ig.add(await readFile3(gitignorePath, "utf-8"));
2703
+ }
2704
+ ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "build", "coverage"]);
2705
+ return ig;
2706
+ }
2707
+ var BINARY_EXTENSIONS2 = /* @__PURE__ */ new Set([
2708
+ ".png",
2709
+ ".jpg",
2710
+ ".jpeg",
2711
+ ".gif",
2712
+ ".svg",
2713
+ ".ico",
2714
+ ".webp",
2715
+ ".pdf",
2716
+ ".zip",
2717
+ ".tar",
2718
+ ".gz",
2719
+ ".exe",
2720
+ ".dll",
2721
+ ".woff",
2722
+ ".woff2",
2723
+ ".ttf",
2724
+ ".mp3",
2725
+ ".mp4",
2726
+ ".db",
2727
+ ".sqlite",
2728
+ ".lock"
2729
+ ]);
2730
+ function isText(f) {
2731
+ return !BINARY_EXTENSIONS2.has(path3.extname(f).toLowerCase());
2732
+ }
2733
+ var MAX_RANGE_LINES = 300;
2807
2734
  async function execute18(input3, _config) {
2808
- const filePath = input3["path"];
2735
+ const pattern = input3["pattern"] ?? "";
2736
+ const fileGlob = input3["file_glob"] ?? "**/*";
2737
+ const contextLines = Math.min(input3["context_lines"] ?? 2, 5);
2738
+ const maxResults = Math.min(input3["max_results"] ?? 50, 200);
2739
+ const startLine = input3["start_line"];
2740
+ const endLine = input3["end_line"];
2741
+ const cwd = process.cwd();
2742
+ if (!pattern && startLine != null && endLine != null) {
2743
+ const files = await globby2(fileGlob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2744
+ if (files.length === 0) return `No file matched: ${fileGlob}`;
2745
+ if (files.length > 1) return `file_glob matched ${files.length} files \u2014 narrow it to a single file for read-range mode.`;
2746
+ const raw = await readFile3(path3.join(cwd, files[0]), "utf-8");
2747
+ const lines = raw.split("\n");
2748
+ const total = lines.length;
2749
+ const from = Math.max(1, startLine);
2750
+ const to = Math.min(total, endLine, from + MAX_RANGE_LINES - 1);
2751
+ const numbered = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(5)}: ${l}`).join("\n");
2752
+ const truncNote = to < Math.min(total, endLine) ? `
2753
+ \u2026 (use start_line=${to + 1} to continue)` : "";
2754
+ return `${files[0]} \u2014 lines ${from}\u2013${to} of ${total}:
2755
+ \`\`\`
2756
+ ${numbered}
2757
+ \`\`\`${truncNote}`;
2758
+ }
2759
+ if (!pattern) return "Provide a `pattern` to search, or `start_line` + `end_line` for read-range mode. Use list_files to browse file paths.";
2760
+ const ig = await buildIgnore(cwd);
2761
+ let regex;
2809
2762
  try {
2810
- const fullPath = path3.join(process.cwd(), filePath);
2811
- const content = await readFile3(fullPath, "utf-8");
2812
- return content.length > 15e3 ? content.slice(0, 15e3) + "\n\n... (truncated)" : content;
2813
- } catch (err) {
2814
- return `Error reading file: ${err.message}`;
2763
+ regex = new RegExp(pattern, "i");
2764
+ } catch {
2765
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
2766
+ }
2767
+ const allFiles = await globby2(fileGlob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2768
+ const filtered = allFiles.filter((f) => !ig.ignores(f) && isText(f));
2769
+ const matches = [];
2770
+ let totalMatches = 0;
2771
+ for (const file of filtered) {
2772
+ if (totalMatches >= maxResults) break;
2773
+ let content;
2774
+ try {
2775
+ content = await readFile3(path3.join(cwd, file), "utf-8");
2776
+ } catch {
2777
+ continue;
2778
+ }
2779
+ const lines = content.split("\n");
2780
+ const hitLines = [];
2781
+ for (let i = 0; i < lines.length; i++) {
2782
+ if (regex.test(lines[i])) hitLines.push(i);
2783
+ }
2784
+ if (hitLines.length === 0) continue;
2785
+ const ranges = [];
2786
+ for (const hit of hitLines) {
2787
+ const s = Math.max(0, hit - contextLines);
2788
+ const e = Math.min(lines.length - 1, hit + contextLines);
2789
+ if (ranges.length > 0 && s <= ranges[ranges.length - 1][1] + 1) {
2790
+ ranges[ranges.length - 1][1] = e;
2791
+ } else {
2792
+ ranges.push([s, e]);
2793
+ }
2794
+ }
2795
+ const snippets = [];
2796
+ for (const [s, e] of ranges) {
2797
+ if (totalMatches >= maxResults) break;
2798
+ snippets.push(
2799
+ lines.slice(s, e + 1).map((l, i) => {
2800
+ const n = s + i + 1;
2801
+ return `${regex.test(l) ? ">" : " "} ${String(n).padStart(4)}: ${l}`;
2802
+ }).join("\n")
2803
+ );
2804
+ totalMatches += hitLines.filter((h) => h >= s && h <= e).length;
2805
+ }
2806
+ if (snippets.length > 0) {
2807
+ matches.push(`## ${file}
2808
+ \`\`\`
2809
+ ${snippets.join("\n---\n")}
2810
+ \`\`\``);
2811
+ }
2815
2812
  }
2813
+ if (matches.length === 0) return `No matches found for: ${pattern}`;
2814
+ const header = `Found matches in ${matches.length} file(s) (${totalMatches} match${totalMatches === 1 ? "" : "es"})${totalMatches >= maxResults ? " \u2014 limit reached" : ""}:`;
2815
+ return [header, ...matches].join("\n\n");
2816
2816
  }
2817
2817
 
2818
2818
  // src/tools/ask-user/index.ts
@@ -2890,13 +2890,33 @@ var toolModules = [
2890
2890
  get_comments_exports,
2891
2891
  get_diff_exports,
2892
2892
  run_command_exports,
2893
- scan_project_exports,
2894
- read_file_exports,
2893
+ list_files_exports,
2894
+ grep_code_exports,
2895
2895
  ask_user_exports
2896
2896
  ];
2897
2897
 
2898
2898
  // src/lib/agent.ts
2899
2899
  var tools = toolModules.map((m) => m.definition);
2900
+ var HISTORY_KEEP_TURNS = 8;
2901
+ function trimHistory(messages) {
2902
+ const userIndices = messages.map((m, i) => m.role === "user" ? i : -1).filter((i) => i !== -1);
2903
+ if (userIndices.length <= HISTORY_KEEP_TURNS) return;
2904
+ const compressBefore = userIndices[userIndices.length - HISTORY_KEEP_TURNS];
2905
+ const compressed = [];
2906
+ for (let t = 0; t < userIndices.length - HISTORY_KEEP_TURNS; t++) {
2907
+ const start = userIndices[t];
2908
+ const end = t + 1 < userIndices.length ? userIndices[t + 1] : compressBefore;
2909
+ const turnMessages = messages.slice(start, end);
2910
+ compressed.push(turnMessages[0]);
2911
+ const lastAssistant = [...turnMessages].reverse().find(
2912
+ (m) => m.role === "assistant" && typeof m.content === "string" && !!m.content
2913
+ );
2914
+ if (lastAssistant) {
2915
+ compressed.push({ role: "assistant", content: lastAssistant.content });
2916
+ }
2917
+ }
2918
+ messages.splice(0, compressBefore, ...compressed);
2919
+ }
2900
2920
  async function executeTool(name, input3, config) {
2901
2921
  const mod = toolModules.find((m) => m.definition.function.name === name);
2902
2922
  if (!mod) return `Unknown tool: ${name}`;
@@ -2922,9 +2942,14 @@ async function runAgentLoop(config, messages) {
2922
2942
  "## Tool philosophy",
2923
2943
  "Command tools (pick, new_task, close, submit, my_status, review, refresh, open_code) run",
2924
2944
  "hardcoded interactive flows \u2014 always use these for user-facing actions.",
2925
- "Low-level tools are for reasoning: run_command, scan_project, read_file, ask_user,",
2945
+ "Low-level tools are for reasoning: run_command, grep_code, ask_user,",
2926
2946
  "get_task, get_comments, get_diff.",
2927
2947
  "",
2948
+ "## Exploring the codebase",
2949
+ "1. list_files \u2014 see all files or filter by glob to orient yourself.",
2950
+ "2. grep_code(pattern) \u2014 find where functions/variables appear.",
2951
+ "3. grep_code(file_glob, start_line, end_line) \u2014 read a specific section after grep gives you line numbers.",
2952
+ "",
2928
2953
  "## Creating a task",
2929
2954
  "If the task description is vague, call ask_user to clarify (max 3 times).",
2930
2955
  "Then call new_task with the title \u2014 the tool scans the project and generates the guide automatically.",
@@ -2955,7 +2980,8 @@ async function runAgentLoop(config, messages) {
2955
2980
  if (++iterations > MAX_ITERATIONS) {
2956
2981
  throw new Error(`Agent exceeded ${MAX_ITERATIONS} iterations without finishing.`);
2957
2982
  }
2958
- const spinner = ora15({ text: chalk13.dim("Thinking\u2026"), color: "cyan" }).start();
2983
+ trimHistory(messages);
2984
+ const spinner = ora14({ text: chalk13.dim("Thinking\u2026"), color: "cyan" }).start();
2959
2985
  let response;
2960
2986
  try {
2961
2987
  response = await client.chat.completions.create({
@@ -3113,8 +3139,8 @@ async function initNewRepo(config, owner, repo) {
3113
3139
  github: { owner, repo }
3114
3140
  };
3115
3141
  setConfig({ github: newConfig.github });
3116
- const ora16 = (await import("ora")).default;
3117
- const spinner = ora16("Creating Techunter labels...").start();
3142
+ const ora15 = (await import("ora")).default;
3143
+ const spinner = ora15("Creating Techunter labels...").start();
3118
3144
  try {
3119
3145
  await ensureLabels(newConfig);
3120
3146
  spinner.succeed("Labels ready");
package/dist/mcp.js CHANGED
@@ -852,7 +852,7 @@ ${issue.body ?? "(none)"}
852
852
 
853
853
  Diff:
854
854
  ${diff || "(no changes)"}`,
855
- ["run_command", "read_file", "get_diff"]
855
+ ["run_command", "grep_code", "get_diff"]
856
856
  );
857
857
  }
858
858
 
@@ -1366,9 +1366,9 @@ Previous guide:
1366
1366
  ${revise.previousGuide}` : `Write an implementation guide for this task: "${title}"`;
1367
1367
  return runSubAgentLoop(
1368
1368
  config,
1369
- "You are a senior engineer writing a brief task guide for a developer. Use scan_project and read_file to identify which files are relevant. Do NOT include code snippets or implementation details. When you have enough context, write the guide.\n\n" + GUIDE_FORMAT,
1369
+ "You are a senior engineer writing a brief task guide for a developer. Use list_files then grep_code to identify which files are relevant. Do NOT include code snippets or implementation details. When you have enough context, write the guide.\n\n" + GUIDE_FORMAT,
1370
1370
  userMessage,
1371
- ["scan_project", "read_file", "run_command", "ask_user"]
1371
+ ["list_files", "grep_code", "run_command", "ask_user"]
1372
1372
  );
1373
1373
  }
1374
1374
 
@@ -1738,10 +1738,10 @@ Clear instruction on what to fix and how to re-submit (via /submit).
1738
1738
  async function generateRejectionComment(config, issueNumber, userFeedback) {
1739
1739
  return runSubAgentLoop(
1740
1740
  config,
1741
- "You are a senior engineer writing a structured code review rejection comment. Use get_task to read the acceptance criteria, get_diff or read_file to inspect the implementation, and get_comments to see prior discussion. Then write the rejection comment.\n\n" + REJECTION_FORMAT,
1741
+ "You are a senior engineer writing a structured code review rejection comment. Use get_task to read the acceptance criteria, get_diff or grep_code to inspect the implementation, and get_comments to see prior discussion. Then write the rejection comment.\n\n" + REJECTION_FORMAT,
1742
1742
  `Write a rejection comment for issue #${issueNumber}.
1743
1743
  Reviewer feedback: ${userFeedback}`,
1744
- ["get_task", "get_comments", "get_diff", "read_file"]
1744
+ ["get_task", "get_comments", "get_diff", "grep_code"]
1745
1745
  );
1746
1746
  }
1747
1747
 
@@ -2284,20 +2284,34 @@ ${out || e.message}`;
2284
2284
  }
2285
2285
  }
2286
2286
 
2287
- // src/tools/scan-project/index.ts
2288
- var scan_project_exports = {};
2289
- __export(scan_project_exports, {
2287
+ // src/tools/list-files/index.ts
2288
+ var list_files_exports = {};
2289
+ __export(list_files_exports, {
2290
2290
  definition: () => definition17,
2291
2291
  execute: () => execute17
2292
2292
  });
2293
- import ora13 from "ora";
2294
-
2295
- // src/lib/project.ts
2296
2293
  import { readFile as readFile2 } from "fs/promises";
2297
2294
  import { existsSync } from "fs";
2298
2295
  import path2 from "path";
2299
2296
  import { globby } from "globby";
2300
2297
  import ignore from "ignore";
2298
+ var definition17 = {
2299
+ type: "function",
2300
+ function: {
2301
+ name: "list_files",
2302
+ description: "List file paths in the project. Use this first to orient yourself before searching or reading.",
2303
+ parameters: {
2304
+ type: "object",
2305
+ properties: {
2306
+ glob: {
2307
+ type: "string",
2308
+ description: 'Glob pattern to filter results, e.g. "src/**/*.ts". Defaults to all text files.'
2309
+ }
2310
+ },
2311
+ required: []
2312
+ }
2313
+ }
2314
+ };
2301
2315
  var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2302
2316
  ".png",
2303
2317
  ".jpg",
@@ -2310,213 +2324,199 @@ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2310
2324
  ".zip",
2311
2325
  ".tar",
2312
2326
  ".gz",
2313
- ".bz2",
2314
- ".rar",
2315
2327
  ".exe",
2316
2328
  ".dll",
2317
- ".so",
2318
- ".dylib",
2319
2329
  ".woff",
2320
2330
  ".woff2",
2321
2331
  ".ttf",
2322
- ".otf",
2323
- ".eot",
2324
2332
  ".mp3",
2325
2333
  ".mp4",
2326
- ".wav",
2327
- ".avi",
2328
- ".mov",
2329
2334
  ".db",
2330
2335
  ".sqlite",
2331
2336
  ".lock"
2332
2337
  ]);
2333
- var ALWAYS_READ = [
2334
- "README.md",
2335
- "README.txt",
2336
- "README",
2337
- "package.json",
2338
- "pyproject.toml",
2339
- "go.mod",
2340
- "Cargo.toml",
2341
- "tsconfig.json",
2342
- "vite.config.ts",
2343
- "vite.config.js",
2344
- "webpack.config.js",
2345
- "rollup.config.js",
2346
- ".env.example",
2347
- "docker-compose.yml",
2348
- "Dockerfile"
2349
- ];
2350
- var MAX_TOTAL_BYTES = 8e4;
2351
- var MAX_FILE_BYTES = 15e3;
2352
- async function buildIgnoreFilter(cwd) {
2338
+ async function execute17(input, _config) {
2339
+ const glob = input["glob"] ?? "**/*";
2340
+ const cwd = process.cwd();
2353
2341
  const ig = ignore();
2354
2342
  const gitignorePath = path2.join(cwd, ".gitignore");
2355
2343
  if (existsSync(gitignorePath)) {
2356
- const content = await readFile2(gitignorePath, "utf-8");
2357
- ig.add(content);
2344
+ ig.add(await readFile2(gitignorePath, "utf-8"));
2358
2345
  }
2359
- ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "*.pyc", "build", "coverage"]);
2360
- return ig;
2361
- }
2362
- async function safeReadFile(filePath, maxBytes = MAX_FILE_BYTES) {
2363
- try {
2364
- const content = await readFile2(filePath, "utf-8");
2365
- if (content.length > maxBytes) {
2366
- return content.slice(0, maxBytes) + `
2367
- ... (truncated at ${maxBytes} chars)`;
2368
- }
2369
- return content;
2370
- } catch {
2371
- return null;
2372
- }
2373
- }
2374
- function isBinaryFile(filePath) {
2375
- const ext = path2.extname(filePath).toLowerCase();
2376
- return BINARY_EXTENSIONS.has(ext);
2377
- }
2378
- function buildFileTree(files) {
2379
- const tree = {};
2380
- for (const file of files) {
2381
- const dir = path2.dirname(file);
2382
- if (!tree[dir]) tree[dir] = [];
2383
- tree[dir].push(path2.basename(file));
2384
- }
2385
- const lines = [];
2386
- const rootFiles = tree["."] ?? [];
2387
- for (const f of rootFiles) lines.push(f);
2388
- const dirs = Object.keys(tree).filter((d) => d !== ".").sort();
2389
- for (const dir of dirs) {
2390
- lines.push(`${dir}/`);
2391
- for (const f of tree[dir]) {
2392
- lines.push(` ${f}`);
2393
- }
2394
- }
2395
- return lines.join("\n");
2396
- }
2397
- function scoreRelevance(filePath, keywords) {
2398
- const lower = filePath.toLowerCase();
2399
- let score = 0;
2400
- for (const kw of keywords) {
2401
- if (lower.includes(kw)) score += 1;
2402
- }
2403
- return score;
2404
- }
2405
- async function buildProjectContext(cwd, issueTitle, issueBody) {
2406
- const ig = await buildIgnoreFilter(cwd);
2407
- const allFiles = await globby("**/*", {
2408
- cwd,
2409
- gitignore: false,
2410
- // We handle ignore ourselves
2411
- dot: false,
2412
- onlyFiles: true
2413
- });
2414
- const filtered = allFiles.filter((f) => !ig.ignores(f) && !isBinaryFile(f));
2415
- const fileTree = buildFileTree(filtered);
2416
- const issueText = `${issueTitle} ${issueBody}`.toLowerCase();
2417
- const keywords = issueText.replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((w) => w.length > 3);
2418
- const keyFiles = {};
2419
- let totalBytes = 0;
2420
- for (const always of ALWAYS_READ) {
2421
- if (totalBytes >= MAX_TOTAL_BYTES) break;
2422
- const fullPath = path2.join(cwd, always);
2423
- if (!existsSync(fullPath)) continue;
2424
- const content = await safeReadFile(fullPath);
2425
- if (content !== null) {
2426
- keyFiles[always] = content;
2427
- totalBytes += content.length;
2428
- }
2429
- }
2430
- const scored = filtered.filter((f) => !ALWAYS_READ.includes(f) && !ALWAYS_READ.includes(path2.basename(f))).map((f) => ({ file: f, score: scoreRelevance(f, keywords) })).filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
2431
- for (const { file } of scored) {
2432
- if (totalBytes >= MAX_TOTAL_BYTES) break;
2433
- const fullPath = path2.join(cwd, file);
2434
- const content = await safeReadFile(fullPath);
2435
- if (content !== null) {
2436
- keyFiles[file] = content;
2437
- totalBytes += content.length;
2438
- }
2439
- }
2440
- return { fileTree, keyFiles };
2346
+ ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "build", "coverage"]);
2347
+ const files = await globby(glob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2348
+ const filtered = files.filter((f) => !ig.ignores(f) && !BINARY_EXTENSIONS.has(path2.extname(f).toLowerCase()));
2349
+ if (filtered.length === 0) return `No files matched: ${glob}`;
2350
+ return `${filtered.length} file(s):
2351
+ ${filtered.join("\n")}`;
2441
2352
  }
2442
2353
 
2443
- // src/tools/scan-project/index.ts
2444
- var definition17 = {
2445
- type: "function",
2446
- function: {
2447
- name: "scan_project",
2448
- description: "Scan the current project directory: returns the file tree and contents of the most relevant files. Call this when creating a new task to understand the codebase before writing the task body and guide.",
2449
- parameters: {
2450
- type: "object",
2451
- properties: {
2452
- focus: {
2453
- type: "string",
2454
- description: "Keywords describing the task. Used to prioritise which files to read."
2455
- }
2456
- },
2457
- required: []
2458
- }
2459
- }
2460
- };
2461
- async function execute17(input, _config) {
2462
- const focus = input["focus"] ?? "";
2463
- const spinner = ora13("Scanning project...").start();
2464
- try {
2465
- const cwd = process.cwd();
2466
- const context = await buildProjectContext(cwd, focus, "");
2467
- spinner.stop();
2468
- const fileCount = context.fileTree.split("\n").filter(Boolean).length;
2469
- const readCount = Object.keys(context.keyFiles).length;
2470
- const totalBytes = Object.values(context.keyFiles).reduce((s, c) => s + c.length, 0);
2471
- const summary = `Scanned ${fileCount} files \xB7 ${readCount} read \xB7 ${(totalBytes / 1024).toFixed(1)} KB`;
2472
- const parts = [summary, `## File tree
2473
- \`\`\`
2474
- ${context.fileTree}
2475
- \`\`\``];
2476
- for (const [filePath, content] of Object.entries(context.keyFiles)) {
2477
- parts.push(`## ${filePath}
2478
- \`\`\`
2479
- ${content}
2480
- \`\`\``);
2481
- }
2482
- return parts.join("\n\n");
2483
- } catch (err) {
2484
- spinner.stop();
2485
- throw err;
2486
- }
2487
- }
2488
-
2489
- // src/tools/read-file/index.ts
2490
- var read_file_exports = {};
2491
- __export(read_file_exports, {
2354
+ // src/tools/grep-code/index.ts
2355
+ var grep_code_exports = {};
2356
+ __export(grep_code_exports, {
2492
2357
  definition: () => definition18,
2493
2358
  execute: () => execute18
2494
2359
  });
2495
2360
  import { readFile as readFile3 } from "fs/promises";
2361
+ import { existsSync as existsSync2 } from "fs";
2496
2362
  import path3 from "path";
2363
+ import { globby as globby2 } from "globby";
2364
+ import ignore2 from "ignore";
2497
2365
  var definition18 = {
2498
2366
  type: "function",
2499
2367
  function: {
2500
- name: "read_file",
2501
- description: "Read the full contents of a specific file in the project.",
2368
+ name: "grep_code",
2369
+ description: "Search for a pattern across files, or read a specific line range from a file.\n- Search mode: provide `pattern` \u2014 returns matching lines with context.\n- Read-range mode: provide `file_glob` (single file) + `start_line` + `end_line`, no `pattern` \u2014 read an exact section. Use after grep has given you line numbers.",
2502
2370
  parameters: {
2503
2371
  type: "object",
2504
2372
  properties: {
2505
- path: { type: "string", description: "File path relative to the project root" }
2373
+ pattern: {
2374
+ type: "string",
2375
+ description: "Regex or plain text to search for (case-insensitive). Omit for read-range mode."
2376
+ },
2377
+ file_glob: {
2378
+ type: "string",
2379
+ description: 'Glob to restrict which files to search or read, e.g. "src/**/*.ts" or "src/lib/agent.ts". Defaults to all text files.'
2380
+ },
2381
+ context_lines: {
2382
+ type: "number",
2383
+ description: "Lines of context before/after each match (search mode only). Default: 2."
2384
+ },
2385
+ max_results: {
2386
+ type: "number",
2387
+ description: "Max matches to return (search mode only). Default: 50."
2388
+ },
2389
+ start_line: {
2390
+ type: "number",
2391
+ description: "First line to read, 1-based (read-range mode). Requires file_glob pointing to a single file."
2392
+ },
2393
+ end_line: {
2394
+ type: "number",
2395
+ description: "Last line to read, 1-based (read-range mode)."
2396
+ }
2506
2397
  },
2507
- required: ["path"]
2398
+ required: []
2508
2399
  }
2509
2400
  }
2510
2401
  };
2402
+ async function buildIgnore(cwd) {
2403
+ const ig = ignore2();
2404
+ const gitignorePath = path3.join(cwd, ".gitignore");
2405
+ if (existsSync2(gitignorePath)) {
2406
+ ig.add(await readFile3(gitignorePath, "utf-8"));
2407
+ }
2408
+ ig.add(["node_modules", "dist", ".git", ".next", "__pycache__", "build", "coverage"]);
2409
+ return ig;
2410
+ }
2411
+ var BINARY_EXTENSIONS2 = /* @__PURE__ */ new Set([
2412
+ ".png",
2413
+ ".jpg",
2414
+ ".jpeg",
2415
+ ".gif",
2416
+ ".svg",
2417
+ ".ico",
2418
+ ".webp",
2419
+ ".pdf",
2420
+ ".zip",
2421
+ ".tar",
2422
+ ".gz",
2423
+ ".exe",
2424
+ ".dll",
2425
+ ".woff",
2426
+ ".woff2",
2427
+ ".ttf",
2428
+ ".mp3",
2429
+ ".mp4",
2430
+ ".db",
2431
+ ".sqlite",
2432
+ ".lock"
2433
+ ]);
2434
+ function isText(f) {
2435
+ return !BINARY_EXTENSIONS2.has(path3.extname(f).toLowerCase());
2436
+ }
2437
+ var MAX_RANGE_LINES = 300;
2511
2438
  async function execute18(input, _config) {
2512
- const filePath = input["path"];
2439
+ const pattern = input["pattern"] ?? "";
2440
+ const fileGlob = input["file_glob"] ?? "**/*";
2441
+ const contextLines = Math.min(input["context_lines"] ?? 2, 5);
2442
+ const maxResults = Math.min(input["max_results"] ?? 50, 200);
2443
+ const startLine = input["start_line"];
2444
+ const endLine = input["end_line"];
2445
+ const cwd = process.cwd();
2446
+ if (!pattern && startLine != null && endLine != null) {
2447
+ const files = await globby2(fileGlob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2448
+ if (files.length === 0) return `No file matched: ${fileGlob}`;
2449
+ if (files.length > 1) return `file_glob matched ${files.length} files \u2014 narrow it to a single file for read-range mode.`;
2450
+ const raw = await readFile3(path3.join(cwd, files[0]), "utf-8");
2451
+ const lines = raw.split("\n");
2452
+ const total = lines.length;
2453
+ const from = Math.max(1, startLine);
2454
+ const to = Math.min(total, endLine, from + MAX_RANGE_LINES - 1);
2455
+ const numbered = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(5)}: ${l}`).join("\n");
2456
+ const truncNote = to < Math.min(total, endLine) ? `
2457
+ \u2026 (use start_line=${to + 1} to continue)` : "";
2458
+ return `${files[0]} \u2014 lines ${from}\u2013${to} of ${total}:
2459
+ \`\`\`
2460
+ ${numbered}
2461
+ \`\`\`${truncNote}`;
2462
+ }
2463
+ if (!pattern) return "Provide a `pattern` to search, or `start_line` + `end_line` for read-range mode. Use list_files to browse file paths.";
2464
+ const ig = await buildIgnore(cwd);
2465
+ let regex;
2513
2466
  try {
2514
- const fullPath = path3.join(process.cwd(), filePath);
2515
- const content = await readFile3(fullPath, "utf-8");
2516
- return content.length > 15e3 ? content.slice(0, 15e3) + "\n\n... (truncated)" : content;
2517
- } catch (err) {
2518
- return `Error reading file: ${err.message}`;
2467
+ regex = new RegExp(pattern, "i");
2468
+ } catch {
2469
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
2470
+ }
2471
+ const allFiles = await globby2(fileGlob, { cwd, dot: false, onlyFiles: true, gitignore: false });
2472
+ const filtered = allFiles.filter((f) => !ig.ignores(f) && isText(f));
2473
+ const matches = [];
2474
+ let totalMatches = 0;
2475
+ for (const file of filtered) {
2476
+ if (totalMatches >= maxResults) break;
2477
+ let content;
2478
+ try {
2479
+ content = await readFile3(path3.join(cwd, file), "utf-8");
2480
+ } catch {
2481
+ continue;
2482
+ }
2483
+ const lines = content.split("\n");
2484
+ const hitLines = [];
2485
+ for (let i = 0; i < lines.length; i++) {
2486
+ if (regex.test(lines[i])) hitLines.push(i);
2487
+ }
2488
+ if (hitLines.length === 0) continue;
2489
+ const ranges = [];
2490
+ for (const hit of hitLines) {
2491
+ const s = Math.max(0, hit - contextLines);
2492
+ const e = Math.min(lines.length - 1, hit + contextLines);
2493
+ if (ranges.length > 0 && s <= ranges[ranges.length - 1][1] + 1) {
2494
+ ranges[ranges.length - 1][1] = e;
2495
+ } else {
2496
+ ranges.push([s, e]);
2497
+ }
2498
+ }
2499
+ const snippets = [];
2500
+ for (const [s, e] of ranges) {
2501
+ if (totalMatches >= maxResults) break;
2502
+ snippets.push(
2503
+ lines.slice(s, e + 1).map((l, i) => {
2504
+ const n = s + i + 1;
2505
+ return `${regex.test(l) ? ">" : " "} ${String(n).padStart(4)}: ${l}`;
2506
+ }).join("\n")
2507
+ );
2508
+ totalMatches += hitLines.filter((h) => h >= s && h <= e).length;
2509
+ }
2510
+ if (snippets.length > 0) {
2511
+ matches.push(`## ${file}
2512
+ \`\`\`
2513
+ ${snippets.join("\n---\n")}
2514
+ \`\`\``);
2515
+ }
2519
2516
  }
2517
+ if (matches.length === 0) return `No matches found for: ${pattern}`;
2518
+ const header = `Found matches in ${matches.length} file(s) (${totalMatches} match${totalMatches === 1 ? "" : "es"})${totalMatches >= maxResults ? " \u2014 limit reached" : ""}:`;
2519
+ return [header, ...matches].join("\n\n");
2520
2520
  }
2521
2521
 
2522
2522
  // src/tools/ask-user/index.ts
@@ -2594,8 +2594,8 @@ var toolModules = [
2594
2594
  get_comments_exports,
2595
2595
  get_diff_exports,
2596
2596
  run_command_exports,
2597
- scan_project_exports,
2598
- read_file_exports,
2597
+ list_files_exports,
2598
+ grep_code_exports,
2599
2599
  ask_user_exports
2600
2600
  ];
2601
2601
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "techunter",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "AI-powered task distribution CLI for development teams",
5
5
  "author": "Techunter Contributors",
6
6
  "license": "MIT",