@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/App.js
CHANGED
|
@@ -12,6 +12,7 @@ import Browse from "./Browse.js";
|
|
|
12
12
|
import FuzzyPicker from "./FuzzyPicker.js";
|
|
13
13
|
import { createSession, logInteraction, updateInteraction } from "./sessions-db.js";
|
|
14
14
|
import { smartDisplay } from "./smart-display.js";
|
|
15
|
+
import { processOutput, shouldProcess } from "./output-processor.js";
|
|
15
16
|
loadCache();
|
|
16
17
|
const MAX_LINES = 20;
|
|
17
18
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -84,10 +85,20 @@ export default function App() {
|
|
|
84
85
|
}));
|
|
85
86
|
};
|
|
86
87
|
const pushScroll = (entry) => updateTab(t => ({ ...t, scroll: [...t.scroll, { ...entry, expanded: false }] }));
|
|
87
|
-
const commitStream = (nl, cmd, lines, error) => {
|
|
88
|
+
const commitStream = async (nl, cmd, lines, error) => {
|
|
88
89
|
const filePaths = !error ? extractFilePaths(lines) : [];
|
|
89
|
-
// Smart display:
|
|
90
|
-
|
|
90
|
+
// Smart display: first try pattern-based compression, then AI if still large
|
|
91
|
+
let displayLines = !error && lines.length > 5 ? smartDisplay(lines) : lines;
|
|
92
|
+
// AI-powered processing for large outputs (no hardcoded patterns)
|
|
93
|
+
if (!error && shouldProcess(lines.join("\n"))) {
|
|
94
|
+
try {
|
|
95
|
+
const processed = await processOutput(cmd, lines.join("\n"));
|
|
96
|
+
if (processed.aiProcessed && processed.tokensSaved > 50) {
|
|
97
|
+
displayLines = processed.summary.split("\n");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { /* fallback to smartDisplay result */ }
|
|
101
|
+
}
|
|
91
102
|
const truncated = displayLines.length > MAX_LINES;
|
|
92
103
|
// Build short output summary for session context (first 10 lines of ORIGINAL output)
|
|
93
104
|
const shortOutput = lines.slice(0, 10).join("\n") + (lines.length > 10 ? `\n... (${lines.length} lines total)` : "");
|
|
@@ -131,15 +142,16 @@ export default function App() {
|
|
|
131
142
|
}
|
|
132
143
|
catch { }
|
|
133
144
|
}
|
|
134
|
-
commitStream(nl, command, lines, code !== 0)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
commitStream(nl, command, lines, code !== 0).then(() => {
|
|
146
|
+
abortRef.current = null;
|
|
147
|
+
if (code !== 0 && !raw) {
|
|
148
|
+
setPhase({ type: "autofix", nl, command, errorOutput: lines.join("\n") });
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
inputPhase({ raw });
|
|
152
|
+
}
|
|
153
|
+
resolve();
|
|
154
|
+
});
|
|
143
155
|
}, abort.signal);
|
|
144
156
|
});
|
|
145
157
|
};
|
package/dist/cli.js
CHANGED
|
@@ -167,6 +167,43 @@ else if (args[0] === "sessions") {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
}
|
|
170
|
+
// ── Repo command ─────────────────────────────────────────────────────────────
|
|
171
|
+
else if (args[0] === "repo") {
|
|
172
|
+
const { execSync } = await import("child_process");
|
|
173
|
+
const run = (cmd) => { try {
|
|
174
|
+
return execSync(cmd, { encoding: "utf8", cwd: process.cwd() }).trim();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return "";
|
|
178
|
+
} };
|
|
179
|
+
const branch = run("git branch --show-current");
|
|
180
|
+
const status = run("git status --short");
|
|
181
|
+
const log = run("git log --oneline -8 --decorate");
|
|
182
|
+
console.log(`Branch: ${branch}`);
|
|
183
|
+
if (status) {
|
|
184
|
+
console.log(`\nChanges:\n${status}`);
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
console.log("\nClean working tree");
|
|
188
|
+
}
|
|
189
|
+
console.log(`\nRecent:\n${log}`);
|
|
190
|
+
}
|
|
191
|
+
// ── Symbols command ──────────────────────────────────────────────────────────
|
|
192
|
+
else if (args[0] === "symbols" && args[1]) {
|
|
193
|
+
const { extractSymbolsFromFile } = await import("./search/semantic.js");
|
|
194
|
+
const { resolve } = await import("path");
|
|
195
|
+
const filePath = resolve(args[1]);
|
|
196
|
+
const symbols = extractSymbolsFromFile(filePath);
|
|
197
|
+
if (symbols.length === 0) {
|
|
198
|
+
console.log("No symbols found.");
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
for (const s of symbols) {
|
|
202
|
+
const exp = s.exported ? "⬡" : "·";
|
|
203
|
+
console.log(` ${exp} ${s.kind.padEnd(10)} L${String(s.line).padStart(4)} ${s.name}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
170
207
|
// ── Snapshot command ─────────────────────────────────────────────────────────
|
|
171
208
|
else if (args[0] === "snapshot") {
|
|
172
209
|
const { captureSnapshot } = await import("./snapshots.js");
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Universal session file cache — cache any file read, serve from memory on repeat
|
|
2
|
+
import { statSync, readFileSync } from "fs";
|
|
3
|
+
const cache = new Map();
|
|
4
|
+
/** Read a file with session caching. Returns content + cache metadata. */
|
|
5
|
+
export function cachedRead(filePath, options = {}) {
|
|
6
|
+
const { offset, limit } = options;
|
|
7
|
+
try {
|
|
8
|
+
const stat = statSync(filePath);
|
|
9
|
+
const mtime = stat.mtimeMs;
|
|
10
|
+
const existing = cache.get(filePath);
|
|
11
|
+
// Cache hit — file unchanged
|
|
12
|
+
if (existing && existing.mtime === mtime) {
|
|
13
|
+
existing.readCount++;
|
|
14
|
+
existing.lastReadAt = Date.now();
|
|
15
|
+
const lines = existing.content.split("\n");
|
|
16
|
+
if (offset !== undefined || limit !== undefined) {
|
|
17
|
+
const start = offset ?? 0;
|
|
18
|
+
const end = limit !== undefined ? start + limit : lines.length;
|
|
19
|
+
return {
|
|
20
|
+
content: lines.slice(start, end).join("\n"),
|
|
21
|
+
cached: true,
|
|
22
|
+
readCount: existing.readCount,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return { content: existing.content, cached: true, readCount: existing.readCount };
|
|
26
|
+
}
|
|
27
|
+
// Cache miss or stale — read from disk
|
|
28
|
+
const content = readFileSync(filePath, "utf8");
|
|
29
|
+
cache.set(filePath, {
|
|
30
|
+
content,
|
|
31
|
+
mtime,
|
|
32
|
+
readCount: 1,
|
|
33
|
+
firstReadAt: Date.now(),
|
|
34
|
+
lastReadAt: Date.now(),
|
|
35
|
+
});
|
|
36
|
+
const lines = content.split("\n");
|
|
37
|
+
if (offset !== undefined || limit !== undefined) {
|
|
38
|
+
const start = offset ?? 0;
|
|
39
|
+
const end = limit !== undefined ? start + limit : lines.length;
|
|
40
|
+
return { content: lines.slice(start, end).join("\n"), cached: false, readCount: 1 };
|
|
41
|
+
}
|
|
42
|
+
return { content, cached: false, readCount: 1 };
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
return { content: `Error: ${e.message}`, cached: false, readCount: 0 };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Invalidate cache for a file (call after writes) */
|
|
49
|
+
export function invalidateFile(filePath) {
|
|
50
|
+
cache.delete(filePath);
|
|
51
|
+
}
|
|
52
|
+
/** Invalidate all files matching a pattern */
|
|
53
|
+
export function invalidatePattern(pattern) {
|
|
54
|
+
for (const key of cache.keys()) {
|
|
55
|
+
if (pattern.test(key))
|
|
56
|
+
cache.delete(key);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Get cache stats */
|
|
60
|
+
export function cacheStats() {
|
|
61
|
+
let totalReads = 0;
|
|
62
|
+
let cacheHits = 0;
|
|
63
|
+
for (const entry of cache.values()) {
|
|
64
|
+
totalReads += entry.readCount;
|
|
65
|
+
cacheHits += Math.max(0, entry.readCount - 1); // first read is never cached
|
|
66
|
+
}
|
|
67
|
+
return { files: cache.size, totalReads, cacheHits };
|
|
68
|
+
}
|
|
69
|
+
/** Clear the entire cache */
|
|
70
|
+
export function clearFileCache() {
|
|
71
|
+
cache.clear();
|
|
72
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -6,12 +6,14 @@ import { spawn } from "child_process";
|
|
|
6
6
|
import { compress, stripAnsi } from "../compression.js";
|
|
7
7
|
import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
|
|
8
8
|
import { summarizeOutput } from "../ai.js";
|
|
9
|
-
import { searchFiles, searchContent } from "../search/index.js";
|
|
9
|
+
import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
|
|
10
10
|
import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
|
|
11
11
|
import { substituteVariables } from "../recipes/model.js";
|
|
12
12
|
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
13
13
|
import { diffOutput } from "../diff-cache.js";
|
|
14
|
+
import { processOutput } from "../output-processor.js";
|
|
14
15
|
import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
|
|
16
|
+
import { cachedRead } from "../file-cache.js";
|
|
15
17
|
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
16
18
|
import { captureSnapshot } from "../snapshots.js";
|
|
17
19
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -110,6 +112,27 @@ export function createServer() {
|
|
|
110
112
|
}
|
|
111
113
|
return { content: [{ type: "text", text: output }] };
|
|
112
114
|
});
|
|
115
|
+
// ── execute_smart: AI-powered output processing ────────────────────────────
|
|
116
|
+
server.tool("execute_smart", "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.", {
|
|
117
|
+
command: z.string().describe("Shell command to execute"),
|
|
118
|
+
cwd: z.string().optional().describe("Working directory"),
|
|
119
|
+
timeout: z.number().optional().describe("Timeout in ms (default: 30000)"),
|
|
120
|
+
}, async ({ command, cwd, timeout }) => {
|
|
121
|
+
const result = await exec(command, cwd, timeout ?? 30000);
|
|
122
|
+
const output = (result.stdout + result.stderr).trim();
|
|
123
|
+
const processed = await processOutput(command, output);
|
|
124
|
+
return {
|
|
125
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
126
|
+
exitCode: result.exitCode,
|
|
127
|
+
summary: processed.summary,
|
|
128
|
+
structured: processed.structured,
|
|
129
|
+
duration: result.duration,
|
|
130
|
+
totalLines: output.split("\n").length,
|
|
131
|
+
tokensSaved: processed.tokensSaved,
|
|
132
|
+
aiProcessed: processed.aiProcessed,
|
|
133
|
+
}) }],
|
|
134
|
+
};
|
|
135
|
+
});
|
|
113
136
|
// ── browse: list files/dirs as structured JSON ────────────────────────────
|
|
114
137
|
server.tool("browse", "List files and directories as structured JSON. Auto-filters node_modules, .git, dist by default.", {
|
|
115
138
|
path: z.string().optional().describe("Directory path (default: cwd)"),
|
|
@@ -185,6 +208,21 @@ export function createServer() {
|
|
|
185
208
|
const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
|
|
186
209
|
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
187
210
|
});
|
|
211
|
+
// ── search_semantic: AST-powered code search ───────────────────────────────
|
|
212
|
+
server.tool("search_semantic", "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.", {
|
|
213
|
+
query: z.string().describe("What to search for (e.g., 'auth functions', 'React components', 'database hooks')"),
|
|
214
|
+
path: z.string().optional().describe("Search root (default: cwd)"),
|
|
215
|
+
kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
|
|
216
|
+
exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
|
|
217
|
+
maxResults: z.number().optional().describe("Max results (default: 30)"),
|
|
218
|
+
}, async ({ query, path, kinds, exportedOnly, maxResults }) => {
|
|
219
|
+
const result = await semanticSearch(query, path ?? process.cwd(), {
|
|
220
|
+
kinds: kinds,
|
|
221
|
+
exportedOnly,
|
|
222
|
+
maxResults,
|
|
223
|
+
});
|
|
224
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
225
|
+
});
|
|
188
226
|
// ── list_recipes: list saved command recipes ──────────────────────────────
|
|
189
227
|
server.tool("list_recipes", "List saved command recipes. Optionally filter by collection or project.", {
|
|
190
228
|
collection: z.string().optional().describe("Filter by collection name"),
|
|
@@ -339,6 +377,73 @@ export function createServer() {
|
|
|
339
377
|
const sessions = listSessions(limit ?? 20);
|
|
340
378
|
return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
|
|
341
379
|
});
|
|
380
|
+
// ── read_file: cached file reading ─────────────────────────────────────────
|
|
381
|
+
server.tool("read_file", "Read a file with session caching. Second read of unchanged file returns instantly from cache. Supports offset/limit for pagination without re-reading.", {
|
|
382
|
+
path: z.string().describe("File path"),
|
|
383
|
+
offset: z.number().optional().describe("Start line (0-indexed)"),
|
|
384
|
+
limit: z.number().optional().describe("Max lines to return"),
|
|
385
|
+
}, async ({ path, offset, limit }) => {
|
|
386
|
+
const result = cachedRead(path, { offset, limit });
|
|
387
|
+
return {
|
|
388
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
389
|
+
content: result.content,
|
|
390
|
+
cached: result.cached,
|
|
391
|
+
readCount: result.readCount,
|
|
392
|
+
...(result.cached ? { note: `Served from cache (read #${result.readCount})` } : {}),
|
|
393
|
+
}) }],
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
// ── repo_state: git status + diff + log in one call ───────────────────────
|
|
397
|
+
server.tool("repo_state", "Get full repository state in one call — branch, status, staged/unstaged files, recent commits. Replaces the common 3-command pattern: git status + git diff --stat + git log.", {
|
|
398
|
+
path: z.string().optional().describe("Repo path (default: cwd)"),
|
|
399
|
+
}, async ({ path }) => {
|
|
400
|
+
const cwd = path ?? process.cwd();
|
|
401
|
+
const [statusResult, diffResult, logResult] = await Promise.all([
|
|
402
|
+
exec("git status --porcelain", cwd),
|
|
403
|
+
exec("git diff --stat", cwd),
|
|
404
|
+
exec("git log --oneline -12 --decorate", cwd),
|
|
405
|
+
]);
|
|
406
|
+
const branchResult = await exec("git branch --show-current", cwd);
|
|
407
|
+
const staged = [];
|
|
408
|
+
const unstaged = [];
|
|
409
|
+
const untracked = [];
|
|
410
|
+
for (const line of statusResult.stdout.split("\n").filter(l => l.trim())) {
|
|
411
|
+
const x = line[0], y = line[1], file = line.slice(3);
|
|
412
|
+
if (x === "?" && y === "?")
|
|
413
|
+
untracked.push(file);
|
|
414
|
+
else if (x !== " " && x !== "?")
|
|
415
|
+
staged.push(file);
|
|
416
|
+
if (y !== " " && y !== "?")
|
|
417
|
+
unstaged.push(file);
|
|
418
|
+
}
|
|
419
|
+
const commits = logResult.stdout.split("\n").filter(l => l.trim()).map(l => {
|
|
420
|
+
const match = l.match(/^([a-f0-9]+)\s+(.+)$/);
|
|
421
|
+
return match ? { hash: match[1], message: match[2] } : { hash: "", message: l };
|
|
422
|
+
});
|
|
423
|
+
return {
|
|
424
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
425
|
+
branch: branchResult.stdout.trim(),
|
|
426
|
+
dirty: staged.length + unstaged.length + untracked.length > 0,
|
|
427
|
+
staged, unstaged, untracked,
|
|
428
|
+
diffSummary: diffResult.stdout.trim() || "no changes",
|
|
429
|
+
recentCommits: commits,
|
|
430
|
+
}) }],
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
// ── symbols: file structure outline ───────────────────────────────────────
|
|
434
|
+
server.tool("symbols", "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.", {
|
|
435
|
+
path: z.string().describe("File path to extract symbols from"),
|
|
436
|
+
}, async ({ path: filePath }) => {
|
|
437
|
+
const { semanticSearch } = await import("../search/semantic.js");
|
|
438
|
+
const dir = filePath.replace(/\/[^/]+$/, "") || ".";
|
|
439
|
+
const file = filePath.split("/").pop() ?? filePath;
|
|
440
|
+
const result = await semanticSearch(file.replace(/\.\w+$/, ""), dir, { maxResults: 50 });
|
|
441
|
+
// Filter to only symbols from the requested file
|
|
442
|
+
const fileSymbols = result.symbols.filter(s => s.file.endsWith(filePath) || s.file.endsWith("/" + filePath));
|
|
443
|
+
return {
|
|
444
|
+
content: [{ type: "text", text: JSON.stringify(fileSymbols) }],
|
|
445
|
+
};
|
|
446
|
+
});
|
|
342
447
|
return server;
|
|
343
448
|
}
|
|
344
449
|
// ── main: start MCP server via stdio ─────────────────────────────────────────
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
import { getProvider } from "./providers/index.js";
|
|
4
|
+
import { estimateTokens } from "./parsers/index.js";
|
|
5
|
+
import { recordSaving } from "./economy.js";
|
|
6
|
+
const MIN_LINES_TO_PROCESS = 15;
|
|
7
|
+
const MAX_OUTPUT_FOR_AI = 8000; // chars to send to AI (truncate if longer)
|
|
8
|
+
const SUMMARIZE_PROMPT = `You are an output summarizer for a terminal. Given command output, return a CONCISE structured summary.
|
|
9
|
+
|
|
10
|
+
RULES:
|
|
11
|
+
- Return ONLY the summary, no explanations
|
|
12
|
+
- For test output: show pass count, fail count, and ONLY the failing test names + errors
|
|
13
|
+
- For build output: show status (ok/fail), error count, warning count
|
|
14
|
+
- For install output: show package count, time, vulnerabilities
|
|
15
|
+
- For file listings: show directory count, file count, notable files
|
|
16
|
+
- For git output: show branch, status, key info
|
|
17
|
+
- For logs: show line count, error count, latest error
|
|
18
|
+
- For search results: show match count, top files
|
|
19
|
+
- For ANY output: keep errors/failures/warnings, drop verbose/repetitive/progress lines
|
|
20
|
+
- Use symbols: ✓ for success, ✗ for failure, ⚠ for warnings
|
|
21
|
+
- Maximum 8 lines in your summary
|
|
22
|
+
- If there are errors, ALWAYS include them verbatim`;
|
|
23
|
+
/**
|
|
24
|
+
* Process command output through AI summarization.
|
|
25
|
+
* Cheap AI call (~100 tokens) saves 1000+ tokens downstream.
|
|
26
|
+
*/
|
|
27
|
+
export async function processOutput(command, output) {
|
|
28
|
+
const lines = output.split("\n");
|
|
29
|
+
// Short output — pass through, no AI needed
|
|
30
|
+
if (lines.length <= MIN_LINES_TO_PROCESS) {
|
|
31
|
+
return {
|
|
32
|
+
summary: output,
|
|
33
|
+
full: output,
|
|
34
|
+
tokensSaved: 0,
|
|
35
|
+
aiProcessed: false,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Truncate very long output before sending to AI
|
|
39
|
+
let toSummarize = output;
|
|
40
|
+
if (toSummarize.length > MAX_OUTPUT_FOR_AI) {
|
|
41
|
+
const headChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.6);
|
|
42
|
+
const tailChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.3);
|
|
43
|
+
toSummarize = output.slice(0, headChars) +
|
|
44
|
+
`\n\n... (${lines.length} total lines, middle truncated) ...\n\n` +
|
|
45
|
+
output.slice(-tailChars);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const provider = getProvider();
|
|
49
|
+
const summary = await provider.complete(`Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}`, {
|
|
50
|
+
system: SUMMARIZE_PROMPT,
|
|
51
|
+
maxTokens: 300,
|
|
52
|
+
});
|
|
53
|
+
const originalTokens = estimateTokens(output);
|
|
54
|
+
const summaryTokens = estimateTokens(summary);
|
|
55
|
+
const saved = Math.max(0, originalTokens - summaryTokens);
|
|
56
|
+
if (saved > 0) {
|
|
57
|
+
recordSaving("compressed", saved);
|
|
58
|
+
}
|
|
59
|
+
// Try to extract structured JSON if the AI returned it
|
|
60
|
+
let structured;
|
|
61
|
+
try {
|
|
62
|
+
const jsonMatch = summary.match(/\{[\s\S]*\}/);
|
|
63
|
+
if (jsonMatch) {
|
|
64
|
+
structured = JSON.parse(jsonMatch[0]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* not JSON, that's fine */ }
|
|
68
|
+
return {
|
|
69
|
+
summary,
|
|
70
|
+
full: output,
|
|
71
|
+
structured,
|
|
72
|
+
tokensSaved: saved,
|
|
73
|
+
aiProcessed: true,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// AI unavailable — fall back to simple truncation
|
|
78
|
+
const head = lines.slice(0, 5).join("\n");
|
|
79
|
+
const tail = lines.slice(-5).join("\n");
|
|
80
|
+
const fallback = `${head}\n ... (${lines.length - 10} lines hidden) ...\n${tail}`;
|
|
81
|
+
return {
|
|
82
|
+
summary: fallback,
|
|
83
|
+
full: output,
|
|
84
|
+
tokensSaved: Math.max(0, estimateTokens(output) - estimateTokens(fallback)),
|
|
85
|
+
aiProcessed: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Lightweight version — just decides IF output should be processed.
|
|
91
|
+
* Returns true if the output would benefit from AI summarization.
|
|
92
|
+
*/
|
|
93
|
+
export function shouldProcess(output) {
|
|
94
|
+
return output.split("\n").length > MIN_LINES_TO_PROCESS;
|
|
95
|
+
}
|
|
@@ -57,5 +57,12 @@ export async function searchContent(pattern, cwd, options = {}) {
|
|
|
57
57
|
const result = { query: pattern, totalMatches, files, filtered };
|
|
58
58
|
const resultTokens = Math.ceil(JSON.stringify(result).length / 4);
|
|
59
59
|
result.tokensSaved = Math.max(0, rawTokens - resultTokens);
|
|
60
|
+
// Overflow guard — warn when results are truncated
|
|
61
|
+
if (totalMatches > maxResults * 3) {
|
|
62
|
+
result.overflow = {
|
|
63
|
+
warning: `${totalMatches} total matches across ${fileMap.size} files — showing top ${files.length}`,
|
|
64
|
+
suggestion: "Try a more specific pattern, add fileType filter, or use -l to list files only",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
60
67
|
return result;
|
|
61
68
|
}
|
package/dist/search/index.js
CHANGED
|
@@ -2,3 +2,4 @@
|
|
|
2
2
|
export { searchFiles } from "./file-search.js";
|
|
3
3
|
export { searchContent } from "./content-search.js";
|
|
4
4
|
export { DEFAULT_EXCLUDE_DIRS, SOURCE_EXTENSIONS, isSourceFile, isExcludedDir, relevanceScore } from "./filters.js";
|
|
5
|
+
export { semanticSearch, findExports, findComponents, findHooks } from "./semantic.js";
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Semantic code search — AST-powered search that understands code structure
|
|
2
|
+
// Instead of raw grep, searches by meaning: "find auth functions" → login(), verifyToken()
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { readFileSync, existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
function exec(command, cwd) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
9
|
+
let out = "";
|
|
10
|
+
proc.stdout?.on("data", (d) => { out += d.toString(); });
|
|
11
|
+
proc.stderr?.on("data", (d) => { });
|
|
12
|
+
proc.on("close", () => resolve(out));
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/** Extract code symbols from a TypeScript/JavaScript file using regex-based parsing */
|
|
16
|
+
export function extractSymbolsFromFile(filePath) {
|
|
17
|
+
return extractSymbols(filePath);
|
|
18
|
+
}
|
|
19
|
+
function extractSymbols(filePath) {
|
|
20
|
+
if (!existsSync(filePath))
|
|
21
|
+
return [];
|
|
22
|
+
const content = readFileSync(filePath, "utf8");
|
|
23
|
+
const lines = content.split("\n");
|
|
24
|
+
const symbols = [];
|
|
25
|
+
const file = filePath;
|
|
26
|
+
for (let i = 0; i < lines.length; i++) {
|
|
27
|
+
const line = lines[i];
|
|
28
|
+
const lineNum = i + 1;
|
|
29
|
+
const isExported = line.trimStart().startsWith("export");
|
|
30
|
+
// Functions: export function X(...) or export const X = (...) =>
|
|
31
|
+
const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
|
|
32
|
+
if (funcMatch) {
|
|
33
|
+
const prevLine = i > 0 ? lines[i - 1] : "";
|
|
34
|
+
const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
|
|
35
|
+
? prevLine.trim().replace(/^\/\*\*\s*|\s*\*\/$/g, "").replace(/^\/\/\s*/, "")
|
|
36
|
+
: undefined;
|
|
37
|
+
symbols.push({
|
|
38
|
+
name: funcMatch[1], kind: "function", file, line: lineNum,
|
|
39
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
40
|
+
exported: isExported, doc,
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Arrow functions: export const X = (...) =>
|
|
45
|
+
const arrowMatch = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*\w[^=]*)?\s*=>/);
|
|
46
|
+
if (arrowMatch) {
|
|
47
|
+
// Detect React hooks
|
|
48
|
+
const isHook = arrowMatch[1].startsWith("use");
|
|
49
|
+
const isComponent = /^[A-Z]/.test(arrowMatch[1]);
|
|
50
|
+
symbols.push({
|
|
51
|
+
name: arrowMatch[1],
|
|
52
|
+
kind: isHook ? "hook" : isComponent ? "component" : "function",
|
|
53
|
+
file, line: lineNum,
|
|
54
|
+
signature: line.trim().replace(/\{.*$/, "").replace(/=>.*$/, "=>").trim(),
|
|
55
|
+
exported: isExported,
|
|
56
|
+
});
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
// Classes
|
|
60
|
+
const classMatch = line.match(/(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
|
|
61
|
+
if (classMatch) {
|
|
62
|
+
symbols.push({
|
|
63
|
+
name: classMatch[1], kind: "class", file, line: lineNum,
|
|
64
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
65
|
+
exported: isExported,
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// Interfaces
|
|
70
|
+
const ifaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
|
|
71
|
+
if (ifaceMatch) {
|
|
72
|
+
symbols.push({
|
|
73
|
+
name: ifaceMatch[1], kind: "interface", file, line: lineNum,
|
|
74
|
+
signature: line.trim().replace(/\{.*$/, "").trim(),
|
|
75
|
+
exported: isExported,
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Type aliases
|
|
80
|
+
const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
|
|
81
|
+
if (typeMatch) {
|
|
82
|
+
symbols.push({
|
|
83
|
+
name: typeMatch[1], kind: "type", file, line: lineNum,
|
|
84
|
+
signature: line.trim(),
|
|
85
|
+
exported: isExported,
|
|
86
|
+
});
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
// Imports (for dependency tracking)
|
|
90
|
+
const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/);
|
|
91
|
+
if (importMatch) {
|
|
92
|
+
const names = importMatch[1]
|
|
93
|
+
? importMatch[1].split(",").map(s => s.trim().split(" as ")[0].trim())
|
|
94
|
+
: [importMatch[2]];
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
if (name) {
|
|
97
|
+
symbols.push({
|
|
98
|
+
name, kind: "import", file, line: lineNum,
|
|
99
|
+
signature: `from '${importMatch[3]}'`,
|
|
100
|
+
exported: false,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
// Exported constants/variables
|
|
107
|
+
const constMatch = line.match(/export\s+const\s+(\w+)\s*[=:]/);
|
|
108
|
+
if (constMatch && !arrowMatch) {
|
|
109
|
+
symbols.push({
|
|
110
|
+
name: constMatch[1], kind: "variable", file, line: lineNum,
|
|
111
|
+
signature: line.trim().slice(0, 80),
|
|
112
|
+
exported: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return symbols;
|
|
117
|
+
}
|
|
118
|
+
/** Find all source files in a directory */
|
|
119
|
+
async function findSourceFiles(cwd, maxFiles = 200) {
|
|
120
|
+
const excludes = ["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"];
|
|
121
|
+
const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
|
|
122
|
+
const extensions = "\\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \\)";
|
|
123
|
+
const cmd = `find . ${extensions} ${excludeArgs} -type f 2>/dev/null | head -${maxFiles}`;
|
|
124
|
+
const output = await exec(cmd, cwd);
|
|
125
|
+
return output.split("\n").filter(l => l.trim()).map(l => join(cwd, l.trim()));
|
|
126
|
+
}
|
|
127
|
+
/** Semantic search: find symbols matching a natural language query */
|
|
128
|
+
export async function semanticSearch(query, cwd, options = {}) {
|
|
129
|
+
const { kinds, exportedOnly = false, maxResults = 30 } = options;
|
|
130
|
+
// Find all source files
|
|
131
|
+
const files = await findSourceFiles(cwd);
|
|
132
|
+
// Extract symbols from all files
|
|
133
|
+
let allSymbols = [];
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
try {
|
|
136
|
+
allSymbols.push(...extractSymbols(file));
|
|
137
|
+
}
|
|
138
|
+
catch { /* skip unreadable files */ }
|
|
139
|
+
}
|
|
140
|
+
// Filter by kind
|
|
141
|
+
if (kinds) {
|
|
142
|
+
allSymbols = allSymbols.filter(s => kinds.includes(s.kind));
|
|
143
|
+
}
|
|
144
|
+
// Filter by exported
|
|
145
|
+
if (exportedOnly) {
|
|
146
|
+
allSymbols = allSymbols.filter(s => s.exported);
|
|
147
|
+
}
|
|
148
|
+
// Score each symbol against the query
|
|
149
|
+
const queryLower = query.toLowerCase();
|
|
150
|
+
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
151
|
+
const scored = allSymbols.map(symbol => {
|
|
152
|
+
let score = 0;
|
|
153
|
+
const nameLower = symbol.name.toLowerCase();
|
|
154
|
+
const sigLower = (symbol.signature ?? "").toLowerCase();
|
|
155
|
+
const fileLower = symbol.file.toLowerCase();
|
|
156
|
+
// Exact name match
|
|
157
|
+
if (queryWords.some(w => nameLower === w))
|
|
158
|
+
score += 10;
|
|
159
|
+
// Name contains query word
|
|
160
|
+
if (queryWords.some(w => nameLower.includes(w)))
|
|
161
|
+
score += 5;
|
|
162
|
+
// Signature contains query word
|
|
163
|
+
if (queryWords.some(w => sigLower.includes(w)))
|
|
164
|
+
score += 3;
|
|
165
|
+
// File path contains query word
|
|
166
|
+
if (queryWords.some(w => fileLower.includes(w)))
|
|
167
|
+
score += 2;
|
|
168
|
+
// Doc contains query word
|
|
169
|
+
if (symbol.doc && queryWords.some(w => symbol.doc.toLowerCase().includes(w)))
|
|
170
|
+
score += 4;
|
|
171
|
+
// Boost exported symbols
|
|
172
|
+
if (symbol.exported)
|
|
173
|
+
score += 1;
|
|
174
|
+
// Boost functions/classes over imports
|
|
175
|
+
if (symbol.kind === "function" || symbol.kind === "class")
|
|
176
|
+
score += 1;
|
|
177
|
+
// Semantic matching for common patterns
|
|
178
|
+
if (queryLower.includes("component") && symbol.kind === "component")
|
|
179
|
+
score += 5;
|
|
180
|
+
if (queryLower.includes("hook") && symbol.kind === "hook")
|
|
181
|
+
score += 5;
|
|
182
|
+
if (queryLower.includes("type") && (symbol.kind === "type" || symbol.kind === "interface"))
|
|
183
|
+
score += 5;
|
|
184
|
+
if (queryLower.includes("import") && symbol.kind === "import")
|
|
185
|
+
score += 5;
|
|
186
|
+
if (queryLower.includes("class") && symbol.kind === "class")
|
|
187
|
+
score += 5;
|
|
188
|
+
return { symbol, score };
|
|
189
|
+
});
|
|
190
|
+
// Sort by score, filter zero scores
|
|
191
|
+
const results = scored
|
|
192
|
+
.filter(s => s.score > 0)
|
|
193
|
+
.sort((a, b) => b.score - a.score)
|
|
194
|
+
.slice(0, maxResults)
|
|
195
|
+
.map(s => s.symbol);
|
|
196
|
+
// Make file paths relative
|
|
197
|
+
for (const r of results) {
|
|
198
|
+
if (r.file.startsWith(cwd)) {
|
|
199
|
+
r.file = "." + r.file.slice(cwd.length);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Estimate token savings
|
|
203
|
+
const rawGrep = await exec(`grep -rn '${queryWords[0] ?? query}' . --include='*.ts' --include='*.tsx' 2>/dev/null | head -100`, cwd);
|
|
204
|
+
const rawTokens = Math.ceil(rawGrep.length / 4);
|
|
205
|
+
const resultTokens = Math.ceil(JSON.stringify(results).length / 4);
|
|
206
|
+
return {
|
|
207
|
+
query,
|
|
208
|
+
symbols: results,
|
|
209
|
+
totalFiles: files.length,
|
|
210
|
+
tokensSaved: Math.max(0, rawTokens - resultTokens),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/** Quick helper: find all exported functions */
|
|
214
|
+
export async function findExports(cwd) {
|
|
215
|
+
const result = await semanticSearch("export", cwd, { exportedOnly: true, maxResults: 100 });
|
|
216
|
+
return result.symbols;
|
|
217
|
+
}
|
|
218
|
+
/** Quick helper: find all React components */
|
|
219
|
+
export async function findComponents(cwd) {
|
|
220
|
+
const result = await semanticSearch("component", cwd, { kinds: ["component"], maxResults: 50 });
|
|
221
|
+
return result.symbols;
|
|
222
|
+
}
|
|
223
|
+
/** Quick helper: find all hooks */
|
|
224
|
+
export async function findHooks(cwd) {
|
|
225
|
+
const result = await semanticSearch("hook", cwd, { kinds: ["hook"], maxResults: 50 });
|
|
226
|
+
return result.symbols;
|
|
227
|
+
}
|