@hasna/terminal 0.3.1 → 0.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.
- package/dist/App.js +24 -12
- package/dist/cli.js +37 -0
- package/dist/file-cache.js +72 -0
- package/dist/mcp/server.js +106 -1
- package/dist/output-processor.js +95 -0
- package/dist/search/content-search.js +7 -0
- package/dist/search/index.js +1 -0
- package/dist/search/semantic.js +227 -0
- package/dist/smart-display.js +97 -1
- package/package.json +3 -2
- package/src/App.tsx +24 -11
- package/src/cli.tsx +30 -0
- package/src/file-cache.ts +95 -0
- package/src/mcp/server.ts +145 -1
- package/src/output-processor.ts +125 -0
- package/src/search/content-search.ts +8 -0
- package/src/search/index.ts +2 -0
- package/src/search/semantic.ts +271 -0
- package/src/smart-display.ts +97 -1
package/dist/smart-display.js
CHANGED
|
@@ -78,6 +78,10 @@ function collapseNodeModules(paths) {
|
|
|
78
78
|
export function smartDisplay(lines) {
|
|
79
79
|
if (lines.length <= 5)
|
|
80
80
|
return lines;
|
|
81
|
+
// Try ls -la table compression first
|
|
82
|
+
const lsCompressed = compressLsTable(lines);
|
|
83
|
+
if (lsCompressed)
|
|
84
|
+
return lsCompressed;
|
|
81
85
|
if (!looksLikePaths(lines))
|
|
82
86
|
return compressGeneric(lines);
|
|
83
87
|
const paths = lines.map(l => l.trim()).filter(l => l);
|
|
@@ -114,7 +118,9 @@ export function smartDisplay(lines) {
|
|
|
114
118
|
const sorted = files.sort();
|
|
115
119
|
const pattern = findPattern(sorted);
|
|
116
120
|
if (pattern) {
|
|
117
|
-
|
|
121
|
+
const dateRange = collapseDateRange(sorted);
|
|
122
|
+
const rangeStr = dateRange ? ` (${dateRange})` : "";
|
|
123
|
+
result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
|
|
118
124
|
}
|
|
119
125
|
else {
|
|
120
126
|
result.push(` ${dir}/ (${files.length} files)`);
|
|
@@ -150,6 +156,96 @@ export function smartDisplay(lines) {
|
|
|
150
156
|
}
|
|
151
157
|
return result;
|
|
152
158
|
}
|
|
159
|
+
/** Detect date range in timestamps and collapse */
|
|
160
|
+
function collapseDateRange(files) {
|
|
161
|
+
const timestamps = [];
|
|
162
|
+
for (const f of files) {
|
|
163
|
+
const match = f.match(/(\d{4})-(\d{2})-(\d{2})T?(\d{2})?/);
|
|
164
|
+
if (match) {
|
|
165
|
+
const [, y, m, d, h] = match;
|
|
166
|
+
timestamps.push(new Date(`${y}-${m}-${d}T${h ?? "00"}:00:00`));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (timestamps.length < 2)
|
|
170
|
+
return null;
|
|
171
|
+
timestamps.sort((a, b) => a.getTime() - b.getTime());
|
|
172
|
+
const first = timestamps[0];
|
|
173
|
+
const last = timestamps[timestamps.length - 1];
|
|
174
|
+
const fmt = (d) => `${d.getMonth() + 1}/${d.getDate()}`;
|
|
175
|
+
if (first.toDateString() === last.toDateString()) {
|
|
176
|
+
return `${fmt(first)}`;
|
|
177
|
+
}
|
|
178
|
+
return `${fmt(first)}–${fmt(last)}`;
|
|
179
|
+
}
|
|
180
|
+
/** Detect and compress ls -la style table output */
|
|
181
|
+
function compressLsTable(lines) {
|
|
182
|
+
// Detect ls -la format: permissions size date name
|
|
183
|
+
const lsPattern = /^[dlcbps-][rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+\S+\s+\w+\s+\d+\s+[\d:]+\s+.+$/;
|
|
184
|
+
const isLsOutput = lines.filter(l => lsPattern.test(l.trim())).length > lines.length * 0.5;
|
|
185
|
+
if (!isLsOutput)
|
|
186
|
+
return null;
|
|
187
|
+
const result = [];
|
|
188
|
+
const dirs = [];
|
|
189
|
+
const files = [];
|
|
190
|
+
let totalSize = 0;
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
const match = line.trim().match(/^([dlcbps-])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\S+)\s+\w+\s+\d+\s+[\d:]+\s+(.+)$/);
|
|
193
|
+
if (!match) {
|
|
194
|
+
if (line.trim().startsWith("total "))
|
|
195
|
+
continue;
|
|
196
|
+
result.push(line);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const [, type, sizeStr, name] = match;
|
|
200
|
+
const size = parseInt(sizeStr) || 0;
|
|
201
|
+
totalSize += size;
|
|
202
|
+
if (type === "d") {
|
|
203
|
+
dirs.push(name);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
files.push({ name, size: formatSize(size) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Compact display
|
|
210
|
+
if (dirs.length > 0) {
|
|
211
|
+
result.push(` 📁 ${dirs.join(" ")}${dirs.length > 5 ? ` (+${dirs.length - 5} more)` : ""}`);
|
|
212
|
+
}
|
|
213
|
+
if (files.length <= 8) {
|
|
214
|
+
for (const f of files) {
|
|
215
|
+
result.push(` ${f.size.padStart(6)} ${f.name}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
// Show top 5 by size + count
|
|
220
|
+
const sorted = files.sort((a, b) => parseSize(b.size) - parseSize(a.size));
|
|
221
|
+
for (const f of sorted.slice(0, 5)) {
|
|
222
|
+
result.push(` ${f.size.padStart(6)} ${f.name}`);
|
|
223
|
+
}
|
|
224
|
+
result.push(` ... +${files.length - 5} more files (${formatSize(totalSize)} total)`);
|
|
225
|
+
}
|
|
226
|
+
return result;
|
|
227
|
+
}
|
|
228
|
+
function formatSize(bytes) {
|
|
229
|
+
if (bytes >= 1_000_000)
|
|
230
|
+
return `${(bytes / 1_000_000).toFixed(1)}M`;
|
|
231
|
+
if (bytes >= 1_000)
|
|
232
|
+
return `${(bytes / 1_000).toFixed(1)}K`;
|
|
233
|
+
return `${bytes}B`;
|
|
234
|
+
}
|
|
235
|
+
function parseSize(s) {
|
|
236
|
+
const match = s.match(/([\d.]+)([BKMG])?/);
|
|
237
|
+
if (!match)
|
|
238
|
+
return 0;
|
|
239
|
+
const n = parseFloat(match[1]);
|
|
240
|
+
const unit = match[2];
|
|
241
|
+
if (unit === "K")
|
|
242
|
+
return n * 1000;
|
|
243
|
+
if (unit === "M")
|
|
244
|
+
return n * 1000000;
|
|
245
|
+
if (unit === "G")
|
|
246
|
+
return n * 1000000000;
|
|
247
|
+
return n;
|
|
248
|
+
}
|
|
153
249
|
/** Compress non-path generic output by deduplicating similar lines */
|
|
154
250
|
function compressGeneric(lines) {
|
|
155
251
|
if (lines.length <= 10)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/terminal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
18
18
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
|
+
"@typescript/vfs": "^1.6.4",
|
|
19
20
|
"better-sqlite3": "^12.8.0",
|
|
20
21
|
"ink": "^5.0.1",
|
|
21
22
|
"react": "^18.2.0",
|
|
@@ -34,6 +35,6 @@
|
|
|
34
35
|
"@types/node": "^20.0.0",
|
|
35
36
|
"@types/react": "^18.2.0",
|
|
36
37
|
"tsx": "^4.0.0",
|
|
37
|
-
"typescript": "^5.
|
|
38
|
+
"typescript": "^5.9.3"
|
|
38
39
|
}
|
|
39
40
|
}
|
package/src/App.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import Browse from "./Browse.js";
|
|
|
11
11
|
import FuzzyPicker from "./FuzzyPicker.js";
|
|
12
12
|
import { createSession, endSession, logInteraction, updateInteraction } from "./sessions-db.js";
|
|
13
13
|
import { smartDisplay } from "./smart-display.js";
|
|
14
|
+
import { processOutput, shouldProcess } from "./output-processor.js";
|
|
14
15
|
|
|
15
16
|
loadCache();
|
|
16
17
|
|
|
@@ -134,10 +135,21 @@ export default function App() {
|
|
|
134
135
|
const pushScroll = (entry: Omit<ScrollEntry, "expanded">) =>
|
|
135
136
|
updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
|
|
136
137
|
|
|
137
|
-
const commitStream = (nl: string, cmd: string, lines: string[], error: boolean) => {
|
|
138
|
+
const commitStream = async (nl: string, cmd: string, lines: string[], error: boolean) => {
|
|
138
139
|
const filePaths = !error ? extractFilePaths(lines) : [];
|
|
139
|
-
// Smart display:
|
|
140
|
-
|
|
140
|
+
// Smart display: first try pattern-based compression, then AI if still large
|
|
141
|
+
let displayLines = !error && lines.length > 5 ? smartDisplay(lines) : lines;
|
|
142
|
+
|
|
143
|
+
// AI-powered processing for large outputs (no hardcoded patterns)
|
|
144
|
+
if (!error && shouldProcess(lines.join("\n"))) {
|
|
145
|
+
try {
|
|
146
|
+
const processed = await processOutput(cmd, lines.join("\n"));
|
|
147
|
+
if (processed.aiProcessed && processed.tokensSaved > 50) {
|
|
148
|
+
displayLines = processed.summary.split("\n");
|
|
149
|
+
}
|
|
150
|
+
} catch { /* fallback to smartDisplay result */ }
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
const truncated = displayLines.length > MAX_LINES;
|
|
142
154
|
// Build short output summary for session context (first 10 lines of ORIGINAL output)
|
|
143
155
|
const shortOutput = lines.slice(0, 10).join("\n") + (lines.length > 10 ? `\n... (${lines.length} lines total)` : "");
|
|
@@ -185,14 +197,15 @@ export default function App() {
|
|
|
185
197
|
updateTab(t => ({ ...t, cwd: newCwd }));
|
|
186
198
|
} catch {}
|
|
187
199
|
}
|
|
188
|
-
commitStream(nl, command, lines, code !== 0)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
200
|
+
commitStream(nl, command, lines, code !== 0).then(() => {
|
|
201
|
+
abortRef.current = null;
|
|
202
|
+
if (code !== 0 && !raw) {
|
|
203
|
+
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
204
|
+
} else {
|
|
205
|
+
inputPhase({ raw });
|
|
206
|
+
}
|
|
207
|
+
resolve();
|
|
208
|
+
});
|
|
196
209
|
},
|
|
197
210
|
abort.signal
|
|
198
211
|
);
|
package/src/cli.tsx
CHANGED
|
@@ -152,6 +152,36 @@ else if (args[0] === "sessions") {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// ── Repo command ─────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
else if (args[0] === "repo") {
|
|
158
|
+
const { execSync } = await import("child_process");
|
|
159
|
+
const run = (cmd: string) => { try { return execSync(cmd, { encoding: "utf8", cwd: process.cwd() }).trim(); } catch { return ""; } };
|
|
160
|
+
const branch = run("git branch --show-current");
|
|
161
|
+
const status = run("git status --short");
|
|
162
|
+
const log = run("git log --oneline -8 --decorate");
|
|
163
|
+
console.log(`Branch: ${branch}`);
|
|
164
|
+
if (status) { console.log(`\nChanges:\n${status}`); }
|
|
165
|
+
else { console.log("\nClean working tree"); }
|
|
166
|
+
console.log(`\nRecent:\n${log}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Symbols command ──────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
else if (args[0] === "symbols" && args[1]) {
|
|
172
|
+
const { extractSymbolsFromFile } = await import("./search/semantic.js");
|
|
173
|
+
const { resolve } = await import("path");
|
|
174
|
+
const filePath = resolve(args[1]);
|
|
175
|
+
const symbols = extractSymbolsFromFile(filePath);
|
|
176
|
+
if (symbols.length === 0) { console.log("No symbols found."); }
|
|
177
|
+
else {
|
|
178
|
+
for (const s of symbols) {
|
|
179
|
+
const exp = s.exported ? "⬡" : "·";
|
|
180
|
+
console.log(` ${exp} ${s.kind.padEnd(10)} L${String(s.line).padStart(4)} ${s.name}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
155
185
|
// ── Snapshot command ─────────────────────────────────────────────────────────
|
|
156
186
|
|
|
157
187
|
else if (args[0] === "snapshot") {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Universal session file cache — cache any file read, serve from memory on repeat
|
|
2
|
+
|
|
3
|
+
import { statSync, readFileSync } from "fs";
|
|
4
|
+
|
|
5
|
+
interface CachedFile {
|
|
6
|
+
content: string;
|
|
7
|
+
mtime: number;
|
|
8
|
+
readCount: number;
|
|
9
|
+
firstReadAt: number;
|
|
10
|
+
lastReadAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const cache = new Map<string, CachedFile>();
|
|
14
|
+
|
|
15
|
+
/** Read a file with session caching. Returns content + cache metadata. */
|
|
16
|
+
export function cachedRead(
|
|
17
|
+
filePath: string,
|
|
18
|
+
options: { offset?: number; limit?: number } = {}
|
|
19
|
+
): { content: string; cached: boolean; readCount: number } {
|
|
20
|
+
const { offset, limit } = options;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const stat = statSync(filePath);
|
|
24
|
+
const mtime = stat.mtimeMs;
|
|
25
|
+
const existing = cache.get(filePath);
|
|
26
|
+
|
|
27
|
+
// Cache hit — file unchanged
|
|
28
|
+
if (existing && existing.mtime === mtime) {
|
|
29
|
+
existing.readCount++;
|
|
30
|
+
existing.lastReadAt = Date.now();
|
|
31
|
+
|
|
32
|
+
const lines = existing.content.split("\n");
|
|
33
|
+
if (offset !== undefined || limit !== undefined) {
|
|
34
|
+
const start = offset ?? 0;
|
|
35
|
+
const end = limit !== undefined ? start + limit : lines.length;
|
|
36
|
+
return {
|
|
37
|
+
content: lines.slice(start, end).join("\n"),
|
|
38
|
+
cached: true,
|
|
39
|
+
readCount: existing.readCount,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { content: existing.content, cached: true, readCount: existing.readCount };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Cache miss or stale — read from disk
|
|
47
|
+
const content = readFileSync(filePath, "utf8");
|
|
48
|
+
cache.set(filePath, {
|
|
49
|
+
content,
|
|
50
|
+
mtime,
|
|
51
|
+
readCount: 1,
|
|
52
|
+
firstReadAt: Date.now(),
|
|
53
|
+
lastReadAt: Date.now(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
if (offset !== undefined || limit !== undefined) {
|
|
58
|
+
const start = offset ?? 0;
|
|
59
|
+
const end = limit !== undefined ? start + limit : lines.length;
|
|
60
|
+
return { content: lines.slice(start, end).join("\n"), cached: false, readCount: 1 };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { content, cached: false, readCount: 1 };
|
|
64
|
+
} catch (e: any) {
|
|
65
|
+
return { content: `Error: ${e.message}`, cached: false, readCount: 0 };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Invalidate cache for a file (call after writes) */
|
|
70
|
+
export function invalidateFile(filePath: string): void {
|
|
71
|
+
cache.delete(filePath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Invalidate all files matching a pattern */
|
|
75
|
+
export function invalidatePattern(pattern: RegExp): void {
|
|
76
|
+
for (const key of cache.keys()) {
|
|
77
|
+
if (pattern.test(key)) cache.delete(key);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Get cache stats */
|
|
82
|
+
export function cacheStats(): { files: number; totalReads: number; cacheHits: number } {
|
|
83
|
+
let totalReads = 0;
|
|
84
|
+
let cacheHits = 0;
|
|
85
|
+
for (const entry of cache.values()) {
|
|
86
|
+
totalReads += entry.readCount;
|
|
87
|
+
cacheHits += Math.max(0, entry.readCount - 1); // first read is never cached
|
|
88
|
+
}
|
|
89
|
+
return { files: cache.size, totalReads, cacheHits };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Clear the entire cache */
|
|
93
|
+
export function clearFileCache(): void {
|
|
94
|
+
cache.clear();
|
|
95
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -7,12 +7,14 @@ import { spawn } from "child_process";
|
|
|
7
7
|
import { compress, stripAnsi } from "../compression.js";
|
|
8
8
|
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
9
9
|
import { summarizeOutput } from "../ai.js";
|
|
10
|
-
import { searchFiles, searchContent } from "../search/index.js";
|
|
10
|
+
import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
|
|
11
11
|
import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
|
|
12
12
|
import { substituteVariables } from "../recipes/model.js";
|
|
13
13
|
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
14
14
|
import { diffOutput } from "../diff-cache.js";
|
|
15
|
+
import { processOutput } from "../output-processor.js";
|
|
15
16
|
import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
|
|
17
|
+
import { cachedRead, cacheStats } from "../file-cache.js";
|
|
16
18
|
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
17
19
|
import { captureSnapshot } from "../snapshots.js";
|
|
18
20
|
|
|
@@ -127,6 +129,35 @@ export function createServer(): McpServer {
|
|
|
127
129
|
}
|
|
128
130
|
);
|
|
129
131
|
|
|
132
|
+
// ── execute_smart: AI-powered output processing ────────────────────────────
|
|
133
|
+
|
|
134
|
+
server.tool(
|
|
135
|
+
"execute_smart",
|
|
136
|
+
"Run a command and get AI-summarized output. The AI decides what's important — errors, failures, key results are kept; verbose logs, progress bars, passing tests are dropped. Saves 80-95% tokens vs raw output. Best tool for agents.",
|
|
137
|
+
{
|
|
138
|
+
command: z.string().describe("Shell command to execute"),
|
|
139
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
140
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
141
|
+
},
|
|
142
|
+
async ({ command, cwd, timeout }) => {
|
|
143
|
+
const result = await exec(command, cwd, timeout ?? 30000);
|
|
144
|
+
const output = (result.stdout + result.stderr).trim();
|
|
145
|
+
const processed = await processOutput(command, output);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
149
|
+
exitCode: result.exitCode,
|
|
150
|
+
summary: processed.summary,
|
|
151
|
+
structured: processed.structured,
|
|
152
|
+
duration: result.duration,
|
|
153
|
+
totalLines: output.split("\n").length,
|
|
154
|
+
tokensSaved: processed.tokensSaved,
|
|
155
|
+
aiProcessed: processed.aiProcessed,
|
|
156
|
+
}) }],
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
|
|
130
161
|
// ── browse: list files/dirs as structured JSON ────────────────────────────
|
|
131
162
|
|
|
132
163
|
server.tool(
|
|
@@ -238,6 +269,28 @@ export function createServer(): McpServer {
|
|
|
238
269
|
}
|
|
239
270
|
);
|
|
240
271
|
|
|
272
|
+
// ── search_semantic: AST-powered code search ───────────────────────────────
|
|
273
|
+
|
|
274
|
+
server.tool(
|
|
275
|
+
"search_semantic",
|
|
276
|
+
"Semantic code search — find functions, classes, components, hooks, types by meaning. Uses AST parsing, not string matching. Much more precise than grep for code navigation.",
|
|
277
|
+
{
|
|
278
|
+
query: z.string().describe("What to search for (e.g., 'auth functions', 'React components', 'database hooks')"),
|
|
279
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
280
|
+
kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
|
|
281
|
+
exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
|
|
282
|
+
maxResults: z.number().optional().describe("Max results (default: 30)"),
|
|
283
|
+
},
|
|
284
|
+
async ({ query, path, kinds, exportedOnly, maxResults }) => {
|
|
285
|
+
const result = await semanticSearch(query, path ?? process.cwd(), {
|
|
286
|
+
kinds: kinds as any,
|
|
287
|
+
exportedOnly,
|
|
288
|
+
maxResults,
|
|
289
|
+
});
|
|
290
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
|
|
241
294
|
// ── list_recipes: list saved command recipes ──────────────────────────────
|
|
242
295
|
|
|
243
296
|
server.tool(
|
|
@@ -487,6 +540,97 @@ export function createServer(): McpServer {
|
|
|
487
540
|
}
|
|
488
541
|
);
|
|
489
542
|
|
|
543
|
+
// ── read_file: cached file reading ─────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
server.tool(
|
|
546
|
+
"read_file",
|
|
547
|
+
"Read a file with session caching. Second read of unchanged file returns instantly from cache. Supports offset/limit for pagination without re-reading.",
|
|
548
|
+
{
|
|
549
|
+
path: z.string().describe("File path"),
|
|
550
|
+
offset: z.number().optional().describe("Start line (0-indexed)"),
|
|
551
|
+
limit: z.number().optional().describe("Max lines to return"),
|
|
552
|
+
},
|
|
553
|
+
async ({ path, offset, limit }) => {
|
|
554
|
+
const result = cachedRead(path, { offset, limit });
|
|
555
|
+
return {
|
|
556
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
557
|
+
content: result.content,
|
|
558
|
+
cached: result.cached,
|
|
559
|
+
readCount: result.readCount,
|
|
560
|
+
...(result.cached ? { note: `Served from cache (read #${result.readCount})` } : {}),
|
|
561
|
+
}) }],
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// ── repo_state: git status + diff + log in one call ───────────────────────
|
|
567
|
+
|
|
568
|
+
server.tool(
|
|
569
|
+
"repo_state",
|
|
570
|
+
"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.",
|
|
571
|
+
{
|
|
572
|
+
path: z.string().optional().describe("Repo path (default: cwd)"),
|
|
573
|
+
},
|
|
574
|
+
async ({ path }) => {
|
|
575
|
+
const cwd = path ?? process.cwd();
|
|
576
|
+
const [statusResult, diffResult, logResult] = await Promise.all([
|
|
577
|
+
exec("git status --porcelain", cwd),
|
|
578
|
+
exec("git diff --stat", cwd),
|
|
579
|
+
exec("git log --oneline -12 --decorate", cwd),
|
|
580
|
+
]);
|
|
581
|
+
|
|
582
|
+
const branchResult = await exec("git branch --show-current", cwd);
|
|
583
|
+
|
|
584
|
+
const staged: string[] = [];
|
|
585
|
+
const unstaged: string[] = [];
|
|
586
|
+
const untracked: string[] = [];
|
|
587
|
+
for (const line of statusResult.stdout.split("\n").filter(l => l.trim())) {
|
|
588
|
+
const x = line[0], y = line[1], file = line.slice(3);
|
|
589
|
+
if (x === "?" && y === "?") untracked.push(file);
|
|
590
|
+
else if (x !== " " && x !== "?") staged.push(file);
|
|
591
|
+
if (y !== " " && y !== "?") unstaged.push(file);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const commits = logResult.stdout.split("\n").filter(l => l.trim()).map(l => {
|
|
595
|
+
const match = l.match(/^([a-f0-9]+)\s+(.+)$/);
|
|
596
|
+
return match ? { hash: match[1], message: match[2] } : { hash: "", message: l };
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
content: [{ type: "text" as const, text: JSON.stringify({
|
|
601
|
+
branch: branchResult.stdout.trim(),
|
|
602
|
+
dirty: staged.length + unstaged.length + untracked.length > 0,
|
|
603
|
+
staged, unstaged, untracked,
|
|
604
|
+
diffSummary: diffResult.stdout.trim() || "no changes",
|
|
605
|
+
recentCommits: commits,
|
|
606
|
+
}) }],
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
// ── symbols: file structure outline ───────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
server.tool(
|
|
614
|
+
"symbols",
|
|
615
|
+
"Get a structured outline of a source file — functions, classes, interfaces, exports with line numbers. Replaces the common grep pattern: grep -n '^export|class|function' file.",
|
|
616
|
+
{
|
|
617
|
+
path: z.string().describe("File path to extract symbols from"),
|
|
618
|
+
},
|
|
619
|
+
async ({ path: filePath }) => {
|
|
620
|
+
const { semanticSearch } = await import("../search/semantic.js");
|
|
621
|
+
const dir = filePath.replace(/\/[^/]+$/, "") || ".";
|
|
622
|
+
const file = filePath.split("/").pop() ?? filePath;
|
|
623
|
+
const result = await semanticSearch(file.replace(/\.\w+$/, ""), dir, { maxResults: 50 });
|
|
624
|
+
// Filter to only symbols from the requested file
|
|
625
|
+
const fileSymbols = result.symbols.filter(s =>
|
|
626
|
+
s.file.endsWith(filePath) || s.file.endsWith("/" + filePath)
|
|
627
|
+
);
|
|
628
|
+
return {
|
|
629
|
+
content: [{ type: "text" as const, text: JSON.stringify(fileSymbols) }],
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
);
|
|
633
|
+
|
|
490
634
|
return server;
|
|
491
635
|
}
|
|
492
636
|
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// AI-powered output processor — uses cheap AI to intelligently summarize any output
|
|
2
|
+
// NOTHING is hardcoded. The AI decides what's important, what's noise, what to keep.
|
|
3
|
+
|
|
4
|
+
import { getProvider } from "./providers/index.js";
|
|
5
|
+
import { estimateTokens } from "./parsers/index.js";
|
|
6
|
+
import { recordSaving } from "./economy.js";
|
|
7
|
+
|
|
8
|
+
export interface ProcessedOutput {
|
|
9
|
+
/** AI-generated summary (concise, structured) */
|
|
10
|
+
summary: string;
|
|
11
|
+
/** Full original output (always available) */
|
|
12
|
+
full: string;
|
|
13
|
+
/** Structured JSON if the AI could extract it */
|
|
14
|
+
structured?: Record<string, unknown>;
|
|
15
|
+
/** How many tokens were saved */
|
|
16
|
+
tokensSaved: number;
|
|
17
|
+
/** Whether AI processing was used (vs passthrough) */
|
|
18
|
+
aiProcessed: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MIN_LINES_TO_PROCESS = 15;
|
|
22
|
+
const MAX_OUTPUT_FOR_AI = 8000; // chars to send to AI (truncate if longer)
|
|
23
|
+
|
|
24
|
+
const SUMMARIZE_PROMPT = `You are an output summarizer for a terminal. Given command output, return a CONCISE structured summary.
|
|
25
|
+
|
|
26
|
+
RULES:
|
|
27
|
+
- Return ONLY the summary, no explanations
|
|
28
|
+
- For test output: show pass count, fail count, and ONLY the failing test names + errors
|
|
29
|
+
- For build output: show status (ok/fail), error count, warning count
|
|
30
|
+
- For install output: show package count, time, vulnerabilities
|
|
31
|
+
- For file listings: show directory count, file count, notable files
|
|
32
|
+
- For git output: show branch, status, key info
|
|
33
|
+
- For logs: show line count, error count, latest error
|
|
34
|
+
- For search results: show match count, top files
|
|
35
|
+
- For ANY output: keep errors/failures/warnings, drop verbose/repetitive/progress lines
|
|
36
|
+
- Use symbols: ✓ for success, ✗ for failure, ⚠ for warnings
|
|
37
|
+
- Maximum 8 lines in your summary
|
|
38
|
+
- If there are errors, ALWAYS include them verbatim`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Process command output through AI summarization.
|
|
42
|
+
* Cheap AI call (~100 tokens) saves 1000+ tokens downstream.
|
|
43
|
+
*/
|
|
44
|
+
export async function processOutput(
|
|
45
|
+
command: string,
|
|
46
|
+
output: string,
|
|
47
|
+
): Promise<ProcessedOutput> {
|
|
48
|
+
const lines = output.split("\n");
|
|
49
|
+
|
|
50
|
+
// Short output — pass through, no AI needed
|
|
51
|
+
if (lines.length <= MIN_LINES_TO_PROCESS) {
|
|
52
|
+
return {
|
|
53
|
+
summary: output,
|
|
54
|
+
full: output,
|
|
55
|
+
tokensSaved: 0,
|
|
56
|
+
aiProcessed: false,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Truncate very long output before sending to AI
|
|
61
|
+
let toSummarize = output;
|
|
62
|
+
if (toSummarize.length > MAX_OUTPUT_FOR_AI) {
|
|
63
|
+
const headChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.6);
|
|
64
|
+
const tailChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.3);
|
|
65
|
+
toSummarize = output.slice(0, headChars) +
|
|
66
|
+
`\n\n... (${lines.length} total lines, middle truncated) ...\n\n` +
|
|
67
|
+
output.slice(-tailChars);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const provider = getProvider();
|
|
72
|
+
const summary = await provider.complete(
|
|
73
|
+
`Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`,
|
|
74
|
+
{
|
|
75
|
+
system: SUMMARIZE_PROMPT,
|
|
76
|
+
maxTokens: 300,
|
|
77
|
+
}
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const originalTokens = estimateTokens(output);
|
|
81
|
+
const summaryTokens = estimateTokens(summary);
|
|
82
|
+
const saved = Math.max(0, originalTokens - summaryTokens);
|
|
83
|
+
|
|
84
|
+
if (saved > 0) {
|
|
85
|
+
recordSaving("compressed", saved);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Try to extract structured JSON if the AI returned it
|
|
89
|
+
let structured: Record<string, unknown> | undefined;
|
|
90
|
+
try {
|
|
91
|
+
const jsonMatch = summary.match(/\{[\s\S]*\}/);
|
|
92
|
+
if (jsonMatch) {
|
|
93
|
+
structured = JSON.parse(jsonMatch[0]);
|
|
94
|
+
}
|
|
95
|
+
} catch { /* not JSON, that's fine */ }
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
summary,
|
|
99
|
+
full: output,
|
|
100
|
+
structured,
|
|
101
|
+
tokensSaved: saved,
|
|
102
|
+
aiProcessed: true,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
// AI unavailable — fall back to simple truncation
|
|
106
|
+
const head = lines.slice(0, 5).join("\n");
|
|
107
|
+
const tail = lines.slice(-5).join("\n");
|
|
108
|
+
const fallback = `${head}\n ... (${lines.length - 10} lines hidden) ...\n${tail}`;
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
summary: fallback,
|
|
112
|
+
full: output,
|
|
113
|
+
tokensSaved: Math.max(0, estimateTokens(output) - estimateTokens(fallback)),
|
|
114
|
+
aiProcessed: false,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Lightweight version — just decides IF output should be processed.
|
|
121
|
+
* Returns true if the output would benefit from AI summarization.
|
|
122
|
+
*/
|
|
123
|
+
export function shouldProcess(output: string): boolean {
|
|
124
|
+
return output.split("\n").length > MIN_LINES_TO_PROCESS;
|
|
125
|
+
}
|
|
@@ -93,5 +93,13 @@ export async function searchContent(
|
|
|
93
93
|
const resultTokens = Math.ceil(JSON.stringify(result).length / 4);
|
|
94
94
|
result.tokensSaved = Math.max(0, rawTokens - resultTokens);
|
|
95
95
|
|
|
96
|
+
// Overflow guard — warn when results are truncated
|
|
97
|
+
if (totalMatches > maxResults * 3) {
|
|
98
|
+
(result as any).overflow = {
|
|
99
|
+
warning: `${totalMatches} total matches across ${fileMap.size} files — showing top ${files.length}`,
|
|
100
|
+
suggestion: "Try a more specific pattern, add fileType filter, or use -l to list files only",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
return result;
|
|
97
105
|
}
|
package/src/search/index.ts
CHANGED
|
@@ -5,3 +5,5 @@ export type { FileSearchResult } from "./file-search.js";
|
|
|
5
5
|
export { searchContent } from "./content-search.js";
|
|
6
6
|
export type { ContentSearchResult, ContentFileMatch, ContentMatch } from "./content-search.js";
|
|
7
7
|
export { DEFAULT_EXCLUDE_DIRS, SOURCE_EXTENSIONS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
|
|
8
|
+
export { semanticSearch, findExports, findComponents, findHooks } from "./semantic.js";
|
|
9
|
+
export type { CodeSymbol, SemanticSearchResult } from "./semantic.js";
|