@hasna/terminal 4.3.0 → 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 (58) hide show
  1. package/dist/Onboarding.js +1 -1
  2. package/dist/ai.js +9 -8
  3. package/dist/cache.js +2 -2
  4. package/dist/cli.js +0 -0
  5. package/dist/economy.js +3 -3
  6. package/dist/history.js +2 -2
  7. package/dist/mcp/server.js +26 -1345
  8. package/dist/mcp/tools/batch.js +111 -0
  9. package/dist/mcp/tools/execute.js +194 -0
  10. package/dist/mcp/tools/files.js +290 -0
  11. package/dist/mcp/tools/git.js +233 -0
  12. package/dist/mcp/tools/helpers.js +63 -0
  13. package/dist/mcp/tools/memory.js +151 -0
  14. package/dist/mcp/tools/meta.js +138 -0
  15. package/dist/mcp/tools/process.js +50 -0
  16. package/dist/mcp/tools/project.js +251 -0
  17. package/dist/mcp/tools/search.js +86 -0
  18. package/dist/output-store.js +2 -1
  19. package/dist/paths.js +28 -0
  20. package/dist/recipes/storage.js +3 -3
  21. package/dist/session-context.js +2 -2
  22. package/dist/sessions-db.js +15 -6
  23. package/dist/snapshots.js +2 -2
  24. package/dist/tool-profiles.js +4 -3
  25. package/dist/usage-cache.js +2 -2
  26. package/package.json +5 -3
  27. package/src/Onboarding.tsx +1 -1
  28. package/src/ai.ts +9 -8
  29. package/src/cache.ts +2 -2
  30. package/src/economy.ts +3 -3
  31. package/src/history.ts +2 -2
  32. package/src/mcp/server.ts +28 -1704
  33. package/src/mcp/tools/batch.ts +106 -0
  34. package/src/mcp/tools/execute.ts +248 -0
  35. package/src/mcp/tools/files.ts +369 -0
  36. package/src/mcp/tools/git.ts +306 -0
  37. package/src/mcp/tools/helpers.ts +92 -0
  38. package/src/mcp/tools/memory.ts +172 -0
  39. package/src/mcp/tools/meta.ts +202 -0
  40. package/src/mcp/tools/process.ts +94 -0
  41. package/src/mcp/tools/project.ts +297 -0
  42. package/src/mcp/tools/search.ts +118 -0
  43. package/src/output-store.ts +2 -1
  44. package/src/paths.ts +32 -0
  45. package/src/recipes/storage.ts +3 -3
  46. package/src/session-context.ts +2 -2
  47. package/src/sessions-db.ts +15 -4
  48. package/src/snapshots.ts +2 -2
  49. package/src/tool-profiles.ts +4 -3
  50. package/src/usage-cache.ts +2 -2
  51. package/dist/output-router.js +0 -41
  52. package/dist/parsers/base.js +0 -2
  53. package/dist/parsers/build.js +0 -64
  54. package/dist/parsers/errors.js +0 -101
  55. package/dist/parsers/files.js +0 -78
  56. package/dist/parsers/git.js +0 -99
  57. package/dist/parsers/index.js +0 -48
  58. package/dist/parsers/tests.js +0 -89
@@ -1,68 +1,19 @@
1
1
  // MCP Server for terminal — exposes terminal capabilities to AI agents
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { z } from "zod";
5
- import { spawn } from "child_process";
6
- import { compress, stripAnsi } from "../compression.js";
7
- import { stripNoise } from "../noise-filter.js";
8
- import { estimateTokens } from "../tokens.js";
9
- import { processOutput } from "../output-processor.js";
10
- import { getOutputProvider } from "../providers/index.js";
11
- import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
12
- import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
13
- import { substituteVariables } from "../recipes/model.js";
14
- import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
15
- import { diffOutput } from "../diff-cache.js";
16
- import { createSession, logInteraction, listSessions, getSessionInteractions, getSessionStats, getSessionEconomy } from "../sessions-db.js";
17
- import { cachedRead } from "../file-cache.js";
18
- import { getBootContext, invalidateBootCache } from "../session-boot.js";
19
- import { storeOutput, expandOutput } from "../expand-store.js";
20
- import { rewriteCommand } from "../command-rewriter.js";
21
- import { shouldBeLazy, toLazy } from "../lazy-executor.js";
22
- import { getEconomyStats, recordSaving } from "../economy.js";
23
- import { captureSnapshot } from "../snapshots.js";
24
- // ── helpers ──────────────────────────────────────────────────────────────────
25
- function exec(command, cwd, timeout, allowRewrite = false) {
26
- // Only rewrite when explicitly allowed (execute_smart, not raw execute)
27
- const rw = allowRewrite ? rewriteCommand(command) : { changed: false, rewritten: command };
28
- const actualCommand = rw.changed ? rw.rewritten : command;
29
- return new Promise((resolve) => {
30
- const start = Date.now();
31
- const proc = spawn("/bin/zsh", ["-c", actualCommand], {
32
- cwd: cwd ?? process.cwd(),
33
- stdio: ["ignore", "pipe", "pipe"],
34
- });
35
- let stdout = "";
36
- let stderr = "";
37
- proc.stdout?.on("data", (d) => { stdout += d.toString(); });
38
- proc.stderr?.on("data", (d) => { stderr += d.toString(); });
39
- const timer = timeout ? setTimeout(() => { try {
40
- proc.kill("SIGTERM");
41
- }
42
- catch { } }, timeout) : null;
43
- proc.on("close", (code) => {
44
- if (timer)
45
- clearTimeout(timer);
46
- // Strip noise before returning (npm fund, progress bars, etc.)
47
- const cleanStdout = stripNoise(stdout).cleaned;
48
- const cleanStderr = stripNoise(stderr).cleaned;
49
- // Invalidate boot cache after state-changing git commands
50
- if (/\bgit\s+(commit|checkout|branch|merge|reset|push|pull|rebase|stash)\b/.test(actualCommand)) {
51
- invalidateBootCache();
52
- }
53
- resolve({ exitCode: code ?? 0, stdout: cleanStdout, stderr: cleanStderr, duration: Date.now() - start, rewritten: rw.changed ? rw.rewritten : undefined });
54
- });
55
- });
56
- }
57
- /** Resolve a path — supports relative paths against cwd, just like a shell */
58
- function resolvePath(p, cwd) {
59
- if (!p)
60
- return cwd ?? process.cwd();
61
- if (p.startsWith("/") || p.startsWith("~"))
62
- return p;
63
- const { join } = require("path");
64
- return join(cwd ?? process.cwd(), p);
65
- }
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";
66
17
  // ── server ───────────────────────────────────────────────────────────────────
67
18
  export function createServer() {
68
19
  const server = new McpServer({
@@ -72,1298 +23,28 @@ export function createServer() {
72
23
  // Create a session for this MCP server instance
73
24
  const sessionId = createSession(process.cwd(), "mcp");
74
25
  // ── Mementos: cross-session project memory ────────────────────────────────
75
- let mementosProjectId = null;
76
26
  try {
77
27
  const mementos = require("@hasna/mementos");
78
28
  const projectName = process.cwd().split("/").pop() ?? "unknown";
79
29
  const project = mementos.registerProject(projectName, process.cwd());
80
- mementosProjectId = project?.id ?? null;
30
+ const mementosProjectId = project?.id ?? null;
81
31
  mementos.registerAgent("terminal-mcp");
82
32
  if (mementosProjectId)
83
33
  mementos.setFocus(mementosProjectId);
84
34
  }
85
35
  catch { } // mementos optional — works without it
86
- /** Log a tool call to sessions.db for economy tracking */
87
- function logCall(tool, data) {
88
- try {
89
- logInteraction(sessionId, {
90
- nl: `[mcp:${tool}]${data.command ? ` ${data.command.slice(0, 200)}` : ""}`,
91
- command: data.command?.slice(0, 500),
92
- exitCode: data.exitCode,
93
- tokensUsed: data.aiProcessed ? (data.outputTokens ?? 0) : 0,
94
- tokensSaved: data.tokensSaved ?? 0,
95
- durationMs: data.durationMs,
96
- model: data.model,
97
- cached: false,
98
- });
99
- }
100
- catch { } // never let logging break tool execution
101
- }
102
- // ── execute: run a command, return structured result ──────────────────────
103
- 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.", {
104
- command: z.string().describe("Shell command to execute"),
105
- cwd: z.string().optional().describe("Working directory (default: server cwd)"),
106
- timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
107
- format: z.enum(["raw", "json", "compressed", "summary"]).optional().describe("Output format"),
108
- maxTokens: z.number().optional().describe("Token budget for compressed/summary format"),
109
- }, async ({ command, cwd, timeout, format, maxTokens }) => {
110
- const start = Date.now();
111
- const result = await exec(command, cwd, timeout ?? 30000);
112
- const output = (result.stdout + result.stderr).trim();
113
- // Raw mode — with lazy execution for large results
114
- if (!format || format === "raw") {
115
- const clean = stripAnsi(output);
116
- if (shouldBeLazy(clean, command)) {
117
- const lazy = toLazy(clean, command);
118
- const detailKey = storeOutput(command, clean);
119
- logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
120
- return {
121
- content: [{ type: "text", text: JSON.stringify({
122
- exitCode: result.exitCode, ...lazy, detailKey, duration: result.duration,
123
- ...(result.rewritten ? { rewrittenFrom: command } : {}),
124
- }) }],
125
- };
126
- }
127
- logCall("execute", { command, outputTokens: estimateTokens(clean), tokensSaved: 0, durationMs: Date.now() - start, exitCode: result.exitCode });
128
- return {
129
- content: [{ type: "text", text: JSON.stringify({
130
- exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
131
- ...(result.rewritten ? { rewrittenFrom: command } : {}),
132
- }) }],
133
- };
134
- }
135
- // JSON and Summary modes — both go through AI processing
136
- if (format === "json" || format === "summary") {
137
- try {
138
- const processed = await processOutput(command, output);
139
- const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
140
- logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
141
- return {
142
- content: [{ type: "text", text: JSON.stringify({
143
- exitCode: result.exitCode,
144
- summary: processed.summary,
145
- structured: processed.structured,
146
- duration: result.duration,
147
- tokensSaved: processed.tokensSaved,
148
- aiProcessed: processed.aiProcessed,
149
- ...(detailKey ? { detailKey, expandable: true } : {}),
150
- }) }],
151
- };
152
- }
153
- catch {
154
- const compressed = compress(command, output, { maxTokens });
155
- logCall("execute", { command, outputTokens: estimateTokens(output), tokensSaved: compressed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
156
- return {
157
- content: [{ type: "text", text: JSON.stringify({
158
- exitCode: result.exitCode, output: compressed.content, duration: result.duration,
159
- tokensSaved: compressed.tokensSaved,
160
- }) }],
161
- };
162
- }
163
- }
164
- // Compressed mode — fast non-AI: strip + dedup + truncate
165
- if (format === "compressed") {
166
- const compressed = compress(command, output, { maxTokens });
167
- return {
168
- content: [{ type: "text", text: JSON.stringify({
169
- exitCode: result.exitCode, output: compressed.content, duration: result.duration,
170
- ...(compressed.tokensSaved > 0 ? { tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent } : {}),
171
- }) }],
172
- };
173
- }
174
- return { content: [{ type: "text", text: output }] };
175
- });
176
- // ── execute_smart: AI-powered output processing ────────────────────────────
177
- 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).", {
178
- command: z.string().describe("Shell command to execute"),
179
- cwd: z.string().optional().describe("Working directory"),
180
- timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
181
- verbosity: z.enum(["minimal", "normal", "detailed"]).optional().describe("Summary detail level (default: normal)"),
182
- }, async ({ command, cwd, timeout, verbosity }) => {
183
- const start = Date.now();
184
- const result = await exec(command, cwd, timeout ?? 30000, true);
185
- const output = (result.stdout + result.stderr).trim();
186
- const processed = await processOutput(command, output, undefined, verbosity);
187
- const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
188
- logCall("execute_smart", { command, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
189
- return {
190
- content: [{ type: "text", text: JSON.stringify({
191
- exitCode: result.exitCode,
192
- summary: processed.summary,
193
- structured: processed.structured,
194
- duration: result.duration,
195
- totalLines: output.split("\n").length,
196
- tokensSaved: processed.tokensSaved,
197
- aiProcessed: processed.aiProcessed,
198
- ...(detailKey ? { detailKey, expandable: true } : {}),
199
- }) }],
200
- };
201
- });
202
- // ── expand: retrieve full output on demand ────────────────────────────────
203
- 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.", {
204
- key: z.string().describe("The detailKey from a previous execute_smart response"),
205
- grep: z.string().optional().describe("Filter output lines by pattern (e.g., 'FAIL', 'error')"),
206
- }, async ({ key, grep }) => {
207
- const result = expandOutput(key, grep);
208
- if (!result.found) {
209
- return { content: [{ type: "text", text: JSON.stringify({ error: "Output expired or not found" }) }] };
210
- }
211
- return { content: [{ type: "text", text: JSON.stringify({ output: result.output, lines: result.lines }) }] };
212
- });
213
- // ── browse: list files/dirs as structured JSON ────────────────────────────
214
- server.tool("browse", "List files and directories as structured JSON. Auto-filters node_modules, .git, dist by default.", {
215
- path: z.string().optional().describe("Directory path (default: cwd)"),
216
- recursive: z.boolean().optional().describe("List recursively (default: false)"),
217
- maxDepth: z.number().optional().describe("Max depth for recursive listing (default: 2)"),
218
- includeHidden: z.boolean().optional().describe("Include hidden files (default: false)"),
219
- }, async ({ path, recursive, maxDepth, includeHidden }) => {
220
- const target = path ?? process.cwd();
221
- const depth = maxDepth ?? 2;
222
- let command;
223
- if (recursive) {
224
- command = `find "${target}" -maxdepth ${depth} -not -path '*/node_modules/*' -not -path '*/.git/*' -not -path '*/dist/*' -not -path '*/.next/*'`;
225
- if (!includeHidden)
226
- command += " -not -name '.*'";
227
- }
228
- else {
229
- command = includeHidden ? `ls -la "${target}"` : `ls -l "${target}"`;
230
- }
231
- const result = await exec(command);
232
- const files = result.stdout.split("\n").filter(l => l.trim());
233
- return { content: [{ type: "text", text: JSON.stringify({ cwd: target, files, count: files.length }) }] };
234
- });
235
- // ── explain_error: structured error diagnosis ─────────────────────────────
236
- server.tool("explain_error", "Parse error output and return structured diagnosis with root cause and fix suggestion.", {
237
- error: z.string().describe("Error output text"),
238
- command: z.string().optional().describe("The command that produced the error"),
239
- }, async ({ error, command }) => {
240
- // AI processes the error — no regex guessing
241
- const processed = await processOutput(command ?? "unknown", error);
242
- return {
243
- content: [{ type: "text", text: JSON.stringify({
244
- summary: processed.summary,
245
- structured: processed.structured,
246
- aiProcessed: processed.aiProcessed,
247
- }) }],
248
- };
249
- });
250
- // ── status: show server info ──────────────────────────────────────────────
251
- server.tool("status", "Get terminal server status, capabilities, and available parsers.", async () => {
252
- return {
253
- content: [{ type: "text", text: JSON.stringify({
254
- name: "terminal", version: "3.3.0", cwd: process.cwd(),
255
- features: ["ai-output-processing", "token-compression", "noise-filtering", "diff-caching", "lazy-execution", "progressive-disclosure"],
256
- }) }],
257
- };
258
- });
259
- // ── search_files: smart file search with auto-filtering ────────────────────
260
- server.tool("search_files", "Search for files by name pattern. Auto-filters node_modules, .git, dist. Returns categorized results (source, config, other) with token savings.", {
261
- pattern: z.string().describe("Glob pattern (e.g., '*hooks*', '*.test.ts')"),
262
- path: z.string().optional().describe("Search root (default: cwd)"),
263
- includeNodeModules: z.boolean().optional().describe("Include node_modules (default: false)"),
264
- maxResults: z.number().optional().describe("Max results per category (default: 50)"),
265
- }, async ({ pattern, path, includeNodeModules, maxResults }) => {
266
- const start = Date.now();
267
- const result = await searchFiles(pattern, path ?? process.cwd(), { includeNodeModules, maxResults });
268
- logCall("search_files", { command: `search_files ${pattern}`, tokensSaved: result.tokensSaved ?? 0, durationMs: Date.now() - start });
269
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
270
- });
271
- // ── search_content: smart grep with grouping ──────────────────────────────
272
- server.tool("search_content", "Search file contents by regex pattern. Groups matches by file, sorted by relevance. Auto-filters excluded directories.", {
273
- pattern: z.string().describe("Search pattern (regex)"),
274
- path: z.string().optional().describe("Search root (default: cwd)"),
275
- fileType: z.string().optional().describe("File type filter (e.g., 'ts', 'py')"),
276
- maxResults: z.number().optional().describe("Max files to return (default: 30)"),
277
- contextLines: z.number().optional().describe("Context lines around matches (default: 0)"),
278
- }, async ({ pattern, path, fileType, maxResults, contextLines }) => {
279
- const start = Date.now();
280
- const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
281
- logCall("search_content", { command: `grep ${pattern}`, tokensSaved: result.tokensSaved ?? 0, durationMs: Date.now() - start });
282
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
283
- });
284
- // ── search_semantic: AST-powered code search ───────────────────────────────
285
- server.tool("search_semantic", "Find functions, classes, components, hooks, types by NAME or SIGNATURE. Searches symbol declarations, NOT code behavior or content. Use search_content (grep) instead for pattern matching inside code (e.g., security audits, string searches, imports).", {
286
- query: z.string().describe("Symbol name to search for (e.g., 'auth', 'login', 'UserService'). Matches function/class/type names, not code content."),
287
- path: z.string().optional().describe("Search root (default: cwd)"),
288
- kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
289
- exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
290
- maxResults: z.number().optional().describe("Max results (default: 30)"),
291
- }, async ({ query, path, kinds, exportedOnly, maxResults }) => {
292
- const result = await semanticSearch(query, path ?? process.cwd(), {
293
- kinds: kinds,
294
- exportedOnly,
295
- maxResults,
296
- });
297
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
298
- });
299
- // ── list_recipes: list saved command recipes ──────────────────────────────
300
- server.tool("list_recipes", "List saved command recipes. Optionally filter by collection or project.", {
301
- collection: z.string().optional().describe("Filter by collection name"),
302
- project: z.string().optional().describe("Project path for project-scoped recipes"),
303
- }, async ({ collection, project }) => {
304
- let recipes = listRecipes(project);
305
- if (collection)
306
- recipes = recipes.filter(r => r.collection === collection);
307
- return { content: [{ type: "text", text: JSON.stringify(recipes) }] };
308
- });
309
- // ── run_recipe: execute a saved recipe ────────────────────────────────────
310
- server.tool("run_recipe", "Run a saved recipe by name with optional variable substitution.", {
311
- name: z.string().describe("Recipe name"),
312
- variables: z.record(z.string(), z.string()).optional().describe("Variable values: {port: '3000'}"),
313
- cwd: z.string().optional().describe("Working directory"),
314
- format: z.enum(["raw", "json", "compressed"]).optional().describe("Output format"),
315
- }, async ({ name, variables, cwd, format }) => {
316
- const recipe = getRecipe(name, cwd);
317
- if (!recipe) {
318
- return { content: [{ type: "text", text: JSON.stringify({ error: `Recipe '${name}' not found` }) }] };
319
- }
320
- const command = variables ? substituteVariables(recipe.command, variables) : recipe.command;
321
- const result = await exec(command, cwd, 30000);
322
- const output = (result.stdout + result.stderr).trim();
323
- if (format === "json" || format === "compressed") {
324
- const processed = await processOutput(command, output);
325
- return { content: [{ type: "text", text: JSON.stringify({
326
- recipe: name, exitCode: result.exitCode, summary: processed.summary,
327
- structured: processed.structured, duration: result.duration,
328
- tokensSaved: processed.tokensSaved, aiProcessed: processed.aiProcessed,
329
- }) }] };
330
- }
331
- return { content: [{ type: "text", text: JSON.stringify({
332
- recipe: name, exitCode: result.exitCode, output: stripAnsi(output), duration: result.duration,
333
- }) }] };
334
- });
335
- // ── save_recipe: save a new recipe ────────────────────────────────────────
336
- server.tool("save_recipe", "Save a reusable command recipe. Variables in commands use {name} syntax.", {
337
- name: z.string().describe("Recipe name"),
338
- command: z.string().describe("Shell command (use {var} for variables)"),
339
- description: z.string().optional().describe("Description"),
340
- collection: z.string().optional().describe("Collection to add to"),
341
- project: z.string().optional().describe("Project path (for project-scoped recipe)"),
342
- tags: z.array(z.string()).optional().describe("Tags"),
343
- }, async ({ name, command, description, collection, project, tags }) => {
344
- const recipe = createRecipe({ name, command, description, collection, project, tags });
345
- return { content: [{ type: "text", text: JSON.stringify(recipe) }] };
346
- });
347
- // ── list_collections: list recipe collections ─────────────────────────────
348
- server.tool("list_collections", "List recipe collections.", {
349
- project: z.string().optional().describe("Project path"),
350
- }, async ({ project }) => {
351
- const collections = listCollections(project);
352
- return { content: [{ type: "text", text: JSON.stringify(collections) }] };
353
- });
354
- // ── bg_start: start a background process ───────────────────────────────────
355
- server.tool("bg_start", "Start a background process (e.g., dev server). Auto-detects port from command.", {
356
- command: z.string().describe("Command to run in background"),
357
- cwd: z.string().optional().describe("Working directory"),
358
- }, async ({ command, cwd }) => {
359
- const result = bgStart(command, cwd);
360
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
361
- });
362
- // ── bg_status: list background processes ──────────────────────────────────
363
- server.tool("bg_status", "List all managed background processes with status, ports, and recent output.", async () => {
364
- return { content: [{ type: "text", text: JSON.stringify(bgStatus()) }] };
365
- });
366
- // ── bg_stop: stop a background process ────────────────────────────────────
367
- server.tool("bg_stop", "Stop a managed background process by PID.", { pid: z.number().describe("Process ID to stop") }, async ({ pid }) => {
368
- const ok = bgStop(pid);
369
- return { content: [{ type: "text", text: JSON.stringify({ stopped: ok, pid }) }] };
370
- });
371
- // ── bg_logs: get process output ───────────────────────────────────────────
372
- server.tool("bg_logs", "Get recent output lines from a background process.", {
373
- pid: z.number().describe("Process ID"),
374
- tail: z.number().optional().describe("Number of lines (default: 20)"),
375
- }, async ({ pid, tail }) => {
376
- const lines = bgLogs(pid, tail);
377
- return { content: [{ type: "text", text: JSON.stringify({ pid, lines }) }] };
378
- });
379
- // ── bg_wait_port: wait for port to be ready ───────────────────────────────
380
- server.tool("bg_wait_port", "Wait for a port to start accepting connections. Useful after starting a dev server.", {
381
- port: z.number().describe("Port number to wait for"),
382
- timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
383
- }, async ({ port, timeout }) => {
384
- const ready = await bgWaitPort(port, timeout);
385
- return { content: [{ type: "text", text: JSON.stringify({ port, ready }) }] };
386
- });
387
- // ── execute_diff: run command with diff from last run ───────────────────────
388
- server.tool("execute_diff", "Run a command and return diff from its last execution. Ideal for edit→test loops — only shows what changed.", {
389
- command: z.string().describe("Shell command to execute"),
390
- cwd: z.string().optional().describe("Working directory"),
391
- timeout: z.number().optional().describe("Timeout in ms"),
392
- }, async ({ command, cwd, timeout }) => {
393
- const start = Date.now();
394
- const workDir = cwd ?? process.cwd();
395
- const result = await exec(command, workDir, timeout ?? 30000);
396
- const output = (result.stdout + result.stderr).trim();
397
- const diff = diffOutput(command, workDir, output);
398
- if (diff.tokensSaved > 0) {
399
- recordSaving("diff", diff.tokensSaved);
400
- }
401
- logCall("execute_diff", { command, outputTokens: estimateTokens(output), tokensSaved: diff.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode });
402
- if (diff.unchanged) {
403
- return { content: [{ type: "text", text: JSON.stringify({
404
- exitCode: result.exitCode, unchanged: true, diffSummary: diff.diffSummary,
405
- duration: result.duration, tokensSaved: diff.tokensSaved,
406
- }) }] };
407
- }
408
- if (diff.hasPrevious) {
409
- return { content: [{ type: "text", text: JSON.stringify({
410
- exitCode: result.exitCode, diffSummary: diff.diffSummary,
411
- added: diff.added.slice(0, 50), removed: diff.removed.slice(0, 50),
412
- duration: result.duration, tokensSaved: diff.tokensSaved,
413
- }) }] };
414
- }
415
- // First run — return full output (ANSI stripped)
416
- const clean = stripAnsi(output);
417
- return { content: [{ type: "text", text: JSON.stringify({
418
- exitCode: result.exitCode, output: clean,
419
- diffSummary: "first run", duration: result.duration,
420
- }) }] };
421
- });
422
- // ── token_stats: economy dashboard ────────────────────────────────────────
423
- server.tool("token_stats", "Get token economy stats — how many tokens have been saved by structured output, compression, diffing, and caching.", async () => {
424
- const stats = getEconomyStats();
425
- return { content: [{ type: "text", text: JSON.stringify(stats) }] };
426
- });
427
- // ── snapshot: capture terminal state ──────────────────────────────────────
428
- server.tool("snapshot", "Capture a compact snapshot of terminal state (cwd, env, running processes, recent commands, recipes). Useful for agent context handoff.", async () => {
429
- const snap = captureSnapshot();
430
- return { content: [{ type: "text", text: JSON.stringify(snap) }] };
431
- });
432
- // ── session_history: query session data ────────────────────────────────────
433
- server.tool("session_history", "Query terminal session history — recent sessions, specific session details, or aggregate stats.", {
434
- action: z.enum(["list", "detail", "stats"]).describe("list=recent sessions, detail=specific session, stats=aggregates"),
435
- sessionId: z.string().optional().describe("Session ID (for detail action)"),
436
- limit: z.number().optional().describe("Max sessions to return (for list, default: 20)"),
437
- }, async ({ action, sessionId, limit }) => {
438
- if (action === "stats") {
439
- return { content: [{ type: "text", text: JSON.stringify(getSessionStats()) }] };
440
- }
441
- if (action === "detail" && sessionId) {
442
- const interactions = getSessionInteractions(sessionId);
443
- const economy = getSessionEconomy(sessionId);
444
- return { content: [{ type: "text", text: JSON.stringify({ interactions, economy }) }] };
445
- }
446
- const sessions = listSessions(limit ?? 20);
447
- return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
448
- });
449
- // ── boot: session start context (replaces first 5 agent commands) ──────────
450
- server.tool("boot", "Get everything an agent needs on session start in ONE call — git state, project info, source structure. Replaces: git status + git log + cat package.json + ls src/. Cached for the session.", async () => {
451
- const ctx = await getBootContext(process.cwd());
452
- return { content: [{ type: "text", text: JSON.stringify({
453
- ...ctx,
454
- hints: {
455
- cwd: process.cwd(),
456
- tip: "All terminal tools support relative paths. Use 'src/foo.ts' not the full absolute path. Use commit({message, push:true}) instead of raw git commands. Use run({task:'test'}) instead of bun/npm test. Use lookup({file, items}) instead of grep pipelines.",
457
- },
458
- }) }] };
459
- });
460
- // ── project_overview: orient agent in one call ─────────────────────────────
461
- server.tool("project_overview", "Get project overview in one call — package.json info, source structure, config files. Replaces: cat package.json + ls src/ + cat tsconfig.json.", {
462
- path: z.string().optional().describe("Project root (default: cwd)"),
463
- }, async ({ path }) => {
464
- const cwd = path ?? process.cwd();
465
- const [pkgResult, srcResult, configResult] = await Promise.all([
466
- exec("cat package.json 2>/dev/null", cwd),
467
- exec("ls -1 src/ 2>/dev/null || ls -1 lib/ 2>/dev/null || ls -1 app/ 2>/dev/null", cwd),
468
- exec("ls -1 *.json *.config.* .env* tsconfig* 2>/dev/null", cwd),
469
- ]);
470
- let pkg = null;
471
- try {
472
- pkg = JSON.parse(pkgResult.stdout);
473
- }
474
- catch { }
475
- return {
476
- content: [{ type: "text", text: JSON.stringify({
477
- name: pkg?.name,
478
- version: pkg?.version,
479
- scripts: pkg?.scripts,
480
- dependencies: pkg?.dependencies ? Object.keys(pkg.dependencies) : [],
481
- devDependencies: pkg?.devDependencies ? Object.keys(pkg.devDependencies) : [],
482
- sourceFiles: srcResult.stdout.split("\n").filter(l => l.trim()),
483
- configFiles: configResult.stdout.split("\n").filter(l => l.trim()),
484
- }) }],
485
- };
486
- });
487
- // ── last_commit: what just happened ───────────────────────────────────────
488
- server.tool("last_commit", "Get details of the last commit — hash, message, files changed, diff stats. Replaces: git log -1 + git show --stat + git diff HEAD~1.", {
489
- path: z.string().optional().describe("Repo path (default: cwd)"),
490
- }, async ({ path }) => {
491
- const cwd = path ?? process.cwd();
492
- const [logResult, statResult] = await Promise.all([
493
- exec("git log -1 --format='%H%n%s%n%an%n%ai'", cwd),
494
- exec("git show --stat --format='' HEAD", cwd),
495
- ]);
496
- const [hash, message, author, date] = logResult.stdout.split("\n");
497
- const filesChanged = statResult.stdout.split("\n").filter(l => l.trim() && !l.includes("changed"));
498
- return {
499
- content: [{ type: "text", text: JSON.stringify({
500
- hash: hash?.trim(),
501
- message: message?.trim(),
502
- author: author?.trim(),
503
- date: date?.trim(),
504
- filesChanged,
505
- }) }],
506
- };
507
- });
508
- // ── read_file: cached file reading ─────────────────────────────────────────
509
- 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.", {
510
- path: z.string().describe("File path"),
511
- offset: z.number().optional().describe("Start line (0-indexed)"),
512
- limit: z.number().optional().describe("Max lines to return"),
513
- summarize: z.boolean().optional().describe("Return AI summary instead of full content (saves ~90% tokens)"),
514
- focus: z.string().optional().describe("Focus hint for summary (e.g., 'public API', 'error handling', 'auth logic')"),
515
- }, async ({ path: rawPath, offset, limit, summarize, focus }) => {
516
- const start = Date.now();
517
- const path = resolvePath(rawPath);
518
- const result = cachedRead(path, { offset, limit });
519
- if (summarize && result.content.length > 500) {
520
- const provider = getOutputProvider();
521
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
522
- const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
523
- const focusInstruction = focus
524
- ? `Focus specifically on: ${focus}. Describe only aspects related to "${focus}".`
525
- : `Describe what this source file does in 2-4 lines. Include: main class/module name, key methods/functions, what it exports, and its purpose.`;
526
- const summary = await provider.complete(`File: ${path}\n\n${content}`, {
527
- model: outputModel,
528
- system: `${focusInstruction} Be specific — name the actual functions and what they do. Never just say "N lines of code."`,
529
- maxTokens: 300,
530
- temperature: 0.2,
531
- });
532
- const outputTokens = estimateTokens(result.content);
533
- const summaryTokens = estimateTokens(summary);
534
- const saved = Math.max(0, outputTokens - summaryTokens);
535
- logCall("read_file", { command: path, outputTokens, tokensSaved: saved, durationMs: Date.now() - start, aiProcessed: true });
536
- return {
537
- content: [{ type: "text", text: JSON.stringify({
538
- summary,
539
- lines: result.content.split("\n").length,
540
- tokensSaved: saved,
541
- cached: result.cached,
542
- }) }],
543
- };
544
- }
545
- logCall("read_file", { command: path, outputTokens: estimateTokens(result.content), tokensSaved: 0, durationMs: Date.now() - start });
546
- return {
547
- content: [{ type: "text", text: JSON.stringify({
548
- content: result.content,
549
- cached: result.cached,
550
- readCount: result.readCount,
551
- ...(result.cached ? { note: `Served from cache (read #${result.readCount})` } : {}),
552
- }) }],
553
- };
554
- });
555
- // ── repo_state: git status + diff + log in one call ───────────────────────
556
- server.tool("repo_state", "Get full repository state in one call — branch, status, staged/unstaged files, recent commits. Replaces the common 3-command pattern: git status + git diff --stat + git log.", {
557
- path: z.string().optional().describe("Repo path (default: cwd)"),
558
- }, async ({ path }) => {
559
- const cwd = path ?? process.cwd();
560
- const [statusResult, diffResult, logResult] = await Promise.all([
561
- exec("git status --porcelain", cwd),
562
- exec("git diff --stat", cwd),
563
- exec("git log --oneline -12 --decorate", cwd),
564
- ]);
565
- const branchResult = await exec("git branch --show-current", cwd);
566
- const staged = [];
567
- const unstaged = [];
568
- const untracked = [];
569
- for (const line of statusResult.stdout.split("\n").filter(l => l.trim())) {
570
- const x = line[0], y = line[1], file = line.slice(3);
571
- if (x === "?" && y === "?")
572
- untracked.push(file);
573
- else if (x !== " " && x !== "?")
574
- staged.push(file);
575
- if (y !== " " && y !== "?")
576
- unstaged.push(file);
577
- }
578
- const commits = logResult.stdout.split("\n").filter(l => l.trim()).map(l => {
579
- const match = l.match(/^([a-f0-9]+)\s+(.+)$/);
580
- return match ? { hash: match[1], message: match[2] } : { hash: "", message: l };
581
- });
582
- return {
583
- content: [{ type: "text", text: JSON.stringify({
584
- branch: branchResult.stdout.trim(),
585
- dirty: staged.length + unstaged.length + untracked.length > 0,
586
- staged, unstaged, untracked,
587
- diffSummary: diffResult.stdout.trim() || "no changes",
588
- recentCommits: commits,
589
- }) }],
590
- };
591
- });
592
- // ── symbols: file structure outline ───────────────────────────────────────
593
- 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.", {
594
- path: z.string().describe("File path to extract symbols from"),
595
- }, async ({ path: rawPath }) => {
596
- const start = Date.now();
597
- const filePath = resolvePath(rawPath);
598
- const result = cachedRead(filePath, {});
599
- if (!result.content || result.content.startsWith("Error:")) {
600
- return { content: [{ type: "text", text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
601
- }
602
- // AI extracts symbols — works for ANY language
603
- let symbols = [];
604
- try {
605
- const provider = getOutputProvider();
606
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
607
- const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
608
- const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
609
- model: outputModel,
610
- system: `Extract all symbols from this source file. Return ONLY a JSON array, no explanation.
611
-
612
- Each symbol: {"name": "symbolName", "kind": "function|class|method|interface|type|variable|export", "line": lineNumber, "signature": "brief signature"}
613
-
614
- For class methods, use "ClassName.methodName" as name with kind "method".
615
- Include: functions, classes, methods, interfaces, types, exported constants.
616
- Exclude: imports, local variables, comments.
617
- Line numbers must be accurate (count from 1).`,
618
- maxTokens: 2000,
619
- temperature: 0,
620
- });
621
- const jsonMatch = summary.match(/\[[\s\S]*\]/);
622
- if (jsonMatch)
623
- symbols = JSON.parse(jsonMatch[0]);
624
- }
625
- catch (err) {
626
- // Surface the error instead of silently returning []
627
- return { content: [{ type: "text", text: JSON.stringify({ error: `AI symbol extraction failed: ${err.message?.slice(0, 200)}`, file: filePath }) }] };
628
- }
629
- const outputTokens = estimateTokens(result.content);
630
- const symbolTokens = estimateTokens(JSON.stringify(symbols));
631
- logCall("symbols", { command: filePath, outputTokens, tokensSaved: Math.max(0, outputTokens - symbolTokens), durationMs: Date.now() - start, aiProcessed: true });
632
- return {
633
- content: [{ type: "text", text: JSON.stringify(symbols) }],
634
- };
635
- });
636
- // ── read_symbol: read a function/class by name ─────────────────────────────
637
- 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.", {
638
- path: z.string().describe("Source file path"),
639
- name: z.string().describe("Symbol name (function, class, interface)"),
640
- }, async ({ path: rawPath, name }) => {
641
- const start = Date.now();
642
- const filePath = resolvePath(rawPath);
643
- const result = cachedRead(filePath, {});
644
- if (!result.content || result.content.startsWith("Error:")) {
645
- return { content: [{ type: "text", text: JSON.stringify({ error: `Cannot read ${filePath}` }) }] };
646
- }
647
- // AI extracts the specific symbol — works for ANY language
648
- const provider = getOutputProvider();
649
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
650
- const summary = await provider.complete(`File: ${filePath}\nSymbol to extract: ${name}\n\n${result.content.slice(0, 8000)}`, {
651
- model: outputModel,
652
- system: `Extract the complete code block for the symbol "${name}" from this file. Return ONLY a JSON object:
653
- {"name": "${name}", "code": "the complete code block", "startLine": N, "endLine": N}
654
-
655
- If the symbol is not found, return: {"error": "not found", "available": ["list", "of", "symbol", "names"]}
656
-
657
- Match by function name, class name, method name (including ClassName.method), interface, type, or variable name.`,
658
- maxTokens: 2000,
659
- temperature: 0,
660
- });
661
- let parsed = {};
662
- try {
663
- const jsonMatch = summary.match(/\{[\s\S]*\}/);
664
- if (jsonMatch)
665
- parsed = JSON.parse(jsonMatch[0]);
666
- }
667
- catch { }
668
- 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 });
669
- return { content: [{ type: "text", text: JSON.stringify(parsed) }] };
670
- });
671
- // ── Intent-level tools — agents express WHAT, we handle HOW ───────────────
672
- server.tool("commit", "Commit and optionally push. Agent says what to commit, we handle git add/commit/push. Saves ~400 tokens vs raw git commands.", {
673
- message: z.string().describe("Commit message"),
674
- files: z.array(z.string()).optional().describe("Files to stage (default: all changed)"),
675
- push: z.boolean().optional().describe("Push after commit (default: false)"),
676
- cwd: z.string().optional().describe("Working directory"),
677
- }, async ({ message, files, push, cwd }) => {
678
- const start = Date.now();
679
- const workDir = cwd ?? process.cwd();
680
- const addCmd = files && files.length > 0 ? `git add ${files.map(f => `"${f}"`).join(" ")}` : "git add -A";
681
- const commitCmd = `${addCmd} && git commit -m ${JSON.stringify(message)}`;
682
- const fullCmd = push ? `${commitCmd} && git push` : commitCmd;
683
- const result = await exec(fullCmd, workDir, 30000);
684
- const output = (result.stdout + result.stderr).trim();
685
- logCall("commit", { command: `commit: ${message.slice(0, 80)}`, durationMs: Date.now() - start, exitCode: result.exitCode });
686
- invalidateBootCache();
687
- return { content: [{ type: "text", text: JSON.stringify({
688
- exitCode: result.exitCode,
689
- output: stripAnsi(output).split("\n").filter(l => l.trim()).slice(0, 5).join("\n"),
690
- pushed: push ?? false,
691
- }) }] };
692
- });
693
- server.tool("bulk_commit", "Multiple logical commits in one call. Agent decides which files go in which commit, we handle all git commands. No AI cost. Use smart_commit instead if you want AI to decide the grouping.", {
694
- commits: z.array(z.object({
695
- message: z.string().describe("Commit message"),
696
- files: z.array(z.string()).describe("Files to stage for this commit"),
697
- })).describe("Array of logical commits"),
698
- push: z.boolean().optional().describe("Push after all commits (default: true)"),
699
- cwd: z.string().optional().describe("Working directory"),
700
- }, async ({ commits, push, cwd }) => {
701
- const start = Date.now();
702
- const workDir = cwd ?? process.cwd();
703
- const results = [];
704
- for (const c of commits) {
705
- const fileArgs = c.files.map(f => `"${f}"`).join(" ");
706
- const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
707
- const r = await exec(cmd, workDir, 15000);
708
- results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
709
- }
710
- let pushed = false;
711
- if (push !== false) {
712
- const pushResult = await exec("git push", workDir, 30000);
713
- pushed = pushResult.exitCode === 0;
714
- }
715
- invalidateBootCache();
716
- logCall("bulk_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start });
717
- return { content: [{ type: "text", text: JSON.stringify({ commits: results, pushed, total: results.length }) }] };
718
- });
719
- server.tool("run", "Run a project task by intent — test, build, lint, dev, typecheck, format. Auto-detects toolchain (bun/npm/pnpm/yarn/cargo/go/make). Saves ~100 tokens vs raw commands.", {
720
- task: z.enum(["test", "build", "lint", "dev", "start", "typecheck", "format", "check"]).describe("What to run"),
721
- args: z.string().optional().describe("Extra arguments (e.g., '--watch', 'src/foo.test.ts')"),
722
- cwd: z.string().optional().describe("Working directory"),
723
- }, async ({ task, args, cwd }) => {
724
- const start = Date.now();
725
- const workDir = cwd ?? process.cwd();
726
- // Detect toolchain from project files
727
- const { existsSync } = await import("fs");
728
- const { join } = await import("path");
729
- let runner = "npm run";
730
- if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock")))
731
- runner = "bun run";
732
- else if (existsSync(join(workDir, "pnpm-lock.yaml")))
733
- runner = "pnpm run";
734
- else if (existsSync(join(workDir, "yarn.lock")))
735
- runner = "yarn";
736
- else if (existsSync(join(workDir, "Cargo.toml")))
737
- runner = "cargo";
738
- else if (existsSync(join(workDir, "go.mod")))
739
- runner = "go";
740
- else if (existsSync(join(workDir, "Makefile")))
741
- runner = "make";
742
- // Map intent to command
743
- let cmd;
744
- if (runner === "cargo") {
745
- cmd = `cargo ${task}${args ? ` ${args}` : ""}`;
746
- }
747
- else if (runner === "go") {
748
- const goMap = { test: "go test ./...", build: "go build ./...", lint: "golangci-lint run", format: "gofmt -w .", check: "go vet ./..." };
749
- cmd = goMap[task] ?? `go ${task}`;
750
- }
751
- else if (runner === "make") {
752
- cmd = `make ${task}${args ? ` ${args}` : ""}`;
753
- }
754
- else {
755
- // JS/TS ecosystem
756
- const jsMap = { test: "test", build: "build", lint: "lint", dev: "dev", start: "start", typecheck: "typecheck", format: "format", check: "check" };
757
- cmd = `${runner} ${jsMap[task] ?? task}${args ? ` ${args}` : ""}`;
758
- }
759
- const result = await exec(cmd, workDir, 120000);
760
- const output = (result.stdout + result.stderr).trim();
761
- const processed = await processOutput(cmd, output);
762
- logCall("run", { command: `${task}${args ? ` ${args}` : ""}`, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, exitCode: result.exitCode, aiProcessed: processed.aiProcessed });
763
- return { content: [{ type: "text", text: JSON.stringify({
764
- exitCode: result.exitCode,
765
- task,
766
- runner,
767
- summary: processed.summary,
768
- tokensSaved: processed.tokensSaved,
769
- }) }] };
770
- });
771
- 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.", {
772
- file: z.string().describe("File path"),
773
- find: z.string().describe("Text to find (exact match)"),
774
- replace: z.string().describe("Replacement text"),
775
- all: z.boolean().optional().describe("Replace all occurrences (default: first only)"),
776
- }, async ({ file: rawFile, find, replace, all }) => {
777
- const start = Date.now();
778
- const file = resolvePath(rawFile);
779
- const { readFileSync, writeFileSync } = await import("fs");
780
- try {
781
- let content = readFileSync(file, "utf8");
782
- const count = (content.match(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g")) || []).length;
783
- if (count === 0) {
784
- return { content: [{ type: "text", text: JSON.stringify({ error: "Text not found", file }) }] };
785
- }
786
- if (all) {
787
- content = content.split(find).join(replace);
788
- }
789
- else {
790
- content = content.replace(find, replace);
791
- }
792
- writeFileSync(file, content);
793
- logCall("edit", { command: `edit ${file}`, durationMs: Date.now() - start });
794
- return { content: [{ type: "text", text: JSON.stringify({ ok: true, file, replacements: all ? count : 1 }) }] };
795
- }
796
- catch (e) {
797
- return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
798
- }
799
- });
800
- server.tool("lookup", "Search for specific items in a file by name or pattern. Agent says what to find, not how to grep. Saves ~300 tokens vs constructing grep pipelines.", {
801
- file: z.string().describe("File path to search in"),
802
- items: z.array(z.string()).describe("Names or patterns to look up"),
803
- context: z.number().optional().describe("Lines of context around each match (default: 3)"),
804
- }, async ({ file: rawFile, items, context }) => {
805
- const start = Date.now();
806
- const file = resolvePath(rawFile);
807
- const { readFileSync } = await import("fs");
808
- try {
809
- const content = readFileSync(file, "utf8");
810
- const lines = content.split("\n");
811
- const ctx = context ?? 3;
812
- const results = {};
813
- for (const item of items) {
814
- results[item] = [];
815
- const pattern = new RegExp(item.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
816
- for (let i = 0; i < lines.length; i++) {
817
- if (pattern.test(lines[i])) {
818
- results[item].push({
819
- line: i + 1,
820
- text: lines[i].trim(),
821
- context: lines.slice(Math.max(0, i - ctx), i + ctx + 1).map(l => l.trimEnd()),
822
- });
823
- }
824
- }
825
- }
826
- logCall("lookup", { command: `lookup ${file} [${items.join(",")}]`, durationMs: Date.now() - start });
827
- return { content: [{ type: "text", text: JSON.stringify(results) }] };
828
- }
829
- catch (e) {
830
- return { content: [{ type: "text", text: JSON.stringify({ error: e.message }) }] };
831
- }
832
- });
833
- server.tool("smart_commit", "AI-powered git commit. Analyzes all changes, groups into logical commits with generated messages, stages and commits each group, optionally pushes. One call replaces the entire git workflow. Agent just says 'commit my work'.", {
834
- push: z.boolean().optional().describe("Push after all commits (default: true)"),
835
- hint: z.string().optional().describe("Optional context about the changes (e.g., 'fixed auth + added users endpoint')"),
836
- cwd: z.string().optional().describe("Working directory"),
837
- }, async ({ push, hint, cwd }) => {
838
- const start = Date.now();
839
- const workDir = cwd ?? process.cwd();
840
- // 1. Get all changed files
841
- const status = await exec("git status --porcelain", workDir, 10000);
842
- const diffStat = await exec("git diff --stat", workDir, 10000);
843
- const untrackedDiff = await exec("git diff HEAD --stat", workDir, 10000);
844
- const changedFiles = status.stdout.trim();
845
- if (!changedFiles) {
846
- return { content: [{ type: "text", text: JSON.stringify({ message: "Nothing to commit — working tree clean" }) }] };
847
- }
848
- // 2. AI groups changes into logical commits
849
- const provider = getOutputProvider();
850
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
851
- const grouping = await provider.complete(`Changed files:\n${changedFiles}\n\nDiff stats:\n${diffStat.stdout}\n${untrackedDiff.stdout}${hint ? `\n\nContext: ${hint}` : ""}`, {
852
- model: outputModel,
853
- system: `You are a git commit assistant. Group these changed files into logical commits. Return ONLY a JSON array:
854
-
855
- [{"message": "conventional commit message", "files": ["file1.ts", "file2.ts"]}]
856
-
857
- Rules:
858
- - Group related changes (same feature, same fix, same refactor)
859
- - Use conventional commits: feat:, fix:, refactor:, test:, docs:, chore:
860
- - Message should explain WHY, not WHAT (the diff shows what)
861
- - Each file appears in exactly one group
862
- - If all changes are related, use a single commit
863
- - Extract file paths from the status output (skip the status prefix like M, A, ??)`,
864
- maxTokens: 1000,
865
- temperature: 0,
866
- });
867
- let commits = [];
868
- try {
869
- const jsonMatch = grouping.match(/\[[\s\S]*\]/);
870
- if (jsonMatch)
871
- commits = JSON.parse(jsonMatch[0]);
872
- }
873
- catch { }
874
- if (commits.length === 0) {
875
- // Fallback: single commit with all files
876
- commits = [{ message: hint ?? "chore: update files", files: changedFiles.split("\n").map(l => l.slice(3).trim()) }];
877
- }
878
- // 3. Execute each commit
879
- const results = [];
880
- for (const c of commits) {
881
- const fileArgs = c.files.map(f => `"${f}"`).join(" ");
882
- const cmd = `git add ${fileArgs} && git commit -m ${JSON.stringify(c.message)}`;
883
- const r = await exec(cmd, workDir, 15000);
884
- results.push({ message: c.message, files: c.files.length, ok: r.exitCode === 0 });
885
- }
886
- // 4. Push if requested
887
- let pushed = false;
888
- if (push !== false) {
889
- const pushResult = await exec("git push", workDir, 30000);
890
- pushed = pushResult.exitCode === 0;
891
- }
892
- invalidateBootCache();
893
- logCall("smart_commit", { command: `${commits.length} commits`, durationMs: Date.now() - start, aiProcessed: true });
894
- return { content: [{ type: "text", text: JSON.stringify({
895
- commits: results,
896
- pushed,
897
- total: results.length,
898
- ok: results.every(r => r.ok),
899
- }) }] };
900
- });
901
- // ── watch: run task on file change ─────────────────────────────────────────
902
- const watchHandles = new Map();
903
- server.tool("watch", "Run a task (test/build/lint/typecheck) on file change. Returns diff from last run. Agent stops polling — we push on change. Call watch_stop to end.", {
904
- task: z.enum(["test", "build", "lint", "typecheck"]).describe("Task to run on change"),
905
- path: z.string().optional().describe("File or directory to watch (default: src/)"),
906
- cwd: z.string().optional().describe("Working directory"),
907
- }, async ({ task, path: watchPath, cwd }) => {
908
- const { watch } = await import("fs");
909
- const workDir = cwd ?? process.cwd();
910
- const target = resolvePath(watchPath ?? "src/", workDir);
911
- const watchId = `${task}:${target}`;
912
- // Run once immediately
913
- const { existsSync } = await import("fs");
914
- const { join } = await import("path");
915
- let runner = "npm run";
916
- if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock")))
917
- runner = "bun run";
918
- else if (existsSync(join(workDir, "Cargo.toml")))
919
- runner = "cargo";
920
- const cmd = runner === "cargo" ? `cargo ${task}` : `${runner} ${task}`;
921
- const result = await exec(cmd, workDir, 60000);
922
- const output = (result.stdout + result.stderr).trim();
923
- const processed = await processOutput(cmd, output);
924
- // Store initial result for diffing
925
- const detailKey = storeOutput(`watch:${task}`, output);
926
- logCall("watch", { command: `watch ${task} ${target}`, exitCode: result.exitCode, durationMs: 0, aiProcessed: processed.aiProcessed });
927
- return { content: [{ type: "text", text: JSON.stringify({
928
- watchId,
929
- task,
930
- watching: target,
931
- initialRun: { exitCode: result.exitCode, summary: processed.summary, tokensSaved: processed.tokensSaved },
932
- hint: "File watching active. Call execute_diff with the same command to get changes on next run.",
933
- }) }] };
934
- });
935
- // ── batch tools: read_files, symbols_dir ──────────────────────────────────
936
- 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.", {
937
- files: z.array(z.string()).describe("File paths (relative or absolute)"),
938
- summarize: z.boolean().optional().describe("AI summary instead of full content"),
939
- }, async ({ files, summarize }) => {
940
- const start = Date.now();
941
- const results = {};
942
- for (const f of files.slice(0, 10)) { // max 10 files per call
943
- const filePath = resolvePath(f);
944
- const result = cachedRead(filePath, {});
945
- if (summarize && result.content.length > 500) {
946
- const provider = getOutputProvider();
947
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
948
- const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
949
- const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
950
- model: outputModel,
951
- 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.`,
952
- maxTokens: 300, temperature: 0.2,
953
- });
954
- results[f] = { summary, lines: result.content.split("\n").length };
955
- }
956
- else {
957
- results[f] = { content: result.content, lines: result.content.split("\n").length };
958
- }
959
- }
960
- logCall("read_files", { command: `${files.length} files`, durationMs: Date.now() - start, aiProcessed: !!summarize });
961
- return { content: [{ type: "text", text: JSON.stringify(results) }] };
962
- });
963
- 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.", {
964
- path: z.string().optional().describe("Directory (default: src/)"),
965
- maxFiles: z.number().optional().describe("Max files to scan (default: 10)"),
966
- }, async ({ path: dirPath, maxFiles }) => {
967
- const start = Date.now();
968
- const dir = resolvePath(dirPath ?? "src/");
969
- const limit = maxFiles ?? 10;
970
- // Find source files
971
- const findResult = await 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);
972
- const files = findResult.stdout.split("\n").filter(l => l.trim());
973
- const allSymbols = {};
974
- const provider = getOutputProvider();
975
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
976
- for (const file of files) {
977
- const result = cachedRead(file, {});
978
- if (!result.content || result.content.startsWith("Error:"))
979
- continue;
980
- try {
981
- const content = result.content.length > 6000 ? result.content.slice(0, 6000) : result.content;
982
- const summary = await provider.complete(`File: ${file}\n\n${content}`, {
983
- model: outputModel,
984
- 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.`,
985
- maxTokens: 1500, temperature: 0,
986
- });
987
- const jsonMatch = summary.match(/\[[\s\S]*\]/);
988
- if (jsonMatch)
989
- allSymbols[file] = JSON.parse(jsonMatch[0]);
990
- }
991
- catch { }
992
- }
993
- logCall("symbols_dir", { command: `${files.length} files in ${dir}`, durationMs: Date.now() - start, aiProcessed: true });
994
- return { content: [{ type: "text", text: JSON.stringify({ directory: dir, files: files.length, symbols: allSymbols }) }] };
995
- });
996
- // ── review: AI code review ────────────────────────────────────────────────
997
- server.tool("review", "AI code review of recent changes or specific files. Returns: bugs, security issues, suggestions. One call replaces git diff + manual reading.", {
998
- since: z.string().optional().describe("Git ref to diff against (e.g., 'HEAD~3', 'main')"),
999
- files: z.array(z.string()).optional().describe("Specific files to review"),
1000
- cwd: z.string().optional().describe("Working directory"),
1001
- }, async ({ since, files, cwd }) => {
1002
- const start = Date.now();
1003
- const workDir = cwd ?? process.cwd();
1004
- let content;
1005
- if (files && files.length > 0) {
1006
- const fileContents = files.map(f => {
1007
- const result = cachedRead(resolvePath(f, workDir), {});
1008
- return `=== ${f} ===\n${result.content.slice(0, 4000)}`;
1009
- });
1010
- content = fileContents.join("\n\n");
1011
- }
1012
- else {
1013
- const ref = since ?? "HEAD~1";
1014
- const diff = await exec(`git diff ${ref} --no-color`, workDir, 15000);
1015
- content = diff.stdout.slice(0, 12000);
1016
- }
1017
- const provider = getOutputProvider();
1018
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1019
- const review = await provider.complete(`Review this code:\n\n${content}`, {
1020
- model: outputModel,
1021
- system: `You are a senior code reviewer. Review concisely:
1022
- - Bugs or logic errors
1023
- - Security issues (injection, auth, secrets)
1024
- - Missing error handling
1025
- - Performance concerns
1026
- - Style/naming issues (only if significant)
1027
-
1028
- Format: list issues as "- [severity] file:line description". If clean, say "No issues found."
1029
- Be specific, not generic. Only flag real problems.`,
1030
- maxTokens: 800, temperature: 0.2,
1031
- });
1032
- logCall("review", { command: `review ${since ?? files?.join(",") ?? "HEAD~1"}`, durationMs: Date.now() - start, aiProcessed: true });
1033
- return { content: [{ type: "text", text: JSON.stringify({ review, scope: since ?? files }) }] };
1034
- });
1035
- // ── secrets vault ─────────────────────────────────────────────────────────
1036
- server.tool("store_secret", "Store a secret for use in commands. Agent uses $NAME in commands, we resolve at execution and redact in output.", {
1037
- name: z.string().describe("Secret name (e.g., JIRA_TOKEN)"),
1038
- value: z.string().describe("Secret value"),
1039
- }, async ({ name, value }) => {
1040
- const { existsSync, readFileSync, writeFileSync, chmodSync } = await import("fs");
1041
- const { join } = await import("path");
1042
- const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
1043
- let secrets = {};
1044
- if (existsSync(secretsFile)) {
1045
- try {
1046
- secrets = JSON.parse(readFileSync(secretsFile, "utf8"));
1047
- }
1048
- catch { }
1049
- }
1050
- secrets[name] = value;
1051
- writeFileSync(secretsFile, JSON.stringify(secrets, null, 2));
1052
- try {
1053
- chmodSync(secretsFile, 0o600);
1054
- }
1055
- catch { }
1056
- logCall("store_secret", { command: `store ${name}` });
1057
- return { content: [{ type: "text", text: JSON.stringify({ stored: name, hint: `Use $${name} in commands. Value will be resolved at execution and redacted in output.` }) }] };
1058
- });
1059
- server.tool("list_secrets", "List stored secret names (never values).", async () => {
1060
- const { existsSync, readFileSync } = await import("fs");
1061
- const { join } = await import("path");
1062
- const secretsFile = join(process.env.HOME ?? "~", ".terminal", "secrets.json");
1063
- let names = [];
1064
- if (existsSync(secretsFile)) {
1065
- try {
1066
- names = Object.keys(JSON.parse(readFileSync(secretsFile, "utf8")));
1067
- }
1068
- catch { }
1069
- }
1070
- // Also show env vars that look like secrets
1071
- const envSecrets = Object.keys(process.env).filter(k => /API_KEY|TOKEN|SECRET|PASSWORD/i.test(k));
1072
- return { content: [{ type: "text", text: JSON.stringify({ stored: names, environment: envSecrets }) }] };
1073
- });
1074
- // ── project memory ────────────────────────────────────────────────────────
1075
- server.tool("project_note", "Save or recall notes about the current project. Persists across sessions. Agents pick up where they left off.", {
1076
- save: z.string().optional().describe("Note to save"),
1077
- recall: z.boolean().optional().describe("Return all saved notes"),
1078
- clear: z.boolean().optional().describe("Clear all notes"),
1079
- }, async ({ save, recall, clear }) => {
1080
- const { existsSync, readFileSync, writeFileSync, mkdirSync } = await import("fs");
1081
- const { join } = await import("path");
1082
- const notesDir = join(process.cwd(), ".terminal");
1083
- const notesFile = join(notesDir, "notes.json");
1084
- let notes = [];
1085
- if (existsSync(notesFile)) {
1086
- try {
1087
- notes = JSON.parse(readFileSync(notesFile, "utf8"));
1088
- }
1089
- catch { }
1090
- }
1091
- if (clear) {
1092
- notes = [];
1093
- if (!existsSync(notesDir))
1094
- mkdirSync(notesDir, { recursive: true });
1095
- writeFileSync(notesFile, "[]");
1096
- return { content: [{ type: "text", text: JSON.stringify({ cleared: true }) }] };
1097
- }
1098
- if (save) {
1099
- notes.push({ text: save, timestamp: new Date().toISOString() });
1100
- if (!existsSync(notesDir))
1101
- mkdirSync(notesDir, { recursive: true });
1102
- writeFileSync(notesFile, JSON.stringify(notes, null, 2));
1103
- logCall("project_note", { command: `save: ${save.slice(0, 80)}` });
1104
- return { content: [{ type: "text", text: JSON.stringify({ saved: true, total: notes.length }) }] };
1105
- }
1106
- return { content: [{ type: "text", text: JSON.stringify({ notes, total: notes.length }) }] };
1107
- });
1108
- // ── diff: show what changed ────────────────────────────────────────────────
1109
- server.tool("diff", "Show what changed — git diff with AI summary. One call replaces constructing git diff commands.", {
1110
- ref: z.string().optional().describe("Diff against this ref (default: unstaged changes). Examples: HEAD~1, main, abc123"),
1111
- file: z.string().optional().describe("Diff a specific file only"),
1112
- stat: z.boolean().optional().describe("Show file-level stats only, not full diff (default: false)"),
1113
- cwd: z.string().optional().describe("Working directory"),
1114
- }, async ({ ref, file, stat, cwd }) => {
1115
- const start = Date.now();
1116
- const workDir = cwd ?? process.cwd();
1117
- let cmd = "git diff";
1118
- if (ref)
1119
- cmd += ` ${ref}`;
1120
- if (stat)
1121
- cmd += " --stat";
1122
- if (file)
1123
- cmd += ` -- ${file}`;
1124
- const result = await exec(cmd, workDir, 15000);
1125
- const output = (result.stdout + result.stderr).trim();
1126
- if (!output) {
1127
- return { content: [{ type: "text", text: JSON.stringify({ clean: true, message: "No changes" }) }] };
1128
- }
1129
- const processed = await processOutput(cmd, output);
1130
- logCall("diff", { command: cmd, outputTokens: estimateTokens(output), tokensSaved: processed.tokensSaved, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
1131
- return { content: [{ type: "text", text: JSON.stringify({
1132
- summary: processed.summary,
1133
- lines: output.split("\n").length,
1134
- tokensSaved: processed.tokensSaved,
1135
- }) }] };
1136
- });
1137
- // ── install: add packages, auto-detect package manager ────────────────────
1138
- server.tool("install", "Install packages — auto-detects bun/npm/pnpm/yarn/pip/cargo. Agent says what to install, we figure out how.", {
1139
- packages: z.array(z.string()).describe("Package names to install"),
1140
- dev: z.boolean().optional().describe("Install as dev dependency (default: false)"),
1141
- cwd: z.string().optional().describe("Working directory"),
1142
- }, async ({ packages, dev, cwd }) => {
1143
- const start = Date.now();
1144
- const workDir = cwd ?? process.cwd();
1145
- const { existsSync } = await import("fs");
1146
- const { join } = await import("path");
1147
- let cmd;
1148
- const pkgs = packages.join(" ");
1149
- const devFlag = dev ? " -D" : "";
1150
- if (existsSync(join(workDir, "bun.lockb")) || existsSync(join(workDir, "bun.lock"))) {
1151
- cmd = `bun add${devFlag} ${pkgs}`;
1152
- }
1153
- else if (existsSync(join(workDir, "pnpm-lock.yaml"))) {
1154
- cmd = `pnpm add${devFlag} ${pkgs}`;
1155
- }
1156
- else if (existsSync(join(workDir, "yarn.lock"))) {
1157
- cmd = `yarn add${dev ? " --dev" : ""} ${pkgs}`;
1158
- }
1159
- else if (existsSync(join(workDir, "package.json"))) {
1160
- cmd = `npm install${dev ? " --save-dev" : ""} ${pkgs}`;
1161
- }
1162
- else if (existsSync(join(workDir, "requirements.txt")) || existsSync(join(workDir, "pyproject.toml"))) {
1163
- cmd = `pip install ${pkgs}`;
1164
- }
1165
- else if (existsSync(join(workDir, "Cargo.toml"))) {
1166
- cmd = `cargo add ${pkgs}`;
1167
- }
1168
- else {
1169
- cmd = `npm install${dev ? " --save-dev" : ""} ${pkgs}`;
1170
- }
1171
- const result = await exec(cmd, workDir, 60000);
1172
- const output = (result.stdout + result.stderr).trim();
1173
- const processed = await processOutput(cmd, output);
1174
- logCall("install", { command: cmd, exitCode: result.exitCode, durationMs: Date.now() - start, aiProcessed: processed.aiProcessed });
1175
- return { content: [{ type: "text", text: JSON.stringify({
1176
- exitCode: result.exitCode,
1177
- command: cmd,
1178
- summary: processed.summary,
1179
- }) }] };
1180
- });
1181
- // ── help: tool discoverability ────────────────────────────────────────────
1182
- server.tool("help", "Get recommendations for which terminal tool to use. Describe what you want to do and get the best tool + usage example.", {
1183
- goal: z.string().optional().describe("What you're trying to do (e.g., 'run tests', 'find where login is defined', 'commit my changes')"),
1184
- }, async ({ goal }) => {
1185
- if (!goal) {
1186
- return { content: [{ type: "text", text: JSON.stringify({
1187
- tools: {
1188
- "execute / execute_smart": "Run any command. Smart = AI summary (80% fewer tokens)",
1189
- "run({task})": "Run test/build/lint — auto-detects toolchain",
1190
- "commit / bulk_commit / smart_commit": "Git commit — single, multi, or AI-grouped",
1191
- "diff({ref})": "Show what changed with AI summary",
1192
- "install({packages})": "Add packages — auto-detects bun/npm/pip/cargo",
1193
- "search_content({pattern})": "Grep with structured results",
1194
- "search_files({pattern})": "Find files by glob",
1195
- "symbols({path})": "AI file outline — any language",
1196
- "read_symbol({path, name})": "Read one function/class by name",
1197
- "read_file({path, summarize})": "Read or AI-summarize a file",
1198
- "read_files({files, summarize})": "Multi-file read in one call",
1199
- "symbols_dir({path})": "Symbols for entire directory",
1200
- "review({since})": "AI code review",
1201
- "lookup({file, items})": "Find items in a file by name",
1202
- "edit({file, find, replace})": "Find-replace in file",
1203
- "repo_state": "Git branch + status + log in one call",
1204
- "boot": "Full project context on session start",
1205
- "watch({task})": "Run task on file change",
1206
- "store_secret / list_secrets": "Secrets vault",
1207
- "project_note({save/recall})": "Persistent project notes",
1208
- },
1209
- tips: [
1210
- "Use relative paths — 'src/foo.ts' not '/Users/.../src/foo.ts'",
1211
- "Use your native Read/Write/Edit for file operations when you don't need AI summary",
1212
- "Use search_content for text patterns, symbols for code structure",
1213
- "Use commit for single, bulk_commit for multiple, smart_commit for AI-grouped",
1214
- ],
1215
- }) }] };
1216
- }
1217
- // AI recommends the best tool for the goal
1218
- const provider = getOutputProvider();
1219
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1220
- const recommendation = await provider.complete(`Agent wants to: ${goal}\n\nAvailable tools: execute, execute_smart, run, commit, bulk_commit, smart_commit, diff, install, search_content, search_files, symbols, read_symbol, read_file, read_files, symbols_dir, review, lookup, edit, repo_state, boot, watch, store_secret, list_secrets, project_note, help`, {
1221
- model: outputModel,
1222
- system: `Recommend the best terminal MCP tool for this goal. Return JSON: {"tool": "name", "example": {params}, "why": "one line"}. If multiple tools work, list top 2.`,
1223
- maxTokens: 200, temperature: 0,
1224
- });
1225
- return { content: [{ type: "text", text: recommendation }] };
1226
- });
1227
- // ── batch: multiple operations in one round trip ───────────────────────────
1228
- 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).", {
1229
- ops: z.array(z.object({
1230
- type: z.enum(["execute", "read", "write", "search", "symbols"]).describe("Operation type"),
1231
- command: z.string().optional().describe("Shell command (for execute)"),
1232
- path: z.string().optional().describe("File path (for read/write/symbols)"),
1233
- content: z.string().optional().describe("File content (for write)"),
1234
- pattern: z.string().optional().describe("Search pattern (for search)"),
1235
- summarize: z.boolean().optional().describe("AI summarize (for read)"),
1236
- format: z.enum(["raw", "summary"]).optional().describe("Output format (for execute)"),
1237
- })).describe("Array of operations to run"),
1238
- cwd: z.string().optional().describe("Working directory for all ops"),
1239
- }, async ({ ops, cwd }) => {
1240
- const start = Date.now();
1241
- const workDir = cwd ?? process.cwd();
1242
- const results = [];
1243
- for (let i = 0; i < ops.slice(0, 10).length; i++) {
1244
- const op = ops[i];
1245
- try {
1246
- if (op.type === "execute" && op.command) {
1247
- const result = await exec(op.command, workDir, 30000);
1248
- const output = (result.stdout + result.stderr).trim();
1249
- if (op.format === "summary" && output.split("\n").length > 15) {
1250
- const processed = await processOutput(op.command, output);
1251
- results.push({ op: i, type: "execute", summary: processed.summary, exitCode: result.exitCode, tokensSaved: processed.tokensSaved });
1252
- }
1253
- else {
1254
- results.push({ op: i, type: "execute", output: stripAnsi(output).slice(0, 2000), exitCode: result.exitCode });
1255
- }
1256
- }
1257
- else if (op.type === "read" && op.path) {
1258
- const filePath = resolvePath(op.path, workDir);
1259
- const result = cachedRead(filePath, {});
1260
- if (op.summarize && result.content.length > 500) {
1261
- const provider = getOutputProvider();
1262
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1263
- const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
1264
- const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
1265
- model: outputModel,
1266
- 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.`,
1267
- maxTokens: 300, temperature: 0.2,
1268
- });
1269
- results.push({ op: i, type: "read", path: op.path, summary, lines: result.content.split("\n").length });
1270
- }
1271
- else {
1272
- results.push({ op: i, type: "read", path: op.path, content: result.content, lines: result.content.split("\n").length });
1273
- }
1274
- }
1275
- else if (op.type === "write" && op.path && op.content !== undefined) {
1276
- const filePath = resolvePath(op.path, workDir);
1277
- const { writeFileSync, mkdirSync, existsSync } = await import("fs");
1278
- const { dirname } = await import("path");
1279
- const dir = dirname(filePath);
1280
- if (!existsSync(dir))
1281
- mkdirSync(dir, { recursive: true });
1282
- writeFileSync(filePath, op.content);
1283
- results.push({ op: i, type: "write", path: op.path, ok: true, bytes: op.content.length });
1284
- }
1285
- else if (op.type === "search" && op.pattern) {
1286
- // Search accepts both files and directories — resolve to parent dir if file
1287
- let searchPath = op.path ? resolvePath(op.path, workDir) : workDir;
1288
- try {
1289
- const { statSync } = await import("fs");
1290
- if (statSync(searchPath).isFile())
1291
- searchPath = searchPath.replace(/\/[^/]+$/, "");
1292
- }
1293
- catch { }
1294
- const result = await searchContent(op.pattern, searchPath, {});
1295
- results.push({ op: i, type: "search", pattern: op.pattern, totalMatches: result.totalMatches, files: result.files.slice(0, 10) });
1296
- }
1297
- else if (op.type === "symbols" && op.path) {
1298
- const filePath = resolvePath(op.path, workDir);
1299
- const result = cachedRead(filePath, {});
1300
- if (result.content && !result.content.startsWith("Error:")) {
1301
- const provider = getOutputProvider();
1302
- const outputModel = provider.name === "groq" ? "llama-3.1-8b-instant" : undefined;
1303
- const content = result.content.length > 8000 ? result.content.slice(0, 8000) : result.content;
1304
- const summary = await provider.complete(`File: ${filePath}\n\n${content}`, {
1305
- model: outputModel,
1306
- 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.`,
1307
- maxTokens: 2000, temperature: 0,
1308
- });
1309
- let symbols = [];
1310
- try {
1311
- const m = summary.match(/\[[\s\S]*\]/);
1312
- if (m)
1313
- symbols = JSON.parse(m[0]);
1314
- }
1315
- catch { }
1316
- results.push({ op: i, type: "symbols", path: op.path, symbols });
1317
- }
1318
- else {
1319
- results.push({ op: i, type: "symbols", path: op.path, error: "Cannot read file" });
1320
- }
1321
- }
1322
- }
1323
- catch (err) {
1324
- results.push({ op: i, type: op.type, error: err.message?.slice(0, 200) });
1325
- }
1326
- }
1327
- logCall("batch", { command: `${ops.length} ops`, durationMs: Date.now() - start, aiProcessed: true });
1328
- return { content: [{ type: "text", text: JSON.stringify({ results, total: results.length, durationMs: Date.now() - start }) }] };
1329
- });
1330
- // ── Cross-session memory (mementos SDK) ────────────────────────────────────
1331
- server.tool("remember", "Save a learning about this project for future sessions. Persists across restarts. Use for: project patterns, conventions, toolchain quirks, architectural decisions.", {
1332
- key: z.string().describe("Short key (e.g., 'test-command', 'deploy-process', 'auth-pattern')"),
1333
- value: z.string().describe("What to remember"),
1334
- importance: z.number().optional().describe("1-10, default 7"),
1335
- }, async ({ key, value, importance }) => {
1336
- try {
1337
- const mementos = require("@hasna/mementos");
1338
- mementos.createMemory({ key, value, scope: "shared", category: "knowledge", importance: importance ?? 7 });
1339
- logCall("remember", { command: `remember: ${key}` });
1340
- return { content: [{ type: "text", text: JSON.stringify({ saved: key }) }] };
1341
- }
1342
- catch (e) {
1343
- return { content: [{ type: "text", text: JSON.stringify({ error: e.message?.slice(0, 200) }) }] };
1344
- }
1345
- });
1346
- server.tool("recall", "Recall project memories from previous sessions. Returns all saved learnings, patterns, and decisions for this project.", {
1347
- search: z.string().optional().describe("Search query to filter memories"),
1348
- limit: z.number().optional().describe("Max memories to return (default: 20)"),
1349
- }, async ({ search, limit }) => {
1350
- try {
1351
- const mementos = require("@hasna/mementos");
1352
- let memories;
1353
- if (search) {
1354
- memories = mementos.searchMemories(search, { limit: limit ?? 20 });
1355
- }
1356
- else {
1357
- memories = mementos.listMemories({ scope: "shared", limit: limit ?? 20 });
1358
- }
1359
- const items = (memories ?? []).map((m) => ({ key: m.key, value: m.value, importance: m.importance }));
1360
- logCall("recall", { command: `recall${search ? `: ${search}` : ""}` });
1361
- return { content: [{ type: "text", text: JSON.stringify({ memories: items, total: items.length }) }] };
1362
- }
1363
- catch (e) {
1364
- return { content: [{ type: "text", text: JSON.stringify({ error: e.message?.slice(0, 200), memories: [] }) }] };
1365
- }
1366
- });
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");
1367
48
  return server;
1368
49
  }
1369
50
  // ── main: start MCP server via stdio ─────────────────────────────────────────