context-compress 2026.3.21 → 2026.5.0

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 (115) hide show
  1. package/README.md +258 -44
  2. package/dist/cli/doctor.d.ts.map +1 -1
  3. package/dist/cli/doctor.js +2 -10
  4. package/dist/cli/doctor.js.map +1 -1
  5. package/dist/cli/filter.d.ts +52 -0
  6. package/dist/cli/filter.d.ts.map +1 -0
  7. package/dist/cli/filter.js +200 -0
  8. package/dist/cli/filter.js.map +1 -0
  9. package/dist/cli/index.d.ts +8 -4
  10. package/dist/cli/index.d.ts.map +1 -1
  11. package/dist/cli/index.js +19 -6
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/cli/lite.d.ts +15 -0
  14. package/dist/cli/lite.d.ts.map +1 -0
  15. package/dist/cli/lite.js +37 -0
  16. package/dist/cli/lite.js.map +1 -0
  17. package/dist/cli/setup.d.ts +23 -1
  18. package/dist/cli/setup.d.ts.map +1 -1
  19. package/dist/cli/setup.js +122 -21
  20. package/dist/cli/setup.js.map +1 -1
  21. package/dist/executor.d.ts +7 -1
  22. package/dist/executor.d.ts.map +1 -1
  23. package/dist/executor.js +51 -4
  24. package/dist/executor.js.map +1 -1
  25. package/dist/filters.d.ts +52 -0
  26. package/dist/filters.d.ts.map +1 -0
  27. package/dist/filters.js +719 -0
  28. package/dist/filters.js.map +1 -0
  29. package/dist/hooks/pretooluse.js +57 -0
  30. package/dist/hooks/pretooluse.js.map +1 -1
  31. package/dist/network.d.ts.map +1 -1
  32. package/dist/network.js +11 -0
  33. package/dist/network.js.map +1 -1
  34. package/dist/server.bundle.mjs +1333 -619
  35. package/dist/server.bundle.mjs.map +4 -4
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +44 -610
  38. package/dist/server.js.map +1 -1
  39. package/dist/stats.d.ts +7 -1
  40. package/dist/stats.d.ts.map +1 -1
  41. package/dist/stats.js +65 -0
  42. package/dist/stats.js.map +1 -1
  43. package/dist/store.d.ts +1 -0
  44. package/dist/store.d.ts.map +1 -1
  45. package/dist/store.js +15 -2
  46. package/dist/store.js.map +1 -1
  47. package/dist/tools/batch-execute.d.ts +4 -0
  48. package/dist/tools/batch-execute.d.ts.map +1 -0
  49. package/dist/tools/batch-execute.js +75 -0
  50. package/dist/tools/batch-execute.js.map +1 -0
  51. package/dist/tools/context.d.ts +17 -0
  52. package/dist/tools/context.d.ts.map +1 -0
  53. package/dist/tools/context.js +2 -0
  54. package/dist/tools/context.js.map +1 -0
  55. package/dist/tools/discover.d.ts +4 -0
  56. package/dist/tools/discover.d.ts.map +1 -0
  57. package/dist/tools/discover.js +65 -0
  58. package/dist/tools/discover.js.map +1 -0
  59. package/dist/tools/execute-file.d.ts +4 -0
  60. package/dist/tools/execute-file.d.ts.map +1 -0
  61. package/dist/tools/execute-file.js +66 -0
  62. package/dist/tools/execute-file.js.map +1 -0
  63. package/dist/tools/execute.d.ts +4 -0
  64. package/dist/tools/execute.d.ts.map +1 -0
  65. package/dist/tools/execute.js +54 -0
  66. package/dist/tools/execute.js.map +1 -0
  67. package/dist/tools/fetch-and-index.d.ts +4 -0
  68. package/dist/tools/fetch-and-index.d.ts.map +1 -0
  69. package/dist/tools/fetch-and-index.js +91 -0
  70. package/dist/tools/fetch-and-index.js.map +1 -0
  71. package/dist/tools/index-content.d.ts +4 -0
  72. package/dist/tools/index-content.d.ts.map +1 -0
  73. package/dist/tools/index-content.js +85 -0
  74. package/dist/tools/index-content.js.map +1 -0
  75. package/dist/tools/search.d.ts +4 -0
  76. package/dist/tools/search.d.ts.map +1 -0
  77. package/dist/tools/search.js +57 -0
  78. package/dist/tools/search.js.map +1 -0
  79. package/dist/tools/stats.d.ts +4 -0
  80. package/dist/tools/stats.d.ts.map +1 -0
  81. package/dist/tools/stats.js +10 -0
  82. package/dist/tools/stats.js.map +1 -0
  83. package/dist/types.d.ts +11 -0
  84. package/dist/types.d.ts.map +1 -1
  85. package/dist/util/auto-mode.d.ts +40 -0
  86. package/dist/util/auto-mode.d.ts.map +1 -0
  87. package/dist/util/auto-mode.js +181 -0
  88. package/dist/util/auto-mode.js.map +1 -0
  89. package/dist/util/fetch-code.d.ts +10 -0
  90. package/dist/util/fetch-code.d.ts.map +1 -0
  91. package/dist/util/fetch-code.js +87 -0
  92. package/dist/util/fetch-code.js.map +1 -0
  93. package/dist/util/intent-filter.d.ts +17 -0
  94. package/dist/util/intent-filter.d.ts.map +1 -0
  95. package/dist/util/intent-filter.js +28 -0
  96. package/dist/util/intent-filter.js.map +1 -0
  97. package/dist/util/label.d.ts +4 -0
  98. package/dist/util/label.d.ts.map +1 -0
  99. package/dist/util/label.js +14 -0
  100. package/dist/util/label.js.map +1 -0
  101. package/dist/util/path.d.ts +8 -0
  102. package/dist/util/path.d.ts.map +1 -0
  103. package/dist/util/path.js +21 -0
  104. package/dist/util/path.js.map +1 -0
  105. package/dist/util/stream-compress.d.ts +36 -0
  106. package/dist/util/stream-compress.d.ts.map +1 -0
  107. package/dist/util/stream-compress.js +104 -0
  108. package/dist/util/stream-compress.js.map +1 -0
  109. package/dist/util/version.d.ts +2 -0
  110. package/dist/util/version.d.ts.map +1 -0
  111. package/dist/util/version.js +15 -0
  112. package/dist/util/version.js.map +1 -0
  113. package/docs/token-reduction-report.md +164 -88
  114. package/hooks/pretooluse.mjs +38 -0
  115. package/package.json +5 -4
package/dist/server.js CHANGED
@@ -1,62 +1,28 @@
1
- import { readFileSync, realpathSync, statSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
1
+ import { join } from "node:path";
4
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
3
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
- import { z } from "zod";
7
4
  import { SubprocessExecutor } from "./executor.js";
8
5
  import { debug } from "./logger.js";
9
- import { isPrivateHost, resolveAndValidate } from "./network.js";
10
6
  import { detectRuntimes, hasBun } from "./runtime/index.js";
11
7
  import { SessionTracker } from "./stats.js";
12
8
  import { ContentStore, cleanupStaleDbs } from "./store.js";
13
- import { ALL_LANGUAGES } from "./types.js";
14
- import { detectInjectionPatterns, limitConcurrency } from "./utils.js";
15
- const LANGUAGE_ENUM = ALL_LANGUAGES;
9
+ import { registerBatchExecuteTool } from "./tools/batch-execute.js";
10
+ import { registerDiscoverTool } from "./tools/discover.js";
11
+ import { registerExecuteFileTool } from "./tools/execute-file.js";
12
+ import { registerExecuteTool } from "./tools/execute.js";
13
+ import { registerFetchAndIndexTool } from "./tools/fetch-and-index.js";
14
+ import { registerIndexTool } from "./tools/index-content.js";
15
+ import { registerSearchTool } from "./tools/search.js";
16
+ import { registerStatsTool } from "./tools/stats.js";
17
+ import { createIntentFilter } from "./util/intent-filter.js";
18
+ import { getVersion } from "./util/version.js";
16
19
  const projectDir = process.env.CLAUDE_PROJECT_DIR ?? process.cwd();
17
- function isWithinProject(absPath) {
18
- try {
19
- const normalized = realpathSync(resolve(absPath));
20
- const realProjectDir = realpathSync(projectDir);
21
- return normalized === realProjectDir || normalized.startsWith(`${realProjectDir}/`);
22
- }
23
- catch {
24
- // Path doesn't exist yet — fall back to string check
25
- const normalized = resolve(absPath);
26
- return normalized === projectDir || normalized.startsWith(`${projectDir}/`);
27
- }
28
- }
29
- function getVersion() {
30
- try {
31
- const __dirname = dirname(fileURLToPath(import.meta.url));
32
- const pkgPath = join(__dirname, "..", "package.json");
33
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
34
- return pkg.version ?? "1.0.0";
35
- }
36
- catch {
37
- return "1.0.0";
38
- }
39
- }
40
- /** Shorten labels based on compression level */
41
- function compactLabel(normal, level) {
42
- if (level === "ultra") {
43
- // Ultra: strip markdown, minimize verbiage
44
- return normal
45
- .replace(/\*\*/g, "")
46
- .replace(/Use search\(queries: \[\.\.\.]\) to retrieve.*$/gm, "→ search() for more")
47
- .replace(/Searchable terms: .+$/gm, "");
48
- }
49
- if (level === "compact") {
50
- return normal.replace(/Use search\(queries: \[\.\.\.]\) to retrieve full content of any section\./, "→ search() for details");
51
- }
52
- return normal;
53
- }
20
+ const MAX_CONCURRENT_EXECUTIONS = 8;
21
+ const EXECUTION_LIMIT_ERROR = "Error: too many concurrent executions. Try again shortly.";
54
22
  export async function createServer(config) {
55
23
  const version = getVersion();
56
24
  debug("Version:", version);
57
- // Cleanup stale databases from previous sessions
58
25
  cleanupStaleDbs();
59
- // Detect runtimes in parallel
60
26
  const runtimes = await detectRuntimes();
61
27
  const bunDetected = hasBun(runtimes);
62
28
  debug("Runtimes detected:", runtimes.size);
@@ -71,10 +37,11 @@ export async function createServer(config) {
71
37
  store = new ContentStore(":memory:");
72
38
  dbFallback = true;
73
39
  }
74
- const tracker = new SessionTracker();
40
+ const cumulativeFile = config.persistDb
41
+ ? join(config.dbDir ?? join(projectDir, ".context-compress"), "stats.json")
42
+ : undefined;
43
+ const tracker = new SessionTracker(cumulativeFile);
75
44
  let activeExecutions = 0;
76
- const MAX_CONCURRENT_EXECUTIONS = 8;
77
- const EXECUTION_LIMIT_ERROR = "Error: too many concurrent executions. Try again shortly.";
78
45
  async function withExecutionLimit(fn) {
79
46
  if (activeExecutions >= MAX_CONCURRENT_EXECUTIONS) {
80
47
  throw new Error(EXECUTION_LIMIT_ERROR);
@@ -87,37 +54,25 @@ export async function createServer(config) {
87
54
  activeExecutions--;
88
55
  }
89
56
  }
90
- function applyIntentFilter(output, intent, sourceLabel) {
91
- if (Buffer.byteLength(output) <= config.intentSearchThreshold)
92
- return output;
93
- const indexed = store.index(output, sourceLabel);
94
- tracker.trackIndexed(Buffer.byteLength(output));
95
- const searchResults = store.search(intent, { limit: 3 });
96
- const terms = store.getDistinctiveTerms(indexed.sourceId);
97
- let filtered = `Indexed ${indexed.totalChunks} sections from ${sourceLabel}.\n`;
98
- filtered += `${searchResults.results.length} sections matched "${intent}":\n\n`;
99
- for (const hit of searchResults.results) {
100
- filtered += ` - **${hit.title}**: ${hit.snippet.slice(0, 200)}\n`;
57
+ const applyIntentFilter = createIntentFilter({ config, store, tracker });
58
+ const shutdown = () => {
59
+ try {
60
+ tracker.saveCumulative();
101
61
  }
102
- if (terms.length > 0 && config.compressionLevel !== "ultra") {
103
- filtered += `\nSearchable terms: ${terms.join(", ")}\n`;
62
+ catch {
63
+ /* ignore */
104
64
  }
105
- filtered += "\nUse search(queries: [...]) to retrieve full content of any section.";
106
- return compactLabel(filtered, config.compressionLevel);
107
- }
108
- // Graceful shutdown: kill subprocesses and close the database on exit
109
- const shutdown = () => {
110
65
  try {
111
66
  executor.shutdown();
112
67
  }
113
68
  catch {
114
- // Ignore errors during shutdown
69
+ /* ignore */
115
70
  }
116
71
  try {
117
72
  store.close();
118
73
  }
119
74
  catch {
120
- // Ignore errors during shutdown
75
+ /* ignore */
121
76
  }
122
77
  };
123
78
  process.on("SIGINT", shutdown);
@@ -133,474 +88,29 @@ export async function createServer(config) {
133
88
  shutdown();
134
89
  process.exit(1);
135
90
  });
136
- // Search throttling state
137
- const searchCalls = [];
138
91
  const server = new McpServer({
139
92
  name: "context-compress",
140
93
  version,
141
94
  });
142
- // ─── Tool: execute ──────────────────────────────────────
143
- server.tool("execute", `Execute code in a sandboxed subprocess. Only stdout enters context — raw data stays in the subprocess. Use instead of bash/cat when output would exceed ~5KB. ${bunDetected ? "(Bun detected — JS/TS runs 3-5x faster) " : ""}Available: ${ALL_LANGUAGES.join(", ")}.
144
-
145
- PREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, pytest), git queries (git log, git diff), data processing, and ANY CLI command that may produce large output. Bash should only be used for file mutations, git writes, and navigation.`, {
146
- language: z.enum(LANGUAGE_ENUM).describe("Runtime language"),
147
- code: z
148
- .string()
149
- .describe("Source code to execute. Use console.log (JS/TS), print (Python/Ruby/Perl/R), echo (Shell), echo (PHP), fmt.Println (Go), or IO.puts (Elixir) to output a summary to context."),
150
- intent: z
151
- .string()
152
- .optional()
153
- .describe("What you're looking for in the output. When provided and output is large (>5KB), indexes output into knowledge base and returns section titles + previews — not full content. Use search(queries: [...]) to retrieve specific sections."),
154
- timeout: z.number().default(30000).describe("Max execution time in ms"),
155
- }, async ({ language, code, intent, timeout }) => {
156
- const codeBytes = Buffer.byteLength(code);
157
- if (codeBytes > 1_024_000) {
158
- return {
159
- content: [
160
- {
161
- type: "text",
162
- text: `Error: code too large (${(codeBytes / 1024).toFixed(0)}KB). Max 1MB.`,
163
- },
164
- ],
165
- };
166
- }
167
- let result;
168
- try {
169
- result = await withExecutionLimit(() => executor.execute({ language, code, timeout }));
170
- }
171
- catch (e) {
172
- const msg = e instanceof Error ? e.message : String(e);
173
- return { content: [{ type: "text", text: msg }] };
174
- }
175
- if (result.networkBytes) {
176
- tracker.trackSandboxed(result.networkBytes);
177
- }
178
- let output = result.stdout;
179
- if (result.stderr && result.exitCode !== 0) {
180
- output += `\n\nSTDERR:\n${result.stderr}`;
181
- }
182
- // Intent-driven filtering for large outputs
183
- if (intent) {
184
- output = applyIntentFilter(output, intent, `execute:${language}`);
185
- }
186
- const responseBytes = Buffer.byteLength(output);
187
- tracker.trackCall("execute", responseBytes);
188
- return { content: [{ type: "text", text: output }] };
189
- });
190
- // ─── Tool: execute_file ─────────────────────────────────
191
- server.tool("execute_file", "Read a file and process it without loading contents into context. The file is read into a FILE_CONTENT variable inside the sandbox. Only your printed summary enters context.\n\nPREFER THIS OVER Read/cat for: log files, data files (CSV, JSON, XML), large source files for analysis, and any file where you need to extract specific information rather than read the entire content.", {
192
- path: z.string().describe("Absolute file path or relative to project root"),
193
- language: z.enum(LANGUAGE_ENUM).describe("Runtime language"),
194
- code: z
195
- .string()
196
- .describe("Code to process FILE_CONTENT. Print summary via console.log/print/echo/IO.puts."),
197
- intent: z.string().optional().describe("What you're looking for in the output."),
198
- timeout: z.number().default(30000).describe("Max execution time in ms"),
199
- }, async ({ path: filePath, language, code, intent, timeout }) => {
200
- const codeBytes = Buffer.byteLength(code);
201
- if (codeBytes > 1_024_000) {
202
- return {
203
- content: [
204
- {
205
- type: "text",
206
- text: `Error: code too large (${(codeBytes / 1024).toFixed(0)}KB). Max 1MB.`,
207
- },
208
- ],
209
- };
210
- }
211
- const absPath = resolve(projectDir, filePath);
212
- if (!isWithinProject(absPath)) {
213
- return {
214
- content: [
215
- {
216
- type: "text",
217
- text: `Error: path "${filePath}" is outside the project directory`,
218
- },
219
- ],
220
- };
221
- }
222
- let result;
223
- try {
224
- result = await withExecutionLimit(() => executor.executeFile({
225
- language,
226
- code,
227
- filePath: absPath,
228
- timeout,
229
- }));
230
- }
231
- catch (e) {
232
- const msg = e instanceof Error ? e.message : String(e);
233
- return { content: [{ type: "text", text: msg }] };
234
- }
235
- let output = result.stdout;
236
- if (result.stderr && result.exitCode !== 0) {
237
- output += `\n\nSTDERR:\n${result.stderr}`;
238
- }
239
- // Intent-driven filtering
240
- if (intent) {
241
- output = applyIntentFilter(output, intent, `file:${filePath}`);
242
- }
243
- const responseBytes = Buffer.byteLength(output);
244
- tracker.trackCall("execute_file", responseBytes);
245
- return { content: [{ type: "text", text: output }] };
246
- });
247
- // ─── Tool: index ────────────────────────────────────────
248
- server.tool("index", "Index documentation or knowledge content into a searchable BM25 knowledge base. Chunks markdown by headings (keeping code blocks intact) and stores in ephemeral FTS5 database. The full content does NOT stay in context — only a brief summary is returned.\n\nWHEN TO USE:\n- Documentation (API docs, framework guides, code examples)\n- README files, migration guides, changelog entries\n- Any content with code examples you may need to reference precisely\n\nAfter indexing, use 'search' to retrieve specific sections on-demand.", {
249
- content: z
250
- .string()
251
- .optional()
252
- .describe("Raw text/markdown to index. Provide this OR path, not both."),
253
- path: z
254
- .string()
255
- .optional()
256
- .describe("File path to read and index (content never enters context)."),
257
- source: z.string().optional().describe("Label for the indexed content"),
258
- }, async ({ content, path: filePath, source }) => {
259
- let text;
260
- let label = source ?? "indexed content";
261
- if (filePath) {
262
- const absPath = resolve(projectDir, filePath);
263
- if (!isWithinProject(absPath)) {
264
- return {
265
- content: [
266
- {
267
- type: "text",
268
- text: `Error: path "${filePath}" is outside the project directory`,
269
- },
270
- ],
271
- };
272
- }
273
- try {
274
- const fileStat = statSync(absPath);
275
- if (fileStat.size > 50 * 1024 * 1024) {
276
- return {
277
- content: [
278
- {
279
- type: "text",
280
- text: `Error: file "${filePath}" is too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB). Max 50MB.`,
281
- },
282
- ],
283
- };
284
- }
285
- text = readFileSync(absPath, "utf-8");
286
- label = source ?? filePath;
287
- }
288
- catch (e) {
289
- const msg = e instanceof Error ? e.message : String(e);
290
- return {
291
- content: [{ type: "text", text: `Error reading "${filePath}": ${msg}` }],
292
- };
293
- }
294
- }
295
- else if (content) {
296
- const contentBytes = Buffer.byteLength(content);
297
- if (contentBytes > 50 * 1024 * 1024) {
298
- return {
299
- content: [
300
- {
301
- type: "text",
302
- text: `Error: content too large (${(contentBytes / 1024 / 1024).toFixed(1)}MB). Max 50MB.`,
303
- },
304
- ],
305
- };
306
- }
307
- text = content;
308
- }
309
- else {
310
- return {
311
- content: [{ type: "text", text: "Error: provide either 'content' or 'path'" }],
312
- };
313
- }
314
- const result = store.index(text, label);
315
- tracker.trackIndexed(Buffer.byteLength(text));
316
- const summary = `Indexed "${label}": ${result.totalChunks} chunks (${result.codeChunks} with code). Use search(queries: [...]) to retrieve sections.`;
317
- tracker.trackCall("index", Buffer.byteLength(summary));
318
- return { content: [{ type: "text", text: summary }] };
319
- });
320
- // ─── Tool: search ───────────────────────────────────────
321
- server.tool("search", "Search indexed content. Pass ALL search questions as queries array in ONE call.\n\nTIPS: 2-4 specific terms per query. Use 'source' to scope results.", {
322
- queries: z
323
- .array(z.string())
324
- .describe("Array of search queries. Batch ALL questions in one call."),
325
- source: z
326
- .string()
327
- .optional()
328
- .describe("Filter to a specific indexed source (partial match)."),
329
- limit: z.number().default(3).describe("Results per query (default: 3)"),
330
- }, async ({ queries, source, limit }) => {
331
- // Progressive throttling
332
- const now = Date.now();
333
- searchCalls.push(now);
334
- // Clean old entries outside window
335
- while (searchCalls.length > 0 && searchCalls[0] < now - config.searchWindowMs) {
336
- searchCalls.shift();
337
- }
338
- const callCount = searchCalls.length;
339
- if (callCount > config.searchBlockAfter) {
340
- const msg = "Too many search calls in quick succession. Use batch_execute instead to run commands and search in one call.";
341
- tracker.trackCall("search", Buffer.byteLength(msg));
342
- return { content: [{ type: "text", text: msg }] };
343
- }
344
- const effectiveLimit = callCount > config.searchReduceAfter ? 1 : Math.min(limit, config.searchLimit);
345
- const allResults = [];
346
- let totalBytes = 0;
347
- for (const query of queries) {
348
- if (totalBytes > config.searchMaxBytes)
349
- break;
350
- const result = store.search(query, { source, limit: effectiveLimit });
351
- let block = `## ${query}\n`;
352
- if (result.corrected) {
353
- block += `(corrected to: "${result.corrected}")\n`;
354
- }
355
- if (result.results.length === 0) {
356
- block += "No results found.\n";
357
- }
358
- else {
359
- for (const hit of result.results) {
360
- block += `\n--- [${hit.source}] ---\n### ${hit.title}\n\n${hit.snippet}\n`;
361
- }
362
- }
363
- allResults.push(block);
364
- totalBytes += Buffer.byteLength(block);
365
- }
366
- if (callCount > config.searchReduceAfter) {
367
- allResults.push(`\n⚠ Search rate limited (${callCount} calls in ${config.searchWindowMs / 1000}s). Results reduced to 1 per query.`);
368
- }
369
- const output = allResults.join("\n---\n\n");
370
- tracker.trackCall("search", Buffer.byteLength(output));
371
- return { content: [{ type: "text", text: output }] };
372
- });
373
- // ─── Tool: fetch_and_index ──────────────────────────────
374
- server.tool("fetch_and_index", "Fetches URL content, converts HTML to markdown, indexes into searchable knowledge base, and returns a ~3KB preview. Full content stays in sandbox — use search() for deeper lookups.\n\nBetter than WebFetch: preview is immediate, full content is searchable, raw HTML never enters context.", {
375
- url: z.string().describe("The URL to fetch and index"),
376
- source: z.string().optional().describe("Label for the indexed content"),
377
- }, async ({ url, source }) => {
378
- // SSRF protection: only allow http/https and block internal addresses
379
- try {
380
- const parsed = new URL(url);
381
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
382
- return {
383
- content: [{ type: "text", text: "Error: only http/https URLs are allowed" }],
384
- };
385
- }
386
- if (isPrivateHost(parsed.hostname)) {
387
- return {
388
- content: [
389
- { type: "text", text: "Error: internal/private URLs are not allowed" },
390
- ],
391
- };
392
- }
393
- }
394
- catch {
395
- return {
396
- content: [{ type: "text", text: `Error: invalid URL "${url}"` }],
397
- };
398
- }
399
- // DNS rebinding protection: resolve hostname and verify the IP is not private
400
- let resolvedIp = null;
401
- try {
402
- const validated = await resolveAndValidate(url);
403
- resolvedIp = validated.resolvedIp;
404
- }
405
- catch (err) {
406
- return {
407
- content: [
408
- {
409
- type: "text",
410
- text: `Error: ${err instanceof Error ? err.message : "DNS validation failed"}`,
411
- },
412
- ],
413
- };
414
- }
415
- const label = source ?? url;
416
- // Use executor to fetch and convert HTML to markdown in subprocess
417
- const fetchCode = buildFetchCode(url, resolvedIp);
418
- let result;
419
- try {
420
- result = await withExecutionLimit(() => executor.execute({
421
- language: "javascript",
422
- code: fetchCode,
423
- timeout: 30_000,
424
- }));
425
- }
426
- catch (e) {
427
- const msg = e instanceof Error ? e.message : String(e);
428
- return { content: [{ type: "text", text: msg }] };
429
- }
430
- if (result.exitCode !== 0 || !result.stdout.trim()) {
431
- const errMsg = `Failed to fetch ${url}: ${result.stderr || "empty response"}`;
432
- tracker.trackCall("fetch_and_index", Buffer.byteLength(errMsg));
433
- return { content: [{ type: "text", text: errMsg }] };
434
- }
435
- const markdown = result.stdout;
436
- tracker.trackSandboxed(result.networkBytes ?? 0);
437
- const injectionWarnings = detectInjectionPatterns(markdown);
438
- const indexed = store.index(markdown, label);
439
- tracker.trackIndexed(Buffer.byteLength(markdown));
440
- // Return ~3KB preview
441
- const preview = markdown.slice(0, 3072);
442
- const terms = store.getDistinctiveTerms(indexed.sourceId);
443
- let output = `Indexed "${label}": ${indexed.totalChunks} chunks.\n\n`;
444
- output += `**Preview:**\n${preview}`;
445
- if (markdown.length > 3072)
446
- output += "\n…(truncated)";
447
- if (terms.length > 0) {
448
- output += `\n\nSearchable terms: ${terms.join(", ")}`;
449
- }
450
- output += "\n\nUse search(queries: [...]) to retrieve full content of any section.";
451
- if (injectionWarnings.length > 0) {
452
- output += `\n\n⚠ Content safety notice: detected patterns (${injectionWarnings.join(", ")}). Review indexed content before relying on it.`;
453
- }
454
- tracker.trackCall("fetch_and_index", Buffer.byteLength(output));
455
- return { content: [{ type: "text", text: output }] };
456
- });
457
- // ─── Tool: batch_execute ────────────────────────────────
458
- server.tool("batch_execute", "Execute multiple commands in ONE call, auto-index all output, and search with multiple queries. Returns search results directly — no follow-up calls needed.\n\nTHIS IS THE PRIMARY TOOL. Use this instead of multiple execute() calls.\n\nOne batch_execute call replaces 30+ execute calls + 10+ search calls.\nProvide all commands to run and all queries to search — everything happens in one round trip.", {
459
- commands: z
460
- .array(z.object({
461
- label: z.string().describe("Section header for this command's output"),
462
- command: z.string().describe("Shell command to execute"),
463
- }))
464
- .describe("Commands to execute as a batch."),
465
- queries: z
466
- .array(z.string())
467
- .describe("Search queries to extract information from indexed output. Use 5-8 comprehensive queries."),
468
- timeout: z.number().default(60000).describe("Max execution time in ms (default: 60s)"),
469
- }, async ({ commands, queries, timeout }) => {
470
- // Execute commands with bounded concurrency (max 4 parallel)
471
- const commandResults = await limitConcurrency(commands.map((cmd) => async () => {
472
- const result = await withExecutionLimit(() => executor.execute({
473
- language: "shell",
474
- code: cmd.command,
475
- timeout,
476
- }));
477
- return { label: cmd.label, result };
478
- }), 4);
479
- // Build combined output with markdown sections
480
- let combined = "";
481
- const inventory = [];
482
- for (let i = 0; i < commandResults.length; i++) {
483
- const settled = commandResults[i];
484
- const label = commands[i].label;
485
- if (settled.status === "fulfilled") {
486
- const { result } = settled.value;
487
- const output = result.stdout || "(no output)";
488
- combined += `## ${label}\n\n${output}\n\n`;
489
- const lineCount = output.split("\n").length;
490
- inventory.push(`- **${label}**: ${lineCount} lines`);
491
- }
492
- else {
493
- combined += `## ${label}\n\n(error: ${settled.reason})\n\n`;
494
- inventory.push(`- **${label}**: error`);
495
- }
496
- }
497
- // Index combined output
498
- const indexed = store.index(combined, "batch_execute");
499
- tracker.trackIndexed(Buffer.byteLength(combined));
500
- // Run all search queries
501
- const searchResults = [];
502
- let totalBytes = 0;
503
- for (const query of queries) {
504
- if (totalBytes > config.batchMaxBytes)
505
- break;
506
- // Try scoped search first, then global fallback
507
- let result = store.search(query, { source: "batch_execute", limit: 5 });
508
- if (result.results.length === 0) {
509
- result = store.search(query, { limit: 5 });
510
- }
511
- let block = `## ${query}\n\n`;
512
- if (result.results.length === 0) {
513
- block += "No results found.\n";
514
- }
515
- else {
516
- for (const hit of result.results) {
517
- block += `--- [${hit.source}] ---\n### ${hit.title}\n\n${hit.snippet}\n\n`;
518
- }
519
- }
520
- searchResults.push(block);
521
- totalBytes += Buffer.byteLength(block);
522
- }
523
- const terms = store.getDistinctiveTerms(indexed.sourceId);
524
- let output = `**Inventory** (${commands.length} commands):\n${inventory.join("\n")}\n\n`;
525
- output += searchResults.join("\n---\n\n");
526
- if (terms.length > 0) {
527
- output += `\n\nSearchable terms: ${terms.join(", ")}`;
528
- }
529
- tracker.trackCall("batch_execute", Buffer.byteLength(output));
530
- return { content: [{ type: "text", text: output }] };
531
- });
532
- // ─── Tool: stats ────────────────────────────────────────
533
- server.tool("stats", "Returns context consumption statistics for the current session. Shows total bytes returned to context, breakdown by tool, call counts, estimated token usage, context savings ratio, and visual charts.", {}, async () => {
534
- const report = tracker.formatReport();
535
- tracker.trackCall("stats", Buffer.byteLength(report));
536
- return { content: [{ type: "text", text: report }] };
537
- });
538
- // ─── Tool: discover ─────────────────────────────────────
539
- server.tool("discover", "Shows what's in the knowledge base and suggests optimization opportunities. Lists all indexed sources, chunk counts, searchable terms, and recommends next actions. Use this to understand what data is available for search.", {}, async () => {
540
- const storeStats = store.getStats();
541
- const snap = tracker.getSnapshot();
542
- const lines = [];
543
- lines.push("## Knowledge Base Discovery\n");
544
- if (storeStats.totalSources === 0) {
545
- lines.push("No content indexed yet. Use these tools to build the knowledge base:\n");
546
- lines.push("- `batch_execute` — run commands and auto-index output");
547
- lines.push("- `execute` with `intent` — auto-indexes large output");
548
- lines.push("- `index` — index documentation or files");
549
- lines.push("- `fetch_and_index` — fetch and index web pages");
550
- }
551
- else {
552
- lines.push("| Metric | Value |");
553
- lines.push("|--------|-------|");
554
- lines.push(`| Indexed sources | ${storeStats.totalSources} |`);
555
- lines.push(`| Total chunks | ${storeStats.totalChunks} |`);
556
- lines.push(`| Vocabulary size | ${storeStats.vocabularySize} |`);
557
- lines.push(`| Trigram index | ${storeStats.hasTrigramTable ? "active" : "lazy (not yet needed)"} |`);
558
- // List indexed sources
559
- const sources = store.listSources();
560
- if (sources.length > 0) {
561
- lines.push("\n### Indexed Sources\n");
562
- for (const src of sources) {
563
- lines.push(`- **${src.label}** — ${src.chunkCount} chunks${src.codeChunks > 0 ? ` (${src.codeChunks} with code)` : ""}`);
564
- }
565
- }
566
- // Show top searchable terms
567
- const terms = store.getDistinctiveTerms();
568
- if (terms.length > 0) {
569
- lines.push("\n### Top Searchable Terms\n");
570
- lines.push(terms.slice(0, 20).join(", "));
571
- }
572
- }
573
- // Optimization suggestions
574
- lines.push("\n### Optimization Suggestions\n");
575
- const totalCalls = Object.values(snap.calls).reduce((a, b) => a + b, 0);
576
- if (totalCalls === 0) {
577
- lines.push("- Start by using `batch_execute` to run multiple commands at once");
578
- }
579
- else {
580
- const searchCalls = snap.calls.search ?? 0;
581
- const executeCalls = snap.calls.execute ?? 0;
582
- const batchCalls = snap.calls.batch_execute ?? 0;
583
- if (executeCalls > 3 && batchCalls === 0) {
584
- lines.push("- **Use batch_execute** — you've made multiple execute calls that could be batched into one");
585
- }
586
- if (searchCalls > 5) {
587
- lines.push("- **Batch your searches** — pass multiple queries in a single search() call");
588
- }
589
- if (storeStats.totalChunks > 50) {
590
- lines.push("- **Use source filtering** — scope searches with `source` parameter for faster, targeted results");
591
- }
592
- if (storeStats.totalSources === 0 && totalCalls > 2) {
593
- lines.push("- **Index more content** — use `intent` parameter in execute calls to auto-index large output");
594
- }
595
- }
596
- if (dbFallback) {
597
- lines.push("\n⚠ **Warning:** Persistent DB creation failed — using in-memory storage. Indexed data will not survive restarts.");
598
- }
599
- const output = lines.join("\n");
600
- tracker.trackCall("discover", Buffer.byteLength(output));
601
- return { content: [{ type: "text", text: output }] };
602
- });
603
- // ─── Transport ──────────────────────────────────────────
95
+ const ctx = {
96
+ config,
97
+ store,
98
+ tracker,
99
+ executor,
100
+ projectDir,
101
+ bunDetected,
102
+ dbFallback,
103
+ withExecutionLimit,
104
+ applyIntentFilter,
105
+ };
106
+ registerExecuteTool(server, ctx);
107
+ registerExecuteFileTool(server, ctx);
108
+ registerIndexTool(server, ctx);
109
+ registerSearchTool(server, ctx);
110
+ registerFetchAndIndexTool(server, ctx);
111
+ registerBatchExecuteTool(server, ctx);
112
+ registerStatsTool(server, ctx);
113
+ registerDiscoverTool(server, ctx);
604
114
  return {
605
115
  async start() {
606
116
  const transport = new StdioServerTransport();
@@ -609,80 +119,4 @@ PREFER THIS OVER BASH for: API calls (gh, curl, aws), test runners (npm test, py
609
119
  },
610
120
  };
611
121
  }
612
- // ─── HTML to Markdown conversion code (runs in subprocess) ──
613
- function buildFetchCode(url, resolvedIp) {
614
- let fetchSetup;
615
- if (resolvedIp) {
616
- // Pin connection to the resolved IP to prevent DNS rebinding (TOCTOU)
617
- const pinnedUrl = new URL(url);
618
- const originalHost = pinnedUrl.host;
619
- pinnedUrl.hostname = resolvedIp;
620
- fetchSetup = `
621
- const url = ${JSON.stringify(pinnedUrl.toString())};
622
- const resp = await fetch(url, { headers: { 'Host': ${JSON.stringify(originalHost)} }, redirect: 'error' });`;
623
- }
624
- else {
625
- fetchSetup = `
626
- const url = ${JSON.stringify(url)};
627
- const resp = await fetch(url, { redirect: 'error' });`;
628
- }
629
- return `${fetchSetup}
630
- if (!resp.ok) { console.error("HTTP " + resp.status); process.exit(1); }
631
- const cl = resp.headers.get('content-length');
632
- if (cl && parseInt(cl, 10) > 10 * 1024 * 1024) {
633
- console.error("Response too large: " + cl + " bytes"); process.exit(1);
634
- }
635
- const html = await resp.text();
636
- if (html.length > 10 * 1024 * 1024) {
637
- console.error("Response body too large: " + html.length + " chars"); process.exit(1);
638
- }
639
-
640
- // Strip unwanted tags
641
- let md = html
642
- .replace(/<script[^>]*>[\\s\\S]*?<\\/script>/gi, "")
643
- .replace(/<style[^>]*>[\\s\\S]*?<\\/style>/gi, "")
644
- .replace(/<nav[^>]*>[\\s\\S]*?<\\/nav>/gi, "")
645
- .replace(/<header[^>]*>[\\s\\S]*?<\\/header>/gi, "")
646
- .replace(/<footer[^>]*>[\\s\\S]*?<\\/footer>/gi, "");
647
-
648
- // Convert headings
649
- md = md.replace(/<h1[^>]*>(.*?)<\\/h1>/gi, "# $1\\n");
650
- md = md.replace(/<h2[^>]*>(.*?)<\\/h2>/gi, "## $1\\n");
651
- md = md.replace(/<h3[^>]*>(.*?)<\\/h3>/gi, "### $1\\n");
652
- md = md.replace(/<h4[^>]*>(.*?)<\\/h4>/gi, "#### $1\\n");
653
-
654
- // Convert code blocks
655
- md = md.replace(/<pre[^>]*><code[^>]*>(.*?)<\\/code><\\/pre>/gis, "\`\`\`\\n$1\\n\`\`\`\\n");
656
- md = md.replace(/<code[^>]*>(.*?)<\\/code>/gi, "\`$1\`");
657
-
658
- // Convert links
659
- md = md.replace(/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\\/a>/gi, "[$2]($1)");
660
-
661
- // Convert lists
662
- md = md.replace(/<li[^>]*>(.*?)<\\/li>/gi, "- $1\\n");
663
-
664
- // Convert paragraphs
665
- md = md.replace(/<p[^>]*>(.*?)<\\/p>/gis, "$1\\n\\n");
666
- md = md.replace(/<br\\s*\\/?>/gi, "\\n");
667
-
668
- // Strip remaining tags
669
- md = md.replace(/<[^>]+>/g, "");
670
-
671
- // Decode entities
672
- md = md.replace(/&lt;/g, "<")
673
- .replace(/&gt;/g, ">")
674
- .replace(/&quot;/g, '"')
675
- .replace(/&#39;/g, "'")
676
- .replace(/&apos;/g, "'")
677
- .replace(/&nbsp;/g, " ")
678
- .replace(/&#(\\d+);/g, (_, n) => { const c = parseInt(n, 10); return c > 0 && c <= 0x10FFFF ? String.fromCodePoint(c) : ''; })
679
- .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => { const c = parseInt(h, 16); return c > 0 && c <= 0x10FFFF ? String.fromCodePoint(c) : ''; })
680
- .replace(/&amp;/g, "&");
681
-
682
- // Clean whitespace
683
- md = md.replace(/\\n{3,}/g, "\\n\\n").trim();
684
-
685
- console.log(md);
686
- `;
687
- }
688
122
  //# sourceMappingURL=server.js.map