@hasna/terminal 4.3.1 → 4.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.js +404 -0
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/Onboarding.js +51 -0
- package/dist/Spinner.js +12 -0
- package/dist/StatusBar.js +49 -0
- package/dist/ai.js +316 -0
- package/dist/cache.js +42 -0
- package/dist/cli.js +778 -0
- package/dist/command-rewriter.js +64 -0
- package/dist/command-validator.js +86 -0
- package/dist/compression.js +91 -0
- package/dist/context-hints.js +285 -0
- package/dist/db/pg-migrations.js +70 -0
- package/dist/diff-cache.js +107 -0
- package/dist/discover.js +212 -0
- package/dist/economy.js +155 -0
- package/dist/expand-store.js +44 -0
- package/dist/file-cache.js +72 -0
- package/dist/file-index.js +62 -0
- package/dist/history.js +62 -0
- package/dist/lazy-executor.js +54 -0
- package/dist/line-dedup.js +59 -0
- package/dist/loop-detector.js +75 -0
- package/dist/mcp/install.js +189 -0
- package/dist/mcp/server.js +90 -0
- package/dist/mcp/tools/batch.js +111 -0
- package/dist/mcp/tools/execute.js +194 -0
- package/dist/mcp/tools/files.js +290 -0
- package/dist/mcp/tools/git.js +233 -0
- package/dist/mcp/tools/helpers.js +63 -0
- package/dist/mcp/tools/memory.js +151 -0
- package/dist/mcp/tools/meta.js +138 -0
- package/dist/mcp/tools/process.js +50 -0
- package/dist/mcp/tools/project.js +251 -0
- package/dist/mcp/tools/search.js +86 -0
- package/dist/noise-filter.js +94 -0
- package/dist/output-processor.js +233 -0
- package/dist/output-store.js +112 -0
- package/dist/paths.js +28 -0
- package/dist/providers/anthropic.js +43 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +8 -0
- package/dist/providers/groq.js +8 -0
- package/dist/providers/index.js +142 -0
- package/dist/providers/openai-compat.js +93 -0
- package/dist/providers/xai.js +8 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/storage.js +153 -0
- package/dist/search/content-search.js +70 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +5 -0
- package/dist/search/semantic.js +346 -0
- package/dist/session-boot.js +59 -0
- package/dist/session-context.js +55 -0
- package/dist/sessions-db.js +240 -0
- package/dist/smart-display.js +286 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/test-watchlist.js +131 -0
- package/dist/tokens.js +17 -0
- package/dist/tool-profiles.js +130 -0
- package/dist/tree.js +94 -0
- package/dist/usage-cache.js +65 -0
- package/package.json +2 -1
- package/src/Onboarding.tsx +1 -1
- package/src/ai.ts +5 -4
- package/src/cache.ts +2 -2
- package/src/db/pg-migrations.ts +77 -0
- package/src/economy.ts +3 -3
- package/src/history.ts +2 -2
- package/src/mcp/server.ts +55 -0
- package/src/mcp/tools/memory.ts +4 -2
- package/src/output-store.ts +2 -1
- package/src/paths.ts +32 -0
- package/src/recipes/storage.ts +3 -3
- package/src/session-context.ts +2 -2
- package/src/sessions-db.ts +15 -4
- package/src/tool-profiles.ts +4 -3
- package/src/usage-cache.ts +2 -2
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Execute tools: execute, execute_smart, execute_diff, expand, browse, explain_error
|
|
2
|
+
import { z } from "./helpers.js";
|
|
3
|
+
import { compress, stripAnsi } from "../../compression.js";
|
|
4
|
+
import { estimateTokens } from "../../tokens.js";
|
|
5
|
+
import { processOutput } from "../../output-processor.js";
|
|
6
|
+
import { storeOutput, expandOutput } from "../../expand-store.js";
|
|
7
|
+
import { shouldBeLazy, toLazy } from "../../lazy-executor.js";
|
|
8
|
+
import { diffOutput } from "../../diff-cache.js";
|
|
9
|
+
import { recordSaving } from "../../economy.js";
|
|
10
|
+
export function registerExecuteTools(server, h) {
|
|
11
|
+
// ── execute: run a command, return structured result ──────────────────────
|
|
12
|
+
server.tool("execute", "Run a shell command. Format guide: no format/raw for git commit/push (<50 tokens). format=compressed for long build output (CPU-only, no AI). format=json or format=summary for AI-summarized output (234ms, saves 80% tokens). Prefer execute_smart for most tasks.", {
|
|
13
|
+
command: z.string().describe("Shell command to execute"),
|
|
14
|
+
cwd: z.string().optional().describe("Working directory (default: server cwd)"),
|
|
15
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
16
|
+
format: z.enum(["raw", "json", "compressed", "summary"]).optional().describe("Output format"),
|
|
17
|
+
maxTokens: z.number().optional().describe("Token budget for compressed/summary format"),
|
|
18
|
+
}, async ({ command, cwd, timeout, format, maxTokens }) => {
|
|
19
|
+
const start = Date.now();
|
|
20
|
+
const result = await h.exec(command, cwd, timeout ?? 30000);
|
|
21
|
+
const output = (result.stdout + result.stderr).trim();
|
|
22
|
+
// Raw mode — with lazy execution for large results
|
|
23
|
+
if (!format || format === "raw") {
|
|
24
|
+
const clean = stripAnsi(output);
|
|
25
|
+
if (shouldBeLazy(clean, command)) {
|
|
26
|
+
const lazy = toLazy(clean, command);
|
|
27
|
+
const detailKey = storeOutput(command, clean);
|
|
28
|
+
h.logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
31
|
+
exitCode: result.exitCode, ...lazy, detailKey, duration: result.duration,
|
|
32
|
+
...(result.rewritten ? { rewrittenFrom: command } : {}),
|
|
33
|
+
}) }],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
h.logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
37
|
+
return {
|
|
38
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
39
|
+
exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
|
|
40
|
+
...(result.rewritten ? { rewrittenFrom: command } : {}),
|
|
41
|
+
}) }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
// JSON and Summary modes — both go through AI processing
|
|
45
|
+
if (format === "json" || format === "summary") {
|
|
46
|
+
try {
|
|
47
|
+
const processed = await processOutput(command, output);
|
|
48
|
+
const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
|
|
49
|
+
h.logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
52
|
+
exitCode: result.exitCode,
|
|
53
|
+
summary: processed.summary,
|
|
54
|
+
structured: processed.structured,
|
|
55
|
+
duration: result.duration,
|
|
56
|
+
tokensSaved: processed.tokensSaved,
|
|
57
|
+
aiProcessed: processed.aiProcessed,
|
|
58
|
+
...(detailKey ? { detailKey, expandable: true } : {}),
|
|
59
|
+
}) }],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
const compressed = compress(command, output, { maxTokens });
|
|
64
|
+
h.logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: compressed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
67
|
+
exitCode: result.exitCode, output: compressed.content, duration: result.duration,
|
|
68
|
+
tokensSaved: compressed.tokensSaved,
|
|
69
|
+
}) }],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Compressed mode — fast non-AI: strip + dedup + truncate
|
|
74
|
+
if (format === "compressed") {
|
|
75
|
+
const compressed = compress(command, output, { maxTokens });
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
78
|
+
exitCode: result.exitCode, output: compressed.content, duration: result.duration,
|
|
79
|
+
...(compressed.tokensSaved > 0 ? { tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent } : {}),
|
|
80
|
+
}) }],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { content: [{ type: "text", text: output }] };
|
|
84
|
+
});
|
|
85
|
+
// ── execute_smart: AI-powered output processing ────────────────────────────
|
|
86
|
+
server.tool("execute_smart", "Run a command and get AI-summarized output (80-95% token savings). Use this for: test runs, builds, git operations, process management, system info. Do NOT use for file read/write — use your native Read/Write/Edit tools instead (they're faster, no shell overhead).", {
|
|
87
|
+
command: z.string().describe("Shell command to execute"),
|
|
88
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
89
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
90
|
+
verbosity: z.enum(["minimal", "normal", "detailed"]).optional().describe("Summary detail level (default: normal)"),
|
|
91
|
+
}, async ({ command, cwd, timeout, verbosity }) => {
|
|
92
|
+
const start = Date.now();
|
|
93
|
+
const result = await h.exec(command, cwd, timeout ?? 30000, true);
|
|
94
|
+
const output = (result.stdout + result.stderr).trim();
|
|
95
|
+
const processed = await processOutput(command, output, undefined, verbosity);
|
|
96
|
+
const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
|
|
97
|
+
h.logCall("execute_smart", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
|
|
98
|
+
return {
|
|
99
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
100
|
+
exitCode: result.exitCode,
|
|
101
|
+
summary: processed.summary,
|
|
102
|
+
structured: processed.structured,
|
|
103
|
+
duration: result.duration,
|
|
104
|
+
totalLines: output.split("\n").length,
|
|
105
|
+
tokensSaved: processed.tokensSaved,
|
|
106
|
+
aiProcessed: processed.aiProcessed,
|
|
107
|
+
...(detailKey ? { detailKey, expandable: true } : {}),
|
|
108
|
+
}) }],
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
// ── execute_diff: run command with diff from last run ───────────────────────
|
|
112
|
+
server.tool("execute_diff", "Run a command and return diff from its last execution. Ideal for edit→test loops — only shows what changed.", {
|
|
113
|
+
command: z.string().describe("Shell command to execute"),
|
|
114
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
115
|
+
timeout: z.number().optional().describe("Timeout in ms"),
|
|
116
|
+
}, async ({ command, cwd, timeout }) => {
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
const workDir = cwd ?? process.cwd();
|
|
119
|
+
const result = await h.exec(command, workDir, timeout ?? 30000);
|
|
120
|
+
const output = (result.stdout + result.stderr).trim();
|
|
121
|
+
const diff = diffOutput(command, workDir, output);
|
|
122
|
+
if (diff.tokensSaved > 0) {
|
|
123
|
+
recordSaving("diff", diff.tokensSaved);
|
|
124
|
+
}
|
|
125
|
+
h.logCall("execute_diff", { command, outputTokens: estimateTokens(output), tokensSaved: diff.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
|
|
126
|
+
if (diff.unchanged) {
|
|
127
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
128
|
+
exitCode: result.exitCode, unchanged: true, diffSummary: diff.diffSummary,
|
|
129
|
+
duration: result.duration, tokensSaved: diff.tokensSaved,
|
|
130
|
+
}) }] };
|
|
131
|
+
}
|
|
132
|
+
if (diff.hasPrevious) {
|
|
133
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
134
|
+
exitCode: result.exitCode, diffSummary: diff.diffSummary,
|
|
135
|
+
added: diff.added.slice(0, 50), removed: diff.removed.slice(0, 50),
|
|
136
|
+
duration: result.duration, tokensSaved: diff.tokensSaved,
|
|
137
|
+
}) }] };
|
|
138
|
+
}
|
|
139
|
+
// First run — return full output (ANSI stripped)
|
|
140
|
+
const clean = stripAnsi(output);
|
|
141
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
142
|
+
exitCode: result.exitCode, output: clean,
|
|
143
|
+
diffSummary: "first run", duration: result.duration,
|
|
144
|
+
}) }] };
|
|
145
|
+
});
|
|
146
|
+
// ── expand: retrieve full output on demand ────────────────────────────────
|
|
147
|
+
server.tool("expand", "Retrieve full output from a previous execute_smart call. Only call this when you need details (e.g., to see failing test errors). Use the detailKey from execute_smart response.", {
|
|
148
|
+
key: z.string().describe("The detailKey from a previous execute_smart response"),
|
|
149
|
+
grep: z.string().optional().describe("Filter output lines by pattern (e.g., 'FAIL', 'error')"),
|
|
150
|
+
}, async ({ key, grep }) => {
|
|
151
|
+
const result = expandOutput(key, grep);
|
|
152
|
+
if (!result.found) {
|
|
153
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Output expired or not found" }) }] };
|
|
154
|
+
}
|
|
155
|
+
return { content: [{ type: "text", text: JSON.stringify({ output: result.output, lines: result.lines }) }] };
|
|
156
|
+
});
|
|
157
|
+
// ── browse: list files/dirs as structured JSON ────────────────────────────
|
|
158
|
+
server.tool("browse", "List files and directories as structured JSON. Auto-filters node_modules, .git, dist by default.", {
|
|
159
|
+
path: z.string().optional().describe("Directory path (default: cwd)"),
|
|
160
|
+
recursive: z.boolean().optional().describe("List recursively (default: false)"),
|
|
161
|
+
maxDepth: z.number().optional().describe("Max depth for recursive listing (default: 2)"),
|
|
162
|
+
includeHidden: z.boolean().optional().describe("Include hidden files (default: false)"),
|
|
163
|
+
}, async ({ path, recursive, maxDepth, includeHidden }) => {
|
|
164
|
+
const target = path ?? process.cwd();
|
|
165
|
+
const depth = maxDepth ?? 2;
|
|
166
|
+
let command;
|
|
167
|
+
if (recursive) {
|
|
168
|
+
command = `find "${target}" -maxdepth ${depth} -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/.next/*'`;
|
|
169
|
+
if (!includeHidden)
|
|
170
|
+
command += " -not -name '.*'";
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
command = includeHidden ? `ls -la "${target}"` : `ls -l "${target}"`;
|
|
174
|
+
}
|
|
175
|
+
const result = await h.exec(command);
|
|
176
|
+
const files = result.stdout.split("\n").filter(l => l.trim());
|
|
177
|
+
return { content: [{ type: "text", text: JSON.stringify({ cwd: target, files, count: files.length }) }] };
|
|
178
|
+
});
|
|
179
|
+
// ── explain_error: structured error diagnosis ─────────────────────────────
|
|
180
|
+
server.tool("explain_error", "Parse error output and return structured diagnosis with root cause and fix suggestion.", {
|
|
181
|
+
error: z.string().describe("Error output text"),
|
|
182
|
+
command: z.string().optional().describe("The command that produced the error"),
|
|
183
|
+
}, async ({ error, command }) => {
|
|
184
|
+
// AI processes the error — no regex guessing
|
|
185
|
+
const processed = await processOutput(command ?? "unknown", error);
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
188
|
+
summary: processed.summary,
|
|
189
|
+
structured: processed.structured,
|
|
190
|
+
aiProcessed: processed.aiProcessed,
|
|
191
|
+
}) }],
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// File tools: read_file, read_files, symbols, symbols_dir, read_symbol, edit, review
|
|
2
|
+
import { z } from "./helpers.js";
|
|
3
|
+
import { estimateTokens } from "../../tokens.js";
|
|
4
|
+
import { getOutputProvider } from "../../providers/index.js";
|
|
5
|
+
import { cachedRead } from "../../file-cache.js";
|
|
6
|
+
export function registerFileTools(server, h) {
|
|
7
|
+
// ── read_file ─────────────────────────────────────────────────────────────
|
|
8
|
+
server.tool("read_file", "Read a file with summarize=true for AI outline (~90% fewer tokens). For full file reads without summarization, prefer your native Read tool (faster, no MCP overhead). Use this when you want cached reads or AI summaries.", {
|
|
9
|
+
path: z.string().describe("File path"),
|
|
10
|
+
offset: z.number().optional().describe("Start line (0-indexed)"),
|
|
11
|
+
limit: z.number().optional().describe("Max lines to return"),
|
|
12
|
+
summarize: z.boolean().optional().describe("Return AI summary instead of full content (saves ~90% tokens)"),
|
|
13
|
+
focus: z.string().optional().describe("Focus hint for summary (e.g., 'public API', 'error handling', 'auth logic')"),
|
|
14
|
+
}, async ({ path: rawPath, offset, limit, summarize, focus }) => {
|
|
15
|
+
const start = Date.now();
|
|
16
|
+
const path = h.resolvePath(rawPath);
|
|
17
|
+
const result = cachedRead(path, { offset, limit });
|
|
18
|
+
if (summarize && result.content.length > 500) {
|
|
19
|
+
const provider = getOutputProvider();
|
|
20
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
21
|
+
const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
|
|
22
|
+
const focusInstruction = focus
|
|
23
|
+
? `Focus specifically on: ${focus}. Describe only aspects related to "${focus}".`
|
|
24
|
+
: `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose.`;
|
|
25
|
+
const summary = await provider.complete(`File: ${path}\n\n${content}`, {
|
|
26
|
+
model: outputModel,
|
|
27
|
+
system: `${focusInstruction} Be specific — name the actual functions and what they do. Never just say "N lines of code."`,
|
|
28
|
+
maxTokens: 300,
|
|
29
|
+
temperature: 0.2,
|
|
30
|
+
});
|
|
31
|
+
const outputTokens = estimateTokens(result.content);
|
|
32
|
+
const summaryTokens = estimateTokens(summary);
|
|
33
|
+
const saved = Math.max(0, outputTokens - summaryTokens);
|
|
34
|
+
h.logCall("read_file", { command: path, outputTokens, tokensSaved: saved, durationMs: Date.now() - start, aiProcessed: true });
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
37
|
+
summary,
|
|
38
|
+
lines: result.content.split("\n").length,
|
|
39
|
+
tokensSaved: saved,
|
|
40
|
+
cached: result.cached,
|
|
41
|
+
}) }],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
h.logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: 0, durationMs: Date.now() - start });
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
47
|
+
content: result.content,
|
|
48
|
+
cached: result.cached,
|
|
49
|
+
readCount: result.readCount,
|
|
50
|
+
...(result.cached ? { note: `Served from cache (read #${result.readCount})` } : {}),
|
|
51
|
+
}) }],
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
// ── read_files ────────────────────────────────────────────────────────────
|
|
55
|
+
server.tool("read_files", "Read multiple files in one call. Use summarize=true for AI outlines (~90% fewer tokens per file). Saves N-1 round trips vs separate read_file calls.", {
|
|
56
|
+
files: z.array(z.string()).describe("File paths (relative or absolute)"),
|
|
57
|
+
summarize: z.boolean().optional().describe("AI summary instead of full content"),
|
|
58
|
+
}, async ({ files, summarize }) => {
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
const results = {};
|
|
61
|
+
for (const f of files.slice(0, 10)) { // max 10 files per call
|
|
62
|
+
const filePath = h.resolvePath(f);
|
|
63
|
+
const result = cachedRead(filePath, {});
|
|
64
|
+
if (summarize && result.content.length > 500) {
|
|
65
|
+
const provider = getOutputProvider();
|
|
66
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
67
|
+
const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
|
|
68
|
+
const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
|
|
69
|
+
model: outputModel,
|
|
70
|
+
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.`,
|
|
71
|
+
maxTokens: 300, temperature: 0.2,
|
|
72
|
+
});
|
|
73
|
+
results[f] = { summary, lines: result.content.split("\n").length };
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
results[f] = { content: result.content, lines: result.content.split("\n").length };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
h.logCall("read_files", { command: `${files.length} files`, durationMs: Date.now() - start, aiProcessed: !!summarize });
|
|
80
|
+
return { content: [{ type: "text", text: JSON.stringify(results) }] };
|
|
81
|
+
});
|
|
82
|
+
// ── symbols ───────────────────────────────────────────────────────────────
|
|
83
|
+
server.tool("symbols", "Get a structured outline of any source file — functions, classes, methods, interfaces, exports with line numbers. Works for ALL languages (TypeScript, Python, Go, Rust, Java, C#, Ruby, PHP, etc.). AI-powered, not regex.", {
|
|
84
|
+
path: z.string().describe("File path to extract symbols from"),
|
|
85
|
+
}, async ({ path: rawPath }) => {
|
|
86
|
+
const start = Date.now();
|
|
87
|
+
const filePath = h.resolvePath(rawPath);
|
|
88
|
+
const result = cachedRead(filePath, {});
|
|
89
|
+
if (!result.content || result.content.startsWith("Error:")) {
|
|
90
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
|
|
91
|
+
}
|
|
92
|
+
// AI extracts symbols — works for ANY language
|
|
93
|
+
let symbols = [];
|
|
94
|
+
try {
|
|
95
|
+
const provider = getOutputProvider();
|
|
96
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
97
|
+
const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
|
|
98
|
+
const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
|
|
99
|
+
model: outputModel,
|
|
100
|
+
system: `Extract all symbols from this source file. Return ONLY a JSON array, no explanation.
|
|
101
|
+
|
|
102
|
+
Each symbol: {"name": "symbolName", "kind": "function|class|method|interface|type|variable|export", "line": lineNumber, "signature": "brief signature"}
|
|
103
|
+
|
|
104
|
+
For class methods, use "ClassName.methodName" as name with kind "method".
|
|
105
|
+
Include: functions, classes, methods, interfaces, types, exported constants.
|
|
106
|
+
Exclude: imports, local variables, comments.
|
|
107
|
+
Line numbers must be accurate (count from 1).`,
|
|
108
|
+
maxTokens: 2000,
|
|
109
|
+
temperature: 0,
|
|
110
|
+
});
|
|
111
|
+
const jsonMatch = summary.match(/\[[\s\S]*\]/);
|
|
112
|
+
if (jsonMatch)
|
|
113
|
+
symbols = JSON.parse(jsonMatch[0]);
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Surface the error instead of silently returning []
|
|
117
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `AI symbol extraction failed: ${err.message?.slice(0, 200)}`, file: filePath }) }] };
|
|
118
|
+
}
|
|
119
|
+
const outputTokens = estimateTokens(result.content);
|
|
120
|
+
const symbolTokens = estimateTokens(JSON.stringify(symbols));
|
|
121
|
+
h.logCall("symbols", { command: filePath, outputTokens, tokensSaved: Math.max(0, outputTokens - symbolTokens), durationMs: Date.now() - start, aiProcessed: true });
|
|
122
|
+
return {
|
|
123
|
+
content: [{ type: "text", text: JSON.stringify(symbols) }],
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
// ── symbols_dir ───────────────────────────────────────────────────────────
|
|
127
|
+
server.tool("symbols_dir", "Get symbols for all source files in a directory. AI-powered, works for any language. One call replaces N separate symbols calls.", {
|
|
128
|
+
path: z.string().optional().describe("Directory (default: src/)"),
|
|
129
|
+
maxFiles: z.number().optional().describe("Max files to scan (default: 10)"),
|
|
130
|
+
}, async ({ path: dirPath, maxFiles }) => {
|
|
131
|
+
const start = Date.now();
|
|
132
|
+
const dir = h.resolvePath(dirPath ?? "src/");
|
|
133
|
+
const limit = maxFiles ?? 10;
|
|
134
|
+
// Find source files
|
|
135
|
+
const findResult = await h.exec(`find "${dir}" -maxdepth 3 -type f \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.java" -o -name "*.rb" -o -name "*.php" \\) -not -path "*/node_modules/*" -not -path "*/dist/*" -not -name "*.test.*" -not -name "*.spec.*" | head -${limit}`, process.cwd(), 5000);
|
|
136
|
+
const files = findResult.stdout.split("\n").filter(l => l.trim());
|
|
137
|
+
const allSymbols = {};
|
|
138
|
+
const provider = getOutputProvider();
|
|
139
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
140
|
+
for (const file of files) {
|
|
141
|
+
const result = cachedRead(file, {});
|
|
142
|
+
if (!result.content || result.content.startsWith("Error:"))
|
|
143
|
+
continue;
|
|
144
|
+
try {
|
|
145
|
+
const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
|
|
146
|
+
const summary = await provider.complete(`File: ${file}\n\n${content}`, {
|
|
147
|
+
model: outputModel,
|
|
148
|
+
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.`,
|
|
149
|
+
maxTokens: 1500, temperature: 0,
|
|
150
|
+
});
|
|
151
|
+
const jsonMatch = summary.match(/\[[\s\S]*\]/);
|
|
152
|
+
if (jsonMatch)
|
|
153
|
+
allSymbols[file] = JSON.parse(jsonMatch[0]);
|
|
154
|
+
}
|
|
155
|
+
catch { }
|
|
156
|
+
}
|
|
157
|
+
h.logCall("symbols_dir", { command: `${files.length} files in ${dir}`, durationMs: Date.now() - start, aiProcessed: true });
|
|
158
|
+
return { content: [{ type: "text", text: JSON.stringify({ directory: dir, files: files.length, symbols: allSymbols }) }] };
|
|
159
|
+
});
|
|
160
|
+
// ── read_symbol ───────────────────────────────────────────────────────────
|
|
161
|
+
server.tool("read_symbol", "Read a specific function, class, or interface by name from a source file. Returns only the code block — not the entire file. Saves 70-85% tokens vs reading the whole file.", {
|
|
162
|
+
path: z.string().describe("Source file path"),
|
|
163
|
+
name: z.string().describe("Symbol name (function, class, interface)"),
|
|
164
|
+
}, async ({ path: rawPath, name }) => {
|
|
165
|
+
const start = Date.now();
|
|
166
|
+
const filePath = h.resolvePath(rawPath);
|
|
167
|
+
const result = cachedRead(filePath, {});
|
|
168
|
+
if (!result.content || result.content.startsWith("Error:")) {
|
|
169
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
|
|
170
|
+
}
|
|
171
|
+
// AI extracts the specific symbol — works for ANY language
|
|
172
|
+
const provider = getOutputProvider();
|
|
173
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
174
|
+
const summary = await provider.complete(`File: ${filePath}\nSymbol to extract: ${name}\n\n${result.content.slice(0, 8000)}`, {
|
|
175
|
+
model: outputModel,
|
|
176
|
+
system: `Extract the complete code block for the symbol "${name}" from this file. Return ONLY a JSON object:
|
|
177
|
+
{"name": "${name}", "code": "the complete code block", "startLine": N, "endLine": N}
|
|
178
|
+
|
|
179
|
+
If the symbol is not found, return: {"error": "not found", "available": ["list", "of", "symbol", "names"]}
|
|
180
|
+
|
|
181
|
+
Match by function name, class name, method name (including ClassName.method), interface, type, or variable name.`,
|
|
182
|
+
maxTokens: 2000,
|
|
183
|
+
temperature: 0,
|
|
184
|
+
});
|
|
185
|
+
let parsed = {};
|
|
186
|
+
try {
|
|
187
|
+
const jsonMatch = summary.match(/\{[\s\S]*\}/);
|
|
188
|
+
if (jsonMatch)
|
|
189
|
+
parsed = JSON.parse(jsonMatch[0]);
|
|
190
|
+
}
|
|
191
|
+
catch { }
|
|
192
|
+
h.logCall("read_symbol", { command: `${filePath}:${name}`, outputTokens: estimateTokens(result.content), tokensSaved: Math.max(0, estimateTokens(result.content) - estimateTokens(JSON.stringify(parsed))), durationMs: Date.now() - start, aiProcessed: true });
|
|
193
|
+
return { content: [{ type: "text", text: JSON.stringify(parsed) }] };
|
|
194
|
+
});
|
|
195
|
+
// ── edit ───────────────────────────────────────────────────────────────────
|
|
196
|
+
server.tool("edit", "Find and replace in a file. For simple edits, prefer your native Edit tool (faster). Use this for batch replacements (all=true) or when you don't have a native Edit tool available.", {
|
|
197
|
+
file: z.string().describe("File path"),
|
|
198
|
+
find: z.string().describe("Text to find (exact match)"),
|
|
199
|
+
replace: z.string().describe("Replacement text"),
|
|
200
|
+
all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
|
|
201
|
+
}, async ({ file: rawFile, find, replace, all }) => {
|
|
202
|
+
const start = Date.now();
|
|
203
|
+
const file = h.resolvePath(rawFile);
|
|
204
|
+
const { readFileSync, writeFileSync } = await import("fs");
|
|
205
|
+
try {
|
|
206
|
+
let content = readFileSync(file, "utf8");
|
|
207
|
+
const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
|
|
208
|
+
if (count === 0) {
|
|
209
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Text not found", file }) }] };
|
|
210
|
+
}
|
|
211
|
+
if (all) {
|
|
212
|
+
content = content.split(find).join(replace);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
content = content.replace(find, replace);
|
|
216
|
+
}
|
|
217
|
+
writeFileSync(file, content);
|
|
218
|
+
h.logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
|
|
219
|
+
return { content: [{ type: "text", text: JSON.stringify({ ok: true, file, replacements: all ? count : 1, diff: { removed: find.slice(0, 100), added: replace.slice(0, 100) } }) }] };
|
|
220
|
+
}
|
|
221
|
+
catch (e) {
|
|
222
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
// ── review ────────────────────────────────────────────────────────────────
|
|
226
|
+
server.tool("review", "AI code review of recent changes or specific files. Returns: bugs, security issues, suggestions. One call replaces git diff + manual reading.", {
|
|
227
|
+
since: z.string().optional().describe("Git ref to diff against (e.g., 'HEAD~3', 'main')"),
|
|
228
|
+
files: z.array(z.string()).optional().describe("Specific files to review"),
|
|
229
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
230
|
+
}, async ({ since, files, cwd }) => {
|
|
231
|
+
const start = Date.now();
|
|
232
|
+
const workDir = cwd ?? process.cwd();
|
|
233
|
+
let content;
|
|
234
|
+
if (files && files.length > 0) {
|
|
235
|
+
const fileContents = files.map(f => {
|
|
236
|
+
const result = cachedRead(h.resolvePath(f, workDir), {});
|
|
237
|
+
return `=== ${f} ===\n${result.content.slice(0, 4000)}`;
|
|
238
|
+
});
|
|
239
|
+
content = fileContents.join("\n\n");
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const ref = since ?? "HEAD~1";
|
|
243
|
+
const diff = await h.exec(`git diff ${ref} --no-color`, workDir, 15000);
|
|
244
|
+
content = diff.stdout.slice(0, 12000);
|
|
245
|
+
}
|
|
246
|
+
const provider = getOutputProvider();
|
|
247
|
+
const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
|
|
248
|
+
const review = await provider.complete(`Review this code:\n\n${content}`, {
|
|
249
|
+
model: outputModel,
|
|
250
|
+
system: `You are a senior code reviewer. Review concisely:
|
|
251
|
+
- Bugs or logic errors
|
|
252
|
+
- Security issues (injection, auth, secrets)
|
|
253
|
+
- Missing error handling
|
|
254
|
+
- Performance concerns
|
|
255
|
+
- Style/naming issues (only if significant)
|
|
256
|
+
|
|
257
|
+
Format: list issues as "- [severity] file:line description". If clean, say "No issues found."
|
|
258
|
+
Be specific, not generic. Only flag real problems.`,
|
|
259
|
+
maxTokens: 800, temperature: 0.2,
|
|
260
|
+
});
|
|
261
|
+
h.logCall("review", { command: `review ${since ?? files?.join(",") ?? "HEAD~1"}`, durationMs: Date.now() - start, aiProcessed: true });
|
|
262
|
+
return { content: [{ type: "text", text: JSON.stringify({ review, scope: since ?? files }) }] };
|
|
263
|
+
});
|
|
264
|
+
// ── write_files ─────────────────────────────────────────────────────────
|
|
265
|
+
server.tool("write_files", "Write multiple files in one call. Auto-creates parent directories. Saves N-1 round trips vs separate writes.", {
|
|
266
|
+
files: z.array(z.object({
|
|
267
|
+
path: z.string().describe("File path (relative or absolute)"),
|
|
268
|
+
content: z.string().describe("File content"),
|
|
269
|
+
})).describe("Files to write"),
|
|
270
|
+
}, async ({ files }) => {
|
|
271
|
+
const { writeFileSync, mkdirSync, existsSync } = await import("fs");
|
|
272
|
+
const { dirname } = await import("path");
|
|
273
|
+
const results = [];
|
|
274
|
+
for (const f of files.slice(0, 20)) {
|
|
275
|
+
try {
|
|
276
|
+
const filePath = h.resolvePath(f.path);
|
|
277
|
+
const dir = dirname(filePath);
|
|
278
|
+
if (!existsSync(dir))
|
|
279
|
+
mkdirSync(dir, { recursive: true });
|
|
280
|
+
writeFileSync(filePath, f.content);
|
|
281
|
+
results.push({ path: f.path, ok: true, bytes: f.content.length });
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
results.push({ path: f.path, ok: false, error: e.message?.slice(0, 100) });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
h.logCall("write_files", { command: `${files.length} files` });
|
|
288
|
+
return { content: [{ type: "text", text: JSON.stringify({ written: results.filter(r => r.ok).length, total: results.length, results }) }] };
|
|
289
|
+
});
|
|
290
|
+
}
|