@hasna/terminal 4.3.1 → 4.3.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 (79) hide show
  1. package/dist/App.js +404 -0
  2. package/dist/Browse.js +79 -0
  3. package/dist/FuzzyPicker.js +47 -0
  4. package/dist/Onboarding.js +51 -0
  5. package/dist/Spinner.js +12 -0
  6. package/dist/StatusBar.js +49 -0
  7. package/dist/ai.js +316 -0
  8. package/dist/cache.js +42 -0
  9. package/dist/cli.js +778 -0
  10. package/dist/command-rewriter.js +64 -0
  11. package/dist/command-validator.js +86 -0
  12. package/dist/compression.js +91 -0
  13. package/dist/context-hints.js +285 -0
  14. package/dist/diff-cache.js +107 -0
  15. package/dist/discover.js +212 -0
  16. package/dist/economy.js +155 -0
  17. package/dist/expand-store.js +44 -0
  18. package/dist/file-cache.js +72 -0
  19. package/dist/file-index.js +62 -0
  20. package/dist/history.js +62 -0
  21. package/dist/lazy-executor.js +54 -0
  22. package/dist/line-dedup.js +59 -0
  23. package/dist/loop-detector.js +75 -0
  24. package/dist/mcp/install.js +189 -0
  25. package/dist/mcp/server.js +56 -0
  26. package/dist/mcp/tools/batch.js +111 -0
  27. package/dist/mcp/tools/execute.js +194 -0
  28. package/dist/mcp/tools/files.js +290 -0
  29. package/dist/mcp/tools/git.js +233 -0
  30. package/dist/mcp/tools/helpers.js +63 -0
  31. package/dist/mcp/tools/memory.js +151 -0
  32. package/dist/mcp/tools/meta.js +138 -0
  33. package/dist/mcp/tools/process.js +50 -0
  34. package/dist/mcp/tools/project.js +251 -0
  35. package/dist/mcp/tools/search.js +86 -0
  36. package/dist/noise-filter.js +94 -0
  37. package/dist/output-processor.js +233 -0
  38. package/dist/output-store.js +112 -0
  39. package/dist/paths.js +28 -0
  40. package/dist/providers/anthropic.js +43 -0
  41. package/dist/providers/base.js +4 -0
  42. package/dist/providers/cerebras.js +8 -0
  43. package/dist/providers/groq.js +8 -0
  44. package/dist/providers/index.js +142 -0
  45. package/dist/providers/openai-compat.js +93 -0
  46. package/dist/providers/xai.js +8 -0
  47. package/dist/recipes/model.js +20 -0
  48. package/dist/recipes/storage.js +153 -0
  49. package/dist/search/content-search.js +70 -0
  50. package/dist/search/file-search.js +61 -0
  51. package/dist/search/filters.js +34 -0
  52. package/dist/search/index.js +5 -0
  53. package/dist/search/semantic.js +346 -0
  54. package/dist/session-boot.js +59 -0
  55. package/dist/session-context.js +55 -0
  56. package/dist/sessions-db.js +240 -0
  57. package/dist/smart-display.js +286 -0
  58. package/dist/snapshots.js +51 -0
  59. package/dist/supervisor.js +112 -0
  60. package/dist/test-watchlist.js +131 -0
  61. package/dist/tokens.js +17 -0
  62. package/dist/tool-profiles.js +130 -0
  63. package/dist/tree.js +94 -0
  64. package/dist/usage-cache.js +65 -0
  65. package/package.json +2 -1
  66. package/src/Onboarding.tsx +1 -1
  67. package/src/ai.ts +5 -4
  68. package/src/cache.ts +2 -2
  69. package/src/economy.ts +3 -3
  70. package/src/history.ts +2 -2
  71. package/src/mcp/server.ts +2 -0
  72. package/src/mcp/tools/memory.ts +4 -2
  73. package/src/output-store.ts +2 -1
  74. package/src/paths.ts +32 -0
  75. package/src/recipes/storage.ts +3 -3
  76. package/src/session-context.ts +2 -2
  77. package/src/sessions-db.ts +15 -4
  78. package/src/tool-profiles.ts +4 -3
  79. package/src/usage-cache.ts +2 -2
@@ -0,0 +1,59 @@
1
+ // Cross-command line deduplication — track lines already shown to agent
2
+ // When new output contains >50% already-seen lines, suppress them
3
+ const seenLines = new Set();
4
+ const MAX_SEEN = 5000;
5
+ function normalize(line) {
6
+ return line.trim().toLowerCase();
7
+ }
8
+ /** Deduplicate output lines against session history */
9
+ export function dedup(output) {
10
+ const lines = output.split("\n");
11
+ if (lines.length < 5) {
12
+ // Short output — add to seen, don't dedup
13
+ for (const l of lines) {
14
+ if (l.trim())
15
+ seenLines.add(normalize(l));
16
+ }
17
+ return { output, novelCount: lines.length, seenCount: 0, deduplicated: false };
18
+ }
19
+ let novelCount = 0;
20
+ let seenCount = 0;
21
+ const novel = [];
22
+ for (const line of lines) {
23
+ const norm = normalize(line);
24
+ if (!norm) {
25
+ novel.push(line);
26
+ continue;
27
+ }
28
+ if (seenLines.has(norm)) {
29
+ seenCount++;
30
+ }
31
+ else {
32
+ novelCount++;
33
+ novel.push(line);
34
+ seenLines.add(norm);
35
+ }
36
+ }
37
+ // Evict oldest if too large
38
+ if (seenLines.size > MAX_SEEN) {
39
+ const entries = [...seenLines];
40
+ for (let i = 0; i < entries.length - MAX_SEEN; i++) {
41
+ seenLines.delete(entries[i]);
42
+ }
43
+ }
44
+ // Only dedup if >50% were already seen
45
+ if (seenCount > lines.length * 0.5) {
46
+ const result = novel.join("\n");
47
+ return { output: result + `\n(${seenCount} lines already shown, omitted)`, novelCount, seenCount, deduplicated: true };
48
+ }
49
+ // Add all to seen but return full output
50
+ for (const l of lines) {
51
+ if (l.trim())
52
+ seenLines.add(normalize(l));
53
+ }
54
+ return { output, novelCount: lines.length, seenCount: 0, deduplicated: false };
55
+ }
56
+ /** Clear dedup history */
57
+ export function clearDedup() {
58
+ seenLines.clear();
59
+ }
@@ -0,0 +1,75 @@
1
+ // Edit-test loop detector — detects repetitive test→edit→test patterns
2
+ // and suggests narrowing to specific test files
3
+ const history = [];
4
+ const MAX_HISTORY = 20;
5
+ // Detect test commands
6
+ const TEST_PATTERNS = [
7
+ /\bbun\s+test\b/, /\bnpm\s+test\b/, /\bnpx\s+jest\b/, /\bnpx\s+vitest\b/,
8
+ /\bpnpm\s+test\b/, /\byarn\s+test\b/, /\bpytest\b/, /\bgo\s+test\b/,
9
+ /\bcargo\s+test\b/, /\brspec\b/, /\bphpunit\b/, /\bmocha\b/,
10
+ ];
11
+ function isTestCommand(cmd) {
12
+ return TEST_PATTERNS.some(p => p.test(cmd));
13
+ }
14
+ function isFullSuiteCommand(cmd) {
15
+ // Full suite = test command without specific file/pattern
16
+ if (!isTestCommand(cmd))
17
+ return false;
18
+ // If it has a specific file or --grep, it's already narrowed
19
+ if (/\.(test|spec)\.(ts|tsx|js|jsx|py|rs|go)/.test(cmd))
20
+ return false;
21
+ if (/--grep|--filter|-t\s/.test(cmd))
22
+ return false;
23
+ return true;
24
+ }
25
+ /** Record a command execution and detect loops */
26
+ export function detectLoop(command) {
27
+ history.push({ command, timestamp: Date.now() });
28
+ if (history.length > MAX_HISTORY)
29
+ history.shift();
30
+ if (!isTestCommand(command)) {
31
+ return { detected: false, iteration: 0, testCommand: command };
32
+ }
33
+ // Count consecutive test runs (allowing non-test commands between them)
34
+ let testCount = 0;
35
+ for (let i = history.length - 1; i >= 0; i--) {
36
+ if (isTestCommand(history[i].command))
37
+ testCount++;
38
+ // If we hit a non-test, non-edit command, stop counting
39
+ // (edits are invisible to us since we only see exec'd commands)
40
+ }
41
+ if (testCount < 3 || !isFullSuiteCommand(command)) {
42
+ return { detected: false, iteration: testCount, testCommand: command };
43
+ }
44
+ // Detected loop — suggest narrowing
45
+ // Try to find a recently-mentioned test file in recent commands
46
+ let suggestedNarrow;
47
+ // Look for file paths in recent history that could be test targets
48
+ for (let i = history.length - 2; i >= Math.max(0, history.length - 10); i--) {
49
+ const cmd = history[i].command;
50
+ // Look for edited/touched files
51
+ const fileMatch = cmd.match(/(\S+\.(ts|tsx|js|jsx|py|rs|go))\b/);
52
+ if (fileMatch && !isTestCommand(cmd)) {
53
+ const file = fileMatch[1];
54
+ // Suggest corresponding test file
55
+ const testFile = file.replace(/\.(ts|tsx|js|jsx)$/, ".test.$1");
56
+ suggestedNarrow = command.replace(/\b(test)\b/, `test ${testFile}`);
57
+ break;
58
+ }
59
+ }
60
+ // Fallback: suggest adding --grep or specific file
61
+ if (!suggestedNarrow) {
62
+ suggestedNarrow = undefined; // Can't determine which file
63
+ }
64
+ return {
65
+ detected: true,
66
+ iteration: testCount,
67
+ testCommand: command,
68
+ suggestedNarrow,
69
+ reason: `Full test suite run ${testCount} times. Consider narrowing to specific test file.`,
70
+ };
71
+ }
72
+ /** Reset loop detection (e.g., on session start) */
73
+ export function resetLoopDetector() {
74
+ history.length = 0;
75
+ }
@@ -0,0 +1,189 @@
1
+ // MCP installation — one command to rule them all
2
+ // `npx @hasna/terminal install` → installs globally + configures all AI agents
3
+ import { execSync } from "child_process";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ function which(cmd) {
8
+ try {
9
+ return execSync(`which ${cmd}`, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ function log(icon, msg) { console.log(` ${icon} ${msg}`); }
16
+ // ── Detect what's installed ──────────────────────────────────────────────────
17
+ function hasClaude() { return !!which("claude"); }
18
+ function hasCodex() { return !!which("codex"); }
19
+ function hasGemini() { return !!which("gemini"); }
20
+ // ── Install for Claude Code ─────────────────────────────────────────────────
21
+ function installClaude(bin) {
22
+ if (!hasClaude()) {
23
+ log("–", "Claude Code not found, skipping");
24
+ return false;
25
+ }
26
+ try {
27
+ execSync(`claude mcp add --transport stdio --scope user terminal -- ${bin} mcp serve`, { stdio: ["pipe", "pipe", "pipe"] });
28
+ log("✓", "Claude Code");
29
+ return true;
30
+ }
31
+ catch {
32
+ // May already exist
33
+ try {
34
+ execSync(`claude mcp remove terminal -s user`, { stdio: ["pipe", "pipe", "pipe"] });
35
+ execSync(`claude mcp add --transport stdio --scope user terminal -- ${bin} mcp serve`, { stdio: ["pipe", "pipe", "pipe"] });
36
+ log("✓", "Claude Code (updated)");
37
+ return true;
38
+ }
39
+ catch (e) {
40
+ log("✗", `Claude Code — ${e}`);
41
+ return false;
42
+ }
43
+ }
44
+ }
45
+ // ── Install for Codex ───────────────────────────────────────────────────────
46
+ function installCodex(bin) {
47
+ if (!hasCodex()) {
48
+ log("–", "Codex not found, skipping");
49
+ return false;
50
+ }
51
+ const dir = join(homedir(), ".codex");
52
+ const configPath = join(dir, "config.toml");
53
+ try {
54
+ if (!existsSync(dir))
55
+ mkdirSync(dir, { recursive: true });
56
+ let content = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
57
+ // Remove old entry if exists
58
+ content = content.replace(/\n?\[mcp_servers\.terminal\][^\[]*/g, "");
59
+ content += `\n[mcp_servers.terminal]\ncommand = "${bin}"\nargs = ["mcp", "serve"]\n`;
60
+ writeFileSync(configPath, content);
61
+ log("✓", "Codex");
62
+ return true;
63
+ }
64
+ catch (e) {
65
+ log("✗", `Codex — ${e}`);
66
+ return false;
67
+ }
68
+ }
69
+ // ── Install for Gemini ──────────────────────────────────────────────────────
70
+ function installGemini(bin) {
71
+ if (!hasGemini()) {
72
+ log("–", "Gemini CLI not found, skipping");
73
+ return false;
74
+ }
75
+ const dir = join(homedir(), ".gemini");
76
+ const configPath = join(dir, "settings.json");
77
+ try {
78
+ if (!existsSync(dir))
79
+ mkdirSync(dir, { recursive: true });
80
+ let config = {};
81
+ if (existsSync(configPath)) {
82
+ try {
83
+ config = JSON.parse(readFileSync(configPath, "utf8"));
84
+ }
85
+ catch { }
86
+ }
87
+ if (!config.mcpServers)
88
+ config.mcpServers = {};
89
+ config.mcpServers["terminal"] = { command: bin, args: ["mcp", "serve"] };
90
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
91
+ log("✓", "Gemini CLI");
92
+ return true;
93
+ }
94
+ catch (e) {
95
+ log("✗", `Gemini — ${e}`);
96
+ return false;
97
+ }
98
+ }
99
+ // ── Uninstall ───────────────────────────────────────────────────────────────
100
+ function uninstallClaude() {
101
+ if (!hasClaude())
102
+ return false;
103
+ try {
104
+ execSync(`claude mcp remove terminal -s user`, { stdio: ["pipe", "pipe", "pipe"] });
105
+ log("✓", "Removed from Claude Code");
106
+ return true;
107
+ }
108
+ catch {
109
+ return false;
110
+ }
111
+ }
112
+ function uninstallCodex() {
113
+ const configPath = join(homedir(), ".codex", "config.toml");
114
+ if (!existsSync(configPath))
115
+ return false;
116
+ try {
117
+ let content = readFileSync(configPath, "utf8");
118
+ if (!content.includes("terminal"))
119
+ return false;
120
+ content = content.replace(/\n?\[mcp_servers\.terminal\][^\[]*/g, "");
121
+ writeFileSync(configPath, content);
122
+ log("✓", "Removed from Codex");
123
+ return true;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ function uninstallGemini() {
130
+ const configPath = join(homedir(), ".gemini", "settings.json");
131
+ if (!existsSync(configPath))
132
+ return false;
133
+ try {
134
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
135
+ if (!config.mcpServers?.["terminal"])
136
+ return false;
137
+ delete config.mcpServers["terminal"];
138
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
139
+ log("✓", "Removed from Gemini CLI");
140
+ return true;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ // ── Main install handler ────────────────────────────────────────────────────
147
+ export function handleInstall(args) {
148
+ const flags = new Set(args);
149
+ // Uninstall
150
+ if (flags.has("uninstall") || flags.has("--uninstall")) {
151
+ console.log("\n Removing terminal MCP server...\n");
152
+ uninstallClaude();
153
+ uninstallCodex();
154
+ uninstallGemini();
155
+ console.log("\n Done. Restart your agents to apply.\n");
156
+ return;
157
+ }
158
+ // Targeted install
159
+ if (flags.has("--claude") || flags.has("--codex") || flags.has("--gemini")) {
160
+ const bin = which("terminal") ?? which("t") ?? "npx @hasna/terminal";
161
+ console.log("");
162
+ if (flags.has("--claude"))
163
+ installClaude(bin);
164
+ if (flags.has("--codex"))
165
+ installCodex(bin);
166
+ if (flags.has("--gemini"))
167
+ installGemini(bin);
168
+ console.log("");
169
+ return;
170
+ }
171
+ // ── Default: install everything ─────────────────────────────────────────
172
+ const bin = which("terminal") ?? which("t") ?? "npx @hasna/terminal";
173
+ console.log(`\n terminal — setting up MCP...\n`);
174
+ let count = 0;
175
+ if (installClaude(bin))
176
+ count++;
177
+ if (installCodex(bin))
178
+ count++;
179
+ if (installGemini(bin))
180
+ count++;
181
+ if (count === 0) {
182
+ console.log(`\n No agents found. Install Claude Code, Codex, or Gemini CLI first.\n`);
183
+ }
184
+ else {
185
+ console.log(`\n ${count} agent${count > 1 ? "s" : ""} ready. Restart to apply.\n`);
186
+ }
187
+ }
188
+ // Re-export individual installers for programmatic use
189
+ export { installClaude, installCodex, installGemini, uninstallClaude, uninstallCodex, uninstallGemini };
@@ -0,0 +1,56 @@
1
+ // MCP Server for terminal — exposes terminal capabilities to AI agents
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { createSession } from "../sessions-db.js";
5
+ import { createHelpers } from "./tools/helpers.js";
6
+ // Tool registration modules
7
+ import { registerExecuteTools } from "./tools/execute.js";
8
+ import { registerGitTools } from "./tools/git.js";
9
+ import { registerSearchTools } from "./tools/search.js";
10
+ import { registerFileTools } from "./tools/files.js";
11
+ import { registerProjectTools } from "./tools/project.js";
12
+ import { registerProcessTools } from "./tools/process.js";
13
+ import { registerBatchTools } from "./tools/batch.js";
14
+ import { registerMemoryTools } from "./tools/memory.js";
15
+ import { registerMetaTools } from "./tools/meta.js";
16
+ import { registerCloudTools } from "@hasna/cloud";
17
+ // ── server ───────────────────────────────────────────────────────────────────
18
+ export function createServer() {
19
+ const server = new McpServer({
20
+ name: "terminal",
21
+ version: "4.2.0",
22
+ });
23
+ // Create a session for this MCP server instance
24
+ const sessionId = createSession(process.cwd(), "mcp");
25
+ // ── Mementos: cross-session project memory ────────────────────────────────
26
+ try {
27
+ const mementos = require("@hasna/mementos");
28
+ const projectName = process.cwd().split("/").pop() ?? "unknown";
29
+ const project = mementos.registerProject(projectName, process.cwd());
30
+ const mementosProjectId = project?.id ?? null;
31
+ mementos.registerAgent("terminal-mcp");
32
+ if (mementosProjectId)
33
+ mementos.setFocus(mementosProjectId);
34
+ }
35
+ catch { } // mementos optional — works without it
36
+ // Create shared helpers and register all tool groups
37
+ const h = createHelpers(sessionId);
38
+ registerExecuteTools(server, h);
39
+ registerGitTools(server, h);
40
+ registerSearchTools(server, h);
41
+ registerFileTools(server, h);
42
+ registerProjectTools(server, h);
43
+ registerProcessTools(server, h);
44
+ registerBatchTools(server, h);
45
+ registerMemoryTools(server, h);
46
+ registerMetaTools(server, h);
47
+ registerCloudTools(server, "terminal");
48
+ return server;
49
+ }
50
+ // ── main: start MCP server via stdio ─────────────────────────────────────────
51
+ export async function startMcpServer() {
52
+ const server = createServer();
53
+ const transport = new StdioServerTransport();
54
+ await server.connect(transport);
55
+ console.error("terminal MCP server running on stdio");
56
+ }
@@ -0,0 +1,111 @@
1
+ // Batch tools: batch
2
+ import { z } from "./helpers.js";
3
+ import { stripAnsi } from "../../compression.js";
4
+ import { processOutput } from "../../output-processor.js";
5
+ import { getOutputProvider } from "../../providers/index.js";
6
+ import { searchContent } from "../../search/index.js";
7
+ import { cachedRead } from "../../file-cache.js";
8
+ export function registerBatchTools(server, h) {
9
+ server.tool("batch", "Run multiple operations in ONE call. Saves N-1 round trips. Each op can be: execute (run command), read (file read/summarize), search (grep pattern), or symbols (file outline).", {
10
+ ops: z.array(z.object({
11
+ type: z.enum(["execute", "read", "write", "search", "symbols"]).describe("Operation type"),
12
+ command: z.string().optional().describe("Shell command (for execute)"),
13
+ path: z.string().optional().describe("File path (for read/write/symbols)"),
14
+ content: z.string().optional().describe("File content (for write)"),
15
+ pattern: z.string().optional().describe("Search pattern (for search)"),
16
+ summarize: z.boolean().optional().describe("AI summarize (for read)"),
17
+ format: z.enum(["raw", "summary"]).optional().describe("Output format (for execute)"),
18
+ })).describe("Array of operations to run"),
19
+ cwd: z.string().optional().describe("Working directory for all ops"),
20
+ }, async ({ ops, cwd }) => {
21
+ const start = Date.now();
22
+ const workDir = cwd ?? process.cwd();
23
+ const results = [];
24
+ for (let i = 0; i < ops.slice(0, 10).length; i++) {
25
+ const op = ops[i];
26
+ try {
27
+ if (op.type === "execute" && op.command) {
28
+ const result = await h.exec(op.command, workDir, 30000);
29
+ const output = (result.stdout + result.stderr).trim();
30
+ if (op.format === "summary" && output.split("\n").length > 15) {
31
+ const processed = await processOutput(op.command, output);
32
+ results.push({ op: i, type: "execute", summary: processed.summary, exitCode: result.exitCode, tokensSaved: processed.tokensSaved });
33
+ }
34
+ else {
35
+ results.push({ op: i, type: "execute", output: stripAnsi(output).slice(0, 2000), exitCode: result.exitCode });
36
+ }
37
+ }
38
+ else if (op.type === "read" && op.path) {
39
+ const filePath = h.resolvePath(op.path, workDir);
40
+ const result = cachedRead(filePath, {});
41
+ if (op.summarize && result.content.length > 500) {
42
+ const provider = getOutputProvider();
43
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
44
+ const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
45
+ const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
46
+ model: outputModel,
47
+ system: `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose. Be specific.`,
48
+ maxTokens: 300, temperature: 0.2,
49
+ });
50
+ results.push({ op: i, type: "read", path: op.path, summary, lines: result.content.split("\n").length });
51
+ }
52
+ else {
53
+ results.push({ op: i, type: "read", path: op.path, content: result.content, lines: result.content.split("\n").length });
54
+ }
55
+ }
56
+ else if (op.type === "write" && op.path && op.content !== undefined) {
57
+ const filePath = h.resolvePath(op.path, workDir);
58
+ const { writeFileSync, mkdirSync, existsSync } = await import("fs");
59
+ const { dirname } = await import("path");
60
+ const dir = dirname(filePath);
61
+ if (!existsSync(dir))
62
+ mkdirSync(dir, { recursive: true });
63
+ writeFileSync(filePath, op.content);
64
+ results.push({ op: i, type: "write", path: op.path, ok: true, bytes: op.content.length });
65
+ }
66
+ else if (op.type === "search" && op.pattern) {
67
+ // Search accepts both files and directories — resolve to parent dir if file
68
+ let searchPath = op.path ? h.resolvePath(op.path, workDir) : workDir;
69
+ try {
70
+ const { statSync } = await import("fs");
71
+ if (statSync(searchPath).isFile())
72
+ searchPath = searchPath.replace(/\/[^/]+$/, "");
73
+ }
74
+ catch { }
75
+ const result = await searchContent(op.pattern, searchPath, {});
76
+ results.push({ op: i, type: "search", pattern: op.pattern, totalMatches: result.totalMatches, files: result.files.slice(0, 10) });
77
+ }
78
+ else if (op.type === "symbols" && op.path) {
79
+ const filePath = h.resolvePath(op.path, workDir);
80
+ const result = cachedRead(filePath, {});
81
+ if (result.content && !result.content.startsWith("Error:")) {
82
+ const provider = getOutputProvider();
83
+ const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
84
+ const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
85
+ const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
86
+ model: outputModel,
87
+ system: `Extract all symbols. Return ONLY a JSON array. Each: {"name":"x","kind":"function|class|method|interface|type","line":N,"signature":"brief"}. For class methods use "Class.method". Exclude imports.`,
88
+ maxTokens: 2000, temperature: 0,
89
+ });
90
+ let symbols = [];
91
+ try {
92
+ const m = summary.match(/\[[\s\S]*\]/);
93
+ if (m)
94
+ symbols = JSON.parse(m[0]);
95
+ }
96
+ catch { }
97
+ results.push({ op: i, type: "symbols", path: op.path, symbols });
98
+ }
99
+ else {
100
+ results.push({ op: i, type: "symbols", path: op.path, error: "Cannot read file" });
101
+ }
102
+ }
103
+ }
104
+ catch (err) {
105
+ results.push({ op: i, type: op.type, error: err.message?.slice(0, 200) });
106
+ }
107
+ }
108
+ h.logCall("batch", { command: `${ops.length} ops`, durationMs: Date.now() - start, aiProcessed: true });
109
+ return { content: [{ type: "text", text: JSON.stringify({ results, total: results.length, durationMs: Date.now() - start }) }] };
110
+ });
111
+ }