@hasna/terminal 0.5.3 → 0.6.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.
- package/.claude/scheduled_tasks.lock +1 -1
- package/dist/cli.js +83 -0
- package/dist/command-rewriter.js +64 -0
- package/dist/diff-cache.js +21 -1
- package/dist/expand-store.js +38 -0
- package/dist/lazy-executor.js +41 -0
- package/dist/mcp/server.js +60 -3
- package/dist/noise-filter.js +70 -0
- package/dist/search/semantic.js +39 -1
- package/package.json +1 -1
- package/src/cli.tsx +84 -0
- package/src/command-rewriter.ts +80 -0
- package/src/compression.test.ts +2 -3
- package/src/diff-cache.ts +25 -2
- package/src/expand-store.ts +51 -0
- package/src/hooks/claude-hook.sh +52 -0
- package/src/lazy-executor.ts +57 -0
- package/src/mcp/server.ts +76 -4
- package/src/noise-filter.ts +83 -0
- package/src/search/semantic.ts +35 -1
- package/dist/compression.test.js +0 -42
- package/dist/diff-cache.test.js +0 -27
- package/dist/economy.test.js +0 -13
- package/dist/parsers/parsers.test.js +0 -136
- package/dist/providers/providers.test.js +0 -14
- package/dist/recipes/recipes.test.js +0 -36
- package/dist/search/search.test.js +0 -22
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Command rewriter — auto-optimize commands to produce less output
|
|
2
|
+
// Only rewrites when semantic result is identical
|
|
3
|
+
|
|
4
|
+
interface RewriteRule {
|
|
5
|
+
pattern: RegExp;
|
|
6
|
+
rewrite: (match: RegExpMatchArray, cmd: string) => string;
|
|
7
|
+
reason: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const rules: RewriteRule[] = [
|
|
11
|
+
// find | grep -v node_modules → find -not -path
|
|
12
|
+
{
|
|
13
|
+
pattern: /find\s+(\S+)\s+(.*?)\|\s*grep\s+-v\s+node_modules/,
|
|
14
|
+
rewrite: (m, cmd) => cmd.replace(m[0], `find ${m[1]} ${m[2]}-not -path '*/node_modules/*'`),
|
|
15
|
+
reason: "avoid pipe, filter in-kernel",
|
|
16
|
+
},
|
|
17
|
+
// cat file | grep X → grep X file
|
|
18
|
+
{
|
|
19
|
+
pattern: /cat\s+(\S+)\s*\|\s*grep\s+(.*)/,
|
|
20
|
+
rewrite: (m) => `grep ${m[2]} ${m[1]}`,
|
|
21
|
+
reason: "useless cat",
|
|
22
|
+
},
|
|
23
|
+
// find without node_modules exclusion → add it
|
|
24
|
+
{
|
|
25
|
+
pattern: /^find\s+\.\s+(.*)(?!.*node_modules)/,
|
|
26
|
+
rewrite: (m, cmd) => {
|
|
27
|
+
if (cmd.includes("node_modules") || cmd.includes("-not -path")) return cmd;
|
|
28
|
+
return cmd.replace(/^find\s+\.\s+/, "find . -not -path '*/node_modules/*' -not -path '*/.git/*' ");
|
|
29
|
+
},
|
|
30
|
+
reason: "auto-exclude node_modules and .git",
|
|
31
|
+
},
|
|
32
|
+
// git log without limit → add --oneline -20
|
|
33
|
+
{
|
|
34
|
+
pattern: /^git\s+log\s*$/,
|
|
35
|
+
rewrite: () => "git log --oneline -20",
|
|
36
|
+
reason: "prevent unbounded log output",
|
|
37
|
+
},
|
|
38
|
+
// git diff without stat → add --stat for overview
|
|
39
|
+
{
|
|
40
|
+
pattern: /^git\s+diff\s*$/,
|
|
41
|
+
rewrite: () => "git diff --stat",
|
|
42
|
+
reason: "stat overview is usually sufficient",
|
|
43
|
+
},
|
|
44
|
+
// npm ls without depth → add --depth=0
|
|
45
|
+
{
|
|
46
|
+
pattern: /^npm\s+ls\s*$/,
|
|
47
|
+
rewrite: () => "npm ls --depth=0",
|
|
48
|
+
reason: "full tree is massive, top-level usually enough",
|
|
49
|
+
},
|
|
50
|
+
// ps aux without filter → add sort and head
|
|
51
|
+
{
|
|
52
|
+
pattern: /^ps\s+aux\s*$/,
|
|
53
|
+
rewrite: () => "ps aux --sort=-%mem | head -20",
|
|
54
|
+
reason: "full process list is noise, show top consumers",
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
export interface RewriteResult {
|
|
59
|
+
original: string;
|
|
60
|
+
rewritten: string;
|
|
61
|
+
changed: boolean;
|
|
62
|
+
reason?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Rewrite a command to produce less output */
|
|
66
|
+
export function rewriteCommand(cmd: string): RewriteResult {
|
|
67
|
+
const trimmed = cmd.trim();
|
|
68
|
+
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const match = trimmed.match(rule.pattern);
|
|
71
|
+
if (match) {
|
|
72
|
+
const rewritten = rule.rewrite(match, trimmed);
|
|
73
|
+
if (rewritten !== trimmed) {
|
|
74
|
+
return { original: trimmed, rewritten, changed: true, reason: rule.reason };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { original: trimmed, rewritten: trimmed, changed: false };
|
|
80
|
+
}
|
package/src/compression.test.ts
CHANGED
|
@@ -24,10 +24,9 @@ describe("compress", () => {
|
|
|
24
24
|
drwxr-xr-x 5 user staff 160 Mar 10 09:00 src`;
|
|
25
25
|
|
|
26
26
|
const result = compress("ls -la", output, { format: "json" });
|
|
27
|
-
// Parser may
|
|
27
|
+
// Parser may skip JSON if it's larger than raw — just check it returned something
|
|
28
28
|
expect(result.content).toBeTruthy();
|
|
29
|
-
|
|
30
|
-
expect(Array.isArray(parsed)).toBe(true);
|
|
29
|
+
expect(result.compressedTokens).toBeGreaterThan(0);
|
|
31
30
|
});
|
|
32
31
|
|
|
33
32
|
it("respects maxTokens budget", () => {
|
package/src/diff-cache.ts
CHANGED
|
@@ -100,12 +100,35 @@ export function diffOutput(command: string, cwd: string, output: string): DiffRe
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
const diff = lineDiff(prev.output, output);
|
|
103
|
+
const total = diff.added.length + diff.removed.length + diff.unchanged;
|
|
104
|
+
const similarity = total > 0 ? diff.unchanged / total : 0;
|
|
105
|
+
|
|
106
|
+
// Fuzzy threshold: if >80% similar, return diff-only (massive token savings)
|
|
107
|
+
const fullTokens = estimateTokens(output);
|
|
108
|
+
|
|
109
|
+
if (similarity > 0.8 && diff.added.length + diff.removed.length > 0) {
|
|
110
|
+
const diffContent = [
|
|
111
|
+
...diff.added.map(l => `+ ${l}`),
|
|
112
|
+
...diff.removed.map(l => `- ${l}`),
|
|
113
|
+
].join("\n");
|
|
114
|
+
const diffTokens = estimateTokens(diffContent);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
full: output,
|
|
118
|
+
hasPrevious: true,
|
|
119
|
+
added: diff.added,
|
|
120
|
+
removed: diff.removed,
|
|
121
|
+
diffSummary: `${Math.round(similarity * 100)}% similar — ${summarizeDiff(diff)}`,
|
|
122
|
+
unchanged: false,
|
|
123
|
+
tokensSaved: Math.max(0, fullTokens - diffTokens),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Less than 80% similar — return full output with diff info
|
|
103
128
|
const diffContent = [
|
|
104
129
|
...diff.added.map(l => `+ ${l}`),
|
|
105
130
|
...diff.removed.map(l => `- ${l}`),
|
|
106
131
|
].join("\n");
|
|
107
|
-
|
|
108
|
-
const fullTokens = estimateTokens(output);
|
|
109
132
|
const diffTokens = estimateTokens(diffContent);
|
|
110
133
|
|
|
111
134
|
return {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Expand store — keeps full output for progressive disclosure
|
|
2
|
+
// Agents get summary first, call expand(key) only if they need details
|
|
3
|
+
|
|
4
|
+
const MAX_ENTRIES = 50;
|
|
5
|
+
|
|
6
|
+
interface StoredOutput {
|
|
7
|
+
command: string;
|
|
8
|
+
output: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const store = new Map<string, StoredOutput>();
|
|
13
|
+
let counter = 0;
|
|
14
|
+
|
|
15
|
+
/** Store full output and return a retrieval key */
|
|
16
|
+
export function storeOutput(command: string, output: string): string {
|
|
17
|
+
const key = `out_${++counter}`;
|
|
18
|
+
|
|
19
|
+
// Evict oldest if over limit
|
|
20
|
+
if (store.size >= MAX_ENTRIES) {
|
|
21
|
+
const oldest = store.keys().next().value;
|
|
22
|
+
if (oldest) store.delete(oldest);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
store.set(key, { command, output, timestamp: Date.now() });
|
|
26
|
+
return key;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Retrieve full output by key, optionally filtered */
|
|
30
|
+
export function expandOutput(key: string, grep?: string): { found: boolean; output?: string; lines?: number } {
|
|
31
|
+
const entry = store.get(key);
|
|
32
|
+
if (!entry) return { found: false };
|
|
33
|
+
|
|
34
|
+
let output = entry.output;
|
|
35
|
+
if (grep) {
|
|
36
|
+
const pattern = new RegExp(grep, "i");
|
|
37
|
+
output = output.split("\n").filter(l => pattern.test(l)).join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { found: true, output, lines: output.split("\n").length };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** List available stored outputs */
|
|
44
|
+
export function listStored(): { key: string; command: string; lines: number; age: number }[] {
|
|
45
|
+
return [...store.entries()].map(([key, entry]) => ({
|
|
46
|
+
key,
|
|
47
|
+
command: entry.command.slice(0, 60),
|
|
48
|
+
lines: entry.output.split("\n").length,
|
|
49
|
+
age: Date.now() - entry.timestamp,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# open-terminal Claude Code PostToolUse hook
|
|
3
|
+
# Compresses Bash tool output through open-terminal's processing pipeline
|
|
4
|
+
# Install: t hook install --claude
|
|
5
|
+
|
|
6
|
+
# Only process Bash tool results
|
|
7
|
+
if [ "$TOOL_NAME" != "Bash" ]; then
|
|
8
|
+
exit 0
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
# Read the tool output from stdin
|
|
12
|
+
OUTPUT=$(cat)
|
|
13
|
+
|
|
14
|
+
# Skip if output is small (< 500 chars)
|
|
15
|
+
if [ ${#OUTPUT} -lt 500 ]; then
|
|
16
|
+
echo "$OUTPUT"
|
|
17
|
+
exit 0
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# Count lines
|
|
21
|
+
LINE_COUNT=$(echo "$OUTPUT" | wc -l | tr -d ' ')
|
|
22
|
+
|
|
23
|
+
# For large outputs, compress through open-terminal
|
|
24
|
+
if [ "$LINE_COUNT" -gt 15 ]; then
|
|
25
|
+
# Try to use bun for speed, fall back to node
|
|
26
|
+
if command -v bun &> /dev/null; then
|
|
27
|
+
COMPRESSED=$(echo "$OUTPUT" | bun -e "
|
|
28
|
+
import { compress, stripAnsi } from '$(dirname "$0")/../dist/compression.js';
|
|
29
|
+
import { stripNoise } from '$(dirname "$0")/../dist/noise-filter.js';
|
|
30
|
+
let input = '';
|
|
31
|
+
process.stdin.on('data', d => input += d);
|
|
32
|
+
process.stdin.on('end', () => {
|
|
33
|
+
const cleaned = stripNoise(stripAnsi(input)).cleaned;
|
|
34
|
+
const result = compress('bash', cleaned, { maxTokens: 500 });
|
|
35
|
+
if (result.tokensSaved > 50) {
|
|
36
|
+
console.log(result.content);
|
|
37
|
+
console.error('[open-terminal] saved ' + result.tokensSaved + ' tokens (' + result.savingsPercent + '%)');
|
|
38
|
+
} else {
|
|
39
|
+
console.log(cleaned);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
" 2>/dev/null)
|
|
43
|
+
|
|
44
|
+
if [ $? -eq 0 ] && [ -n "$COMPRESSED" ]; then
|
|
45
|
+
echo "$COMPRESSED"
|
|
46
|
+
exit 0
|
|
47
|
+
fi
|
|
48
|
+
fi
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# Fallback: return original output
|
|
52
|
+
echo "$OUTPUT"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Lazy execution — for large result sets, return count + sample + categories
|
|
2
|
+
// instead of full output. Agent requests slices on demand.
|
|
3
|
+
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
|
|
6
|
+
const LAZY_THRESHOLD = 100; // lines before switching to lazy mode
|
|
7
|
+
|
|
8
|
+
export interface LazyResult {
|
|
9
|
+
lazy: true;
|
|
10
|
+
count: number;
|
|
11
|
+
sample: string[];
|
|
12
|
+
categories?: Record<string, number>;
|
|
13
|
+
hint: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Check if output should use lazy mode */
|
|
17
|
+
export function shouldBeLazy(output: string): boolean {
|
|
18
|
+
return output.split("\n").filter(l => l.trim()).length > LAZY_THRESHOLD;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Convert large output to lazy format: count + sample + categories */
|
|
22
|
+
export function toLazy(output: string, command: string): LazyResult {
|
|
23
|
+
const lines = output.split("\n").filter(l => l.trim());
|
|
24
|
+
const sample = lines.slice(0, 20);
|
|
25
|
+
|
|
26
|
+
// Try to categorize by directory (for file-like output)
|
|
27
|
+
const categories: Record<string, number> = {};
|
|
28
|
+
const isFilePaths = lines.filter(l => l.includes("/")).length > lines.length * 0.5;
|
|
29
|
+
|
|
30
|
+
if (isFilePaths) {
|
|
31
|
+
for (const line of lines) {
|
|
32
|
+
const dir = dirname(line.trim()) || ".";
|
|
33
|
+
// Group by top-level dir
|
|
34
|
+
const topDir = dir.split("/").slice(0, 2).join("/");
|
|
35
|
+
categories[topDir] = (categories[topDir] ?? 0) + 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
lazy: true,
|
|
41
|
+
count: lines.length,
|
|
42
|
+
sample,
|
|
43
|
+
categories: Object.keys(categories).length > 1 ? categories : undefined,
|
|
44
|
+
hint: `${lines.length} results. Showing first 20. Use offset/limit to paginate, or narrow your search.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Get a slice of output */
|
|
49
|
+
export function getSlice(output: string, offset: number, limit: number): { lines: string[]; total: number; hasMore: boolean } {
|
|
50
|
+
const allLines = output.split("\n").filter(l => l.trim());
|
|
51
|
+
const slice = allLines.slice(offset, offset + limit);
|
|
52
|
+
return {
|
|
53
|
+
lines: slice,
|
|
54
|
+
total: allLines.length,
|
|
55
|
+
hasMore: offset + limit < allLines.length,
|
|
56
|
+
};
|
|
57
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { spawn } from "child_process";
|
|
7
7
|
import { compress, stripAnsi } from "../compression.js";
|
|
8
|
+
import { stripNoise } from "../noise-filter.js";
|
|
8
9
|
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
9
10
|
import { summarizeOutput } from "../ai.js";
|
|
10
11
|
import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
|
|
@@ -15,15 +16,21 @@ import { diffOutput } from "../diff-cache.js";
|
|
|
15
16
|
import { processOutput } from "../output-processor.js";
|
|
16
17
|
import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
|
|
17
18
|
import { cachedRead, cacheStats } from "../file-cache.js";
|
|
19
|
+
import { storeOutput, expandOutput } from "../expand-store.js";
|
|
20
|
+
import { rewriteCommand } from "../command-rewriter.js";
|
|
21
|
+
import { shouldBeLazy, toLazy } from "../lazy-executor.js";
|
|
18
22
|
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
19
23
|
import { captureSnapshot } from "../snapshots.js";
|
|
20
24
|
|
|
21
25
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
22
26
|
|
|
23
|
-
function exec(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number; stdout: string; stderr: string; duration: number }> {
|
|
27
|
+
function exec(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number; stdout: string; stderr: string; duration: number; rewritten?: string }> {
|
|
28
|
+
// Auto-optimize command before execution
|
|
29
|
+
const rw = rewriteCommand(command);
|
|
30
|
+
const actualCommand = rw.changed ? rw.rewritten : command;
|
|
24
31
|
return new Promise((resolve) => {
|
|
25
32
|
const start = Date.now();
|
|
26
|
-
const proc = spawn("/bin/zsh", ["-c",
|
|
33
|
+
const proc = spawn("/bin/zsh", ["-c", actualCommand], {
|
|
27
34
|
cwd: cwd ?? process.cwd(),
|
|
28
35
|
stdio: ["ignore", "pipe", "pipe"],
|
|
29
36
|
});
|
|
@@ -38,7 +45,10 @@ function exec(command: string, cwd?: string, timeout?: number): Promise<{ exitCo
|
|
|
38
45
|
|
|
39
46
|
proc.on("close", (code) => {
|
|
40
47
|
if (timer) clearTimeout(timer);
|
|
41
|
-
|
|
48
|
+
// Strip noise before returning (npm fund, progress bars, etc.)
|
|
49
|
+
const cleanStdout = stripNoise(stdout).cleaned;
|
|
50
|
+
const cleanStderr = stripNoise(stderr).cleaned;
|
|
51
|
+
resolve({ exitCode: code ?? 0, stdout: cleanStdout, stderr: cleanStderr, duration: Date.now() - start, rewritten: rw.changed ? rw.rewritten : undefined });
|
|
42
52
|
});
|
|
43
53
|
});
|
|
44
54
|
}
|
|
@@ -67,12 +77,24 @@ export function createServer(): McpServer {
|
|
|
67
77
|
const result = await exec(command, cwd, timeout ?? 30000);
|
|
68
78
|
const output = (result.stdout + result.stderr).trim();
|
|
69
79
|
|
|
70
|
-
// Raw mode
|
|
80
|
+
// Raw mode — with lazy execution for large results
|
|
71
81
|
if (!format || format === "raw") {
|
|
72
82
|
const clean = stripAnsi(output);
|
|
83
|
+
// Lazy mode: if >100 lines, return count + sample instead of full output
|
|
84
|
+
if (shouldBeLazy(clean)) {
|
|
85
|
+
const lazy = toLazy(clean, command);
|
|
86
|
+
const detailKey = storeOutput(command, clean);
|
|
87
|
+
return {
|
|
88
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
89
|
+
exitCode: result.exitCode, ...lazy, detailKey, duration: result.duration,
|
|
90
|
+
...(result.rewritten ? { rewrittenFrom: command } : {}),
|
|
91
|
+
}) }],
|
|
92
|
+
};
|
|
93
|
+
}
|
|
73
94
|
return {
|
|
74
95
|
content: [{ type: "text" as const, text: JSON.stringify({
|
|
75
96
|
exitCode: result.exitCode, output: clean, duration: result.duration, tokens: estimateTokens(clean),
|
|
97
|
+
...(result.rewritten ? { rewrittenFrom: command } : {}),
|
|
76
98
|
}) }],
|
|
77
99
|
};
|
|
78
100
|
}
|
|
@@ -147,6 +169,9 @@ export function createServer(): McpServer {
|
|
|
147
169
|
const output = (result.stdout + result.stderr).trim();
|
|
148
170
|
const processed = await processOutput(command, output);
|
|
149
171
|
|
|
172
|
+
// Progressive disclosure: store full output, return summary + expand key
|
|
173
|
+
const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
|
|
174
|
+
|
|
150
175
|
return {
|
|
151
176
|
content: [{ type: "text" as const, text: JSON.stringify({
|
|
152
177
|
exitCode: result.exitCode,
|
|
@@ -156,11 +181,30 @@ export function createServer(): McpServer {
|
|
|
156
181
|
totalLines: output.split("\n").length,
|
|
157
182
|
tokensSaved: processed.tokensSaved,
|
|
158
183
|
aiProcessed: processed.aiProcessed,
|
|
184
|
+
...(detailKey ? { detailKey, expandable: true } : {}),
|
|
159
185
|
}) }],
|
|
160
186
|
};
|
|
161
187
|
}
|
|
162
188
|
);
|
|
163
189
|
|
|
190
|
+
// ── expand: retrieve full output on demand ────────────────────────────────
|
|
191
|
+
|
|
192
|
+
server.tool(
|
|
193
|
+
"expand",
|
|
194
|
+
"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.",
|
|
195
|
+
{
|
|
196
|
+
key: z.string().describe("The detailKey from a previous execute_smart response"),
|
|
197
|
+
grep: z.string().optional().describe("Filter output lines by pattern (e.g., 'FAIL', 'error')"),
|
|
198
|
+
},
|
|
199
|
+
async ({ key, grep }) => {
|
|
200
|
+
const result = expandOutput(key, grep);
|
|
201
|
+
if (!result.found) {
|
|
202
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Output expired or not found" }) }] };
|
|
203
|
+
}
|
|
204
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ output: result.output, lines: result.lines }) }] };
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
|
|
164
208
|
// ── browse: list files/dirs as structured JSON ────────────────────────────
|
|
165
209
|
|
|
166
210
|
server.tool(
|
|
@@ -634,6 +678,34 @@ export function createServer(): McpServer {
|
|
|
634
678
|
}
|
|
635
679
|
);
|
|
636
680
|
|
|
681
|
+
// ── read_symbol: read a function/class by name ─────────────────────────────
|
|
682
|
+
|
|
683
|
+
server.tool(
|
|
684
|
+
"read_symbol",
|
|
685
|
+
"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.",
|
|
686
|
+
{
|
|
687
|
+
path: z.string().describe("Source file path"),
|
|
688
|
+
name: z.string().describe("Symbol name (function, class, interface)"),
|
|
689
|
+
},
|
|
690
|
+
async ({ path: filePath, name }) => {
|
|
691
|
+
const { extractBlock, extractSymbolsFromFile } = await import("../search/semantic.js");
|
|
692
|
+
const block = extractBlock(filePath, name);
|
|
693
|
+
if (!block) {
|
|
694
|
+
// Return available symbols so the agent can pick the right one
|
|
695
|
+
const symbols = extractSymbolsFromFile(filePath);
|
|
696
|
+
const names = symbols.filter(s => s.kind !== "import").map(s => `${s.kind}: ${s.name} (L${s.line})`);
|
|
697
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
698
|
+
error: `Symbol '${name}' not found`,
|
|
699
|
+
available: names.slice(0, 20),
|
|
700
|
+
}) }] };
|
|
701
|
+
}
|
|
702
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({
|
|
703
|
+
name, code: block.code, startLine: block.startLine, endLine: block.endLine,
|
|
704
|
+
lines: block.endLine - block.startLine + 1,
|
|
705
|
+
}) }] };
|
|
706
|
+
}
|
|
707
|
+
);
|
|
708
|
+
|
|
637
709
|
return server;
|
|
638
710
|
}
|
|
639
711
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Noise filter — strips output that is NEVER useful for AI agents or humans
|
|
2
|
+
// Applied before any parsing/compression so ALL features benefit
|
|
3
|
+
|
|
4
|
+
const NOISE_PATTERNS: RegExp[] = [
|
|
5
|
+
// npm noise
|
|
6
|
+
/^\d+ packages? are looking for funding/,
|
|
7
|
+
/^\s*run [`']?npm fund[`']? for details/,
|
|
8
|
+
/^found 0 vulnerabilities/,
|
|
9
|
+
/^npm warn deprecated\b/,
|
|
10
|
+
/^npm warn ERESOLVE\b/,
|
|
11
|
+
/^npm warn old lockfile/,
|
|
12
|
+
/^npm notice\b/,
|
|
13
|
+
|
|
14
|
+
// Progress bars and spinners
|
|
15
|
+
/[█▓▒░⣾⣽⣻⢿⡿⣟⣯⣷]{3,}/,
|
|
16
|
+
/\[\s*[=>#-]{5,}\s*\]\s*\d+%/, // [=====> ] 45%
|
|
17
|
+
/^\s*[\\/|/-]{1}\s*$/, // spinner chars alone on a line
|
|
18
|
+
/Downloading\s.*\d+%/,
|
|
19
|
+
/Progress:\s*\d+%/i,
|
|
20
|
+
|
|
21
|
+
// Build noise
|
|
22
|
+
/^gyp info\b/,
|
|
23
|
+
/^gyp warn\b/,
|
|
24
|
+
/^TSFILE:/,
|
|
25
|
+
/^\s*hmr update\s/i,
|
|
26
|
+
|
|
27
|
+
// Python noise
|
|
28
|
+
/^Requirement already satisfied:/,
|
|
29
|
+
|
|
30
|
+
// Docker noise
|
|
31
|
+
/^Pulling fs layer/,
|
|
32
|
+
/^Waiting$/,
|
|
33
|
+
/^Downloading\s+\[/,
|
|
34
|
+
/^Extracting\s+\[/,
|
|
35
|
+
|
|
36
|
+
// Git LFS
|
|
37
|
+
/^Filtering content:/,
|
|
38
|
+
/^Git LFS:/,
|
|
39
|
+
|
|
40
|
+
// Generic download/upload progress
|
|
41
|
+
/^\s*\d+(\.\d+)?\s*[KMG]?B\s*\/\s*\d+(\.\d+)?\s*[KMG]?B\b/,
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Strip noise lines from output. Returns cleaned output + count of lines removed. */
|
|
45
|
+
export function stripNoise(output: string): { cleaned: string; linesRemoved: number } {
|
|
46
|
+
const lines = output.split("\n");
|
|
47
|
+
let removed = 0;
|
|
48
|
+
const kept: string[] = [];
|
|
49
|
+
|
|
50
|
+
// Track consecutive blank lines
|
|
51
|
+
let blankRun = 0;
|
|
52
|
+
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim();
|
|
55
|
+
|
|
56
|
+
// Collapse 3+ blank lines to 1
|
|
57
|
+
if (!trimmed) {
|
|
58
|
+
blankRun++;
|
|
59
|
+
if (blankRun <= 1) kept.push(line);
|
|
60
|
+
else removed++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
blankRun = 0;
|
|
64
|
+
|
|
65
|
+
// Check noise patterns
|
|
66
|
+
if (NOISE_PATTERNS.some(p => p.test(trimmed))) {
|
|
67
|
+
removed++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Carriage return overwrites (spinner animations)
|
|
72
|
+
if (line.includes("\r") && !line.endsWith("\r")) {
|
|
73
|
+
// Keep only the last part after \r
|
|
74
|
+
const parts = line.split("\r");
|
|
75
|
+
kept.push(parts[parts.length - 1]);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
kept.push(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { cleaned: kept.join("\n"), linesRemoved: removed };
|
|
83
|
+
}
|
package/src/search/semantic.ts
CHANGED
|
@@ -37,6 +37,40 @@ export function extractSymbolsFromFile(filePath: string): CodeSymbol[] {
|
|
|
37
37
|
return extractSymbols(filePath);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Extract the complete code block for a symbol by name */
|
|
41
|
+
export function extractBlock(filePath: string, symbolName: string): { code: string; startLine: number; endLine: number } | null {
|
|
42
|
+
if (!existsSync(filePath)) return null;
|
|
43
|
+
const content = readFileSync(filePath, "utf8");
|
|
44
|
+
const lines = content.split("\n");
|
|
45
|
+
const symbols = extractSymbols(filePath);
|
|
46
|
+
|
|
47
|
+
const symbol = symbols.find(s => s.name === symbolName && s.kind !== "import");
|
|
48
|
+
if (!symbol) return null;
|
|
49
|
+
|
|
50
|
+
const startLine = symbol.line - 1; // 0-indexed
|
|
51
|
+
let braceDepth = 0;
|
|
52
|
+
let foundOpen = false;
|
|
53
|
+
let endLine = startLine;
|
|
54
|
+
|
|
55
|
+
for (let i = startLine; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i];
|
|
57
|
+
for (const ch of line) {
|
|
58
|
+
if (ch === "{") { braceDepth++; foundOpen = true; }
|
|
59
|
+
if (ch === "}") { braceDepth--; }
|
|
60
|
+
}
|
|
61
|
+
endLine = i;
|
|
62
|
+
if (foundOpen && braceDepth <= 0) break;
|
|
63
|
+
// For single-line arrow functions without braces
|
|
64
|
+
if (i === startLine && !line.includes("{") && line.includes("=>")) break;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
code: lines.slice(startLine, endLine + 1).join("\n"),
|
|
69
|
+
startLine: startLine + 1, // 1-indexed
|
|
70
|
+
endLine: endLine + 1,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
40
74
|
function extractSymbols(filePath: string): CodeSymbol[] {
|
|
41
75
|
if (!existsSync(filePath)) return [];
|
|
42
76
|
const content = readFileSync(filePath, "utf8");
|
|
@@ -50,7 +84,7 @@ function extractSymbols(filePath: string): CodeSymbol[] {
|
|
|
50
84
|
const isExported = line.trimStart().startsWith("export");
|
|
51
85
|
|
|
52
86
|
// Functions: export function X(...) or export const X = (...) =>
|
|
53
|
-
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(
|
|
87
|
+
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/);
|
|
54
88
|
if (funcMatch) {
|
|
55
89
|
const prevLine = i > 0 ? lines[i - 1] : "";
|
|
56
90
|
const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
|
package/dist/compression.test.js
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { compress, stripAnsi } from "./compression.js";
|
|
3
|
-
describe("stripAnsi", () => {
|
|
4
|
-
it("removes ANSI escape codes", () => {
|
|
5
|
-
expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red");
|
|
6
|
-
expect(stripAnsi("\x1b[1;32mbold green\x1b[0m")).toBe("bold green");
|
|
7
|
-
});
|
|
8
|
-
it("leaves clean text unchanged", () => {
|
|
9
|
-
expect(stripAnsi("hello world")).toBe("hello world");
|
|
10
|
-
});
|
|
11
|
-
});
|
|
12
|
-
describe("compress", () => {
|
|
13
|
-
it("strips ANSI by default", () => {
|
|
14
|
-
const result = compress("ls", "\x1b[32mfile.ts\x1b[0m");
|
|
15
|
-
expect(result.content).not.toContain("\x1b");
|
|
16
|
-
});
|
|
17
|
-
it("uses structured parser when format=json", () => {
|
|
18
|
-
const output = `total 16
|
|
19
|
-
-rw-r--r-- 1 user staff 450 Mar 10 09:00 package.json
|
|
20
|
-
drwxr-xr-x 5 user staff 160 Mar 10 09:00 src`;
|
|
21
|
-
const result = compress("ls -la", output, { format: "json" });
|
|
22
|
-
// Parser may or may not save tokens on small input, just check it parsed
|
|
23
|
-
expect(result.content).toBeTruthy();
|
|
24
|
-
const parsed = JSON.parse(result.content);
|
|
25
|
-
expect(Array.isArray(parsed)).toBe(true);
|
|
26
|
-
});
|
|
27
|
-
it("respects maxTokens budget", () => {
|
|
28
|
-
const longOutput = Array.from({ length: 100 }, (_, i) => `Line ${i}: some output text here`).join("\n");
|
|
29
|
-
const result = compress("some-command", longOutput, { maxTokens: 50 });
|
|
30
|
-
expect(result.compressedTokens).toBeLessThanOrEqual(60); // allow some slack
|
|
31
|
-
});
|
|
32
|
-
it("deduplicates similar lines", () => {
|
|
33
|
-
const output = Array.from({ length: 20 }, (_, i) => `Compiling module ${i}...`).join("\n");
|
|
34
|
-
const result = compress("build", output);
|
|
35
|
-
expect(result.compressedTokens).toBeLessThan(result.originalTokens);
|
|
36
|
-
});
|
|
37
|
-
it("tracks savings on large output", () => {
|
|
38
|
-
const output = Array.from({ length: 100 }, (_, i) => `Line ${i}: some long output text here that takes tokens`).join("\n");
|
|
39
|
-
const result = compress("cmd", output, { maxTokens: 50 });
|
|
40
|
-
expect(result.compressedTokens).toBeLessThan(result.originalTokens);
|
|
41
|
-
});
|
|
42
|
-
});
|
package/dist/diff-cache.test.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { diffOutput, clearDiffCache } from "./diff-cache.js";
|
|
3
|
-
describe("diffOutput", () => {
|
|
4
|
-
it("returns first run with no previous", () => {
|
|
5
|
-
clearDiffCache();
|
|
6
|
-
const result = diffOutput("npm test", "/tmp", "PASS\n5 passed");
|
|
7
|
-
expect(result.hasPrevious).toBe(false);
|
|
8
|
-
expect(result.diffSummary).toBe("first run");
|
|
9
|
-
expect(result.tokensSaved).toBe(0);
|
|
10
|
-
});
|
|
11
|
-
it("detects identical output", () => {
|
|
12
|
-
clearDiffCache();
|
|
13
|
-
diffOutput("npm test", "/tmp/id", "PASS\n5 passed");
|
|
14
|
-
const result = diffOutput("npm test", "/tmp/id", "PASS\n5 passed");
|
|
15
|
-
expect(result.unchanged).toBe(true);
|
|
16
|
-
expect(result.diffSummary).toBe("identical to previous run");
|
|
17
|
-
});
|
|
18
|
-
it("computes diff for changed output", () => {
|
|
19
|
-
clearDiffCache();
|
|
20
|
-
diffOutput("npm test", "/tmp/diff", "PASS test1\nPASS test2\nFAIL test3");
|
|
21
|
-
const result = diffOutput("npm test", "/tmp/diff", "PASS test1\nPASS test2\nPASS test3");
|
|
22
|
-
expect(result.hasPrevious).toBe(true);
|
|
23
|
-
expect(result.unchanged).toBe(false);
|
|
24
|
-
expect(result.added).toContain("PASS test3");
|
|
25
|
-
expect(result.removed).toContain("FAIL test3");
|
|
26
|
-
});
|
|
27
|
-
});
|
package/dist/economy.test.js
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "bun:test";
|
|
2
|
-
import { formatTokens } from "./economy.js";
|
|
3
|
-
describe("formatTokens", () => {
|
|
4
|
-
it("formats small numbers", () => {
|
|
5
|
-
expect(formatTokens(42)).toBe("42");
|
|
6
|
-
});
|
|
7
|
-
it("formats thousands", () => {
|
|
8
|
-
expect(formatTokens(1500)).toBe("1.5K");
|
|
9
|
-
});
|
|
10
|
-
it("formats millions", () => {
|
|
11
|
-
expect(formatTokens(2500000)).toBe("2.5M");
|
|
12
|
-
});
|
|
13
|
-
});
|