@hasna/terminal 0.3.1 → 0.4.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.
@@ -6,7 +6,7 @@ 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";
@@ -185,6 +185,21 @@ export function createServer() {
185
185
  const result = await searchContent(pattern, path ?? process.cwd(), { fileType, maxResults, contextLines });
186
186
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
187
187
  });
188
+ // ── search_semantic: AST-powered code search ───────────────────────────────
189
+ 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.", {
190
+ query: z.string().describe("What to search for (e.g., 'auth functions', 'React components', 'database hooks')"),
191
+ path: z.string().optional().describe("Search root (default: cwd)"),
192
+ kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
193
+ exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
194
+ maxResults: z.number().optional().describe("Max results (default: 30)"),
195
+ }, async ({ query, path, kinds, exportedOnly, maxResults }) => {
196
+ const result = await semanticSearch(query, path ?? process.cwd(), {
197
+ kinds: kinds,
198
+ exportedOnly,
199
+ maxResults,
200
+ });
201
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
202
+ });
188
203
  // ── list_recipes: list saved command recipes ──────────────────────────────
189
204
  server.tool("list_recipes", "List saved command recipes. Optionally filter by collection or project.", {
190
205
  collection: z.string().optional().describe("Filter by collection name"),
@@ -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,224 @@
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
+ function extractSymbols(filePath) {
17
+ if (!existsSync(filePath))
18
+ return [];
19
+ const content = readFileSync(filePath, "utf8");
20
+ const lines = content.split("\n");
21
+ const symbols = [];
22
+ const file = filePath;
23
+ for (let i = 0; i < lines.length; i++) {
24
+ const line = lines[i];
25
+ const lineNum = i + 1;
26
+ const isExported = line.trimStart().startsWith("export");
27
+ // Functions: export function X(...) or export const X = (...) =>
28
+ const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
29
+ if (funcMatch) {
30
+ const prevLine = i > 0 ? lines[i - 1] : "";
31
+ const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
32
+ ? prevLine.trim().replace(/^\/\*\*\s*|\s*\*\/$/g, "").replace(/^\/\/\s*/, "")
33
+ : undefined;
34
+ symbols.push({
35
+ name: funcMatch[1], kind: "function", file, line: lineNum,
36
+ signature: line.trim().replace(/\{.*$/, "").trim(),
37
+ exported: isExported, doc,
38
+ });
39
+ continue;
40
+ }
41
+ // Arrow functions: export const X = (...) =>
42
+ const arrowMatch = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*\w[^=]*)?\s*=>/);
43
+ if (arrowMatch) {
44
+ // Detect React hooks
45
+ const isHook = arrowMatch[1].startsWith("use");
46
+ const isComponent = /^[A-Z]/.test(arrowMatch[1]);
47
+ symbols.push({
48
+ name: arrowMatch[1],
49
+ kind: isHook ? "hook" : isComponent ? "component" : "function",
50
+ file, line: lineNum,
51
+ signature: line.trim().replace(/\{.*$/, "").replace(/=>.*$/, "=>").trim(),
52
+ exported: isExported,
53
+ });
54
+ continue;
55
+ }
56
+ // Classes
57
+ const classMatch = line.match(/(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
58
+ if (classMatch) {
59
+ symbols.push({
60
+ name: classMatch[1], kind: "class", file, line: lineNum,
61
+ signature: line.trim().replace(/\{.*$/, "").trim(),
62
+ exported: isExported,
63
+ });
64
+ continue;
65
+ }
66
+ // Interfaces
67
+ const ifaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
68
+ if (ifaceMatch) {
69
+ symbols.push({
70
+ name: ifaceMatch[1], kind: "interface", file, line: lineNum,
71
+ signature: line.trim().replace(/\{.*$/, "").trim(),
72
+ exported: isExported,
73
+ });
74
+ continue;
75
+ }
76
+ // Type aliases
77
+ const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
78
+ if (typeMatch) {
79
+ symbols.push({
80
+ name: typeMatch[1], kind: "type", file, line: lineNum,
81
+ signature: line.trim(),
82
+ exported: isExported,
83
+ });
84
+ continue;
85
+ }
86
+ // Imports (for dependency tracking)
87
+ const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/);
88
+ if (importMatch) {
89
+ const names = importMatch[1]
90
+ ? importMatch[1].split(",").map(s => s.trim().split(" as ")[0].trim())
91
+ : [importMatch[2]];
92
+ for (const name of names) {
93
+ if (name) {
94
+ symbols.push({
95
+ name, kind: "import", file, line: lineNum,
96
+ signature: `from '${importMatch[3]}'`,
97
+ exported: false,
98
+ });
99
+ }
100
+ }
101
+ continue;
102
+ }
103
+ // Exported constants/variables
104
+ const constMatch = line.match(/export\s+const\s+(\w+)\s*[=:]/);
105
+ if (constMatch && !arrowMatch) {
106
+ symbols.push({
107
+ name: constMatch[1], kind: "variable", file, line: lineNum,
108
+ signature: line.trim().slice(0, 80),
109
+ exported: true,
110
+ });
111
+ }
112
+ }
113
+ return symbols;
114
+ }
115
+ /** Find all source files in a directory */
116
+ async function findSourceFiles(cwd, maxFiles = 200) {
117
+ const excludes = ["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"];
118
+ const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
119
+ const extensions = "\\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \\)";
120
+ const cmd = `find . ${extensions} ${excludeArgs} -type f 2>/dev/null | head -${maxFiles}`;
121
+ const output = await exec(cmd, cwd);
122
+ return output.split("\n").filter(l => l.trim()).map(l => join(cwd, l.trim()));
123
+ }
124
+ /** Semantic search: find symbols matching a natural language query */
125
+ export async function semanticSearch(query, cwd, options = {}) {
126
+ const { kinds, exportedOnly = false, maxResults = 30 } = options;
127
+ // Find all source files
128
+ const files = await findSourceFiles(cwd);
129
+ // Extract symbols from all files
130
+ let allSymbols = [];
131
+ for (const file of files) {
132
+ try {
133
+ allSymbols.push(...extractSymbols(file));
134
+ }
135
+ catch { /* skip unreadable files */ }
136
+ }
137
+ // Filter by kind
138
+ if (kinds) {
139
+ allSymbols = allSymbols.filter(s => kinds.includes(s.kind));
140
+ }
141
+ // Filter by exported
142
+ if (exportedOnly) {
143
+ allSymbols = allSymbols.filter(s => s.exported);
144
+ }
145
+ // Score each symbol against the query
146
+ const queryLower = query.toLowerCase();
147
+ const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
148
+ const scored = allSymbols.map(symbol => {
149
+ let score = 0;
150
+ const nameLower = symbol.name.toLowerCase();
151
+ const sigLower = (symbol.signature ?? "").toLowerCase();
152
+ const fileLower = symbol.file.toLowerCase();
153
+ // Exact name match
154
+ if (queryWords.some(w => nameLower === w))
155
+ score += 10;
156
+ // Name contains query word
157
+ if (queryWords.some(w => nameLower.includes(w)))
158
+ score += 5;
159
+ // Signature contains query word
160
+ if (queryWords.some(w => sigLower.includes(w)))
161
+ score += 3;
162
+ // File path contains query word
163
+ if (queryWords.some(w => fileLower.includes(w)))
164
+ score += 2;
165
+ // Doc contains query word
166
+ if (symbol.doc && queryWords.some(w => symbol.doc.toLowerCase().includes(w)))
167
+ score += 4;
168
+ // Boost exported symbols
169
+ if (symbol.exported)
170
+ score += 1;
171
+ // Boost functions/classes over imports
172
+ if (symbol.kind === "function" || symbol.kind === "class")
173
+ score += 1;
174
+ // Semantic matching for common patterns
175
+ if (queryLower.includes("component") && symbol.kind === "component")
176
+ score += 5;
177
+ if (queryLower.includes("hook") && symbol.kind === "hook")
178
+ score += 5;
179
+ if (queryLower.includes("type") && (symbol.kind === "type" || symbol.kind === "interface"))
180
+ score += 5;
181
+ if (queryLower.includes("import") && symbol.kind === "import")
182
+ score += 5;
183
+ if (queryLower.includes("class") && symbol.kind === "class")
184
+ score += 5;
185
+ return { symbol, score };
186
+ });
187
+ // Sort by score, filter zero scores
188
+ const results = scored
189
+ .filter(s => s.score > 0)
190
+ .sort((a, b) => b.score - a.score)
191
+ .slice(0, maxResults)
192
+ .map(s => s.symbol);
193
+ // Make file paths relative
194
+ for (const r of results) {
195
+ if (r.file.startsWith(cwd)) {
196
+ r.file = "." + r.file.slice(cwd.length);
197
+ }
198
+ }
199
+ // Estimate token savings
200
+ const rawGrep = await exec(`grep -rn '${queryWords[0] ?? query}' . --include='*.ts' --include='*.tsx' 2>/dev/null | head -100`, cwd);
201
+ const rawTokens = Math.ceil(rawGrep.length / 4);
202
+ const resultTokens = Math.ceil(JSON.stringify(results).length / 4);
203
+ return {
204
+ query,
205
+ symbols: results,
206
+ totalFiles: files.length,
207
+ tokensSaved: Math.max(0, rawTokens - resultTokens),
208
+ };
209
+ }
210
+ /** Quick helper: find all exported functions */
211
+ export async function findExports(cwd) {
212
+ const result = await semanticSearch("export", cwd, { exportedOnly: true, maxResults: 100 });
213
+ return result.symbols;
214
+ }
215
+ /** Quick helper: find all React components */
216
+ export async function findComponents(cwd) {
217
+ const result = await semanticSearch("component", cwd, { kinds: ["component"], maxResults: 50 });
218
+ return result.symbols;
219
+ }
220
+ /** Quick helper: find all hooks */
221
+ export async function findHooks(cwd) {
222
+ const result = await semanticSearch("hook", cwd, { kinds: ["hook"], maxResults: 50 });
223
+ return result.symbols;
224
+ }
@@ -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
- result.push(` ${dir}/${pattern} ×${files.length}`);
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.1",
3
+ "version": "0.4.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.0.0"
38
+ "typescript": "^5.9.3"
38
39
  }
39
40
  }
package/src/mcp/server.ts CHANGED
@@ -7,7 +7,7 @@ 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";
@@ -238,6 +238,28 @@ export function createServer(): McpServer {
238
238
  }
239
239
  );
240
240
 
241
+ // ── search_semantic: AST-powered code search ───────────────────────────────
242
+
243
+ server.tool(
244
+ "search_semantic",
245
+ "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.",
246
+ {
247
+ query: z.string().describe("What to search for (e.g., 'auth functions', 'React components', 'database hooks')"),
248
+ path: z.string().optional().describe("Search root (default: cwd)"),
249
+ kinds: z.array(z.enum(["function", "class", "interface", "type", "variable", "export", "import", "component", "hook"])).optional().describe("Filter by symbol kind"),
250
+ exportedOnly: z.boolean().optional().describe("Only show exported symbols (default: false)"),
251
+ maxResults: z.number().optional().describe("Max results (default: 30)"),
252
+ },
253
+ async ({ query, path, kinds, exportedOnly, maxResults }) => {
254
+ const result = await semanticSearch(query, path ?? process.cwd(), {
255
+ kinds: kinds as any,
256
+ exportedOnly,
257
+ maxResults,
258
+ });
259
+ return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
260
+ }
261
+ );
262
+
241
263
  // ── list_recipes: list saved command recipes ──────────────────────────────
242
264
 
243
265
  server.tool(
@@ -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";
@@ -0,0 +1,267 @@
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
+
4
+ import { spawn } from "child_process";
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { join, extname } from "path";
7
+
8
+ export interface CodeSymbol {
9
+ name: string;
10
+ kind: "function" | "class" | "interface" | "type" | "variable" | "export" | "import" | "component" | "hook";
11
+ file: string;
12
+ line: number;
13
+ signature?: string; // e.g., "function login(email: string, password: string): Promise<User>"
14
+ exported: boolean;
15
+ doc?: string; // JSDoc comment if present
16
+ }
17
+
18
+ export interface SemanticSearchResult {
19
+ query: string;
20
+ symbols: CodeSymbol[];
21
+ totalFiles: number;
22
+ tokensSaved?: number;
23
+ }
24
+
25
+ function exec(command: string, cwd: string): Promise<string> {
26
+ return new Promise((resolve) => {
27
+ const proc = spawn("/bin/zsh", ["-c", command], { cwd, stdio: ["ignore", "pipe", "pipe"] });
28
+ let out = "";
29
+ proc.stdout?.on("data", (d: Buffer) => { out += d.toString(); });
30
+ proc.stderr?.on("data", (d: Buffer) => { /* ignore */ });
31
+ proc.on("close", () => resolve(out));
32
+ });
33
+ }
34
+
35
+ /** Extract code symbols from a TypeScript/JavaScript file using regex-based parsing */
36
+ function extractSymbols(filePath: string): CodeSymbol[] {
37
+ if (!existsSync(filePath)) return [];
38
+ const content = readFileSync(filePath, "utf8");
39
+ const lines = content.split("\n");
40
+ const symbols: CodeSymbol[] = [];
41
+ const file = filePath;
42
+
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = lines[i];
45
+ const lineNum = i + 1;
46
+ const isExported = line.trimStart().startsWith("export");
47
+
48
+ // Functions: export function X(...) or export const X = (...) =>
49
+ const funcMatch = line.match(/(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/);
50
+ if (funcMatch) {
51
+ const prevLine = i > 0 ? lines[i - 1] : "";
52
+ const doc = prevLine.trim().startsWith("/**") || prevLine.trim().startsWith("//")
53
+ ? prevLine.trim().replace(/^\/\*\*\s*|\s*\*\/$/g, "").replace(/^\/\/\s*/, "")
54
+ : undefined;
55
+ symbols.push({
56
+ name: funcMatch[1], kind: "function", file, line: lineNum,
57
+ signature: line.trim().replace(/\{.*$/, "").trim(),
58
+ exported: isExported, doc,
59
+ });
60
+ continue;
61
+ }
62
+
63
+ // Arrow functions: export const X = (...) =>
64
+ const arrowMatch = line.match(/(?:export\s+)?(?:const|let)\s+(\w+)\s*=\s*(?:async\s+)?\(([^)]*)\)\s*(?::\s*\w[^=]*)?\s*=>/);
65
+ if (arrowMatch) {
66
+ // Detect React hooks
67
+ const isHook = arrowMatch[1].startsWith("use");
68
+ const isComponent = /^[A-Z]/.test(arrowMatch[1]);
69
+ symbols.push({
70
+ name: arrowMatch[1],
71
+ kind: isHook ? "hook" : isComponent ? "component" : "function",
72
+ file, line: lineNum,
73
+ signature: line.trim().replace(/\{.*$/, "").replace(/=>.*$/, "=>").trim(),
74
+ exported: isExported,
75
+ });
76
+ continue;
77
+ }
78
+
79
+ // Classes
80
+ const classMatch = line.match(/(?:export\s+)?class\s+(\w+)(?:\s+extends\s+(\w+))?/);
81
+ if (classMatch) {
82
+ symbols.push({
83
+ name: classMatch[1], kind: "class", file, line: lineNum,
84
+ signature: line.trim().replace(/\{.*$/, "").trim(),
85
+ exported: isExported,
86
+ });
87
+ continue;
88
+ }
89
+
90
+ // Interfaces
91
+ const ifaceMatch = line.match(/(?:export\s+)?interface\s+(\w+)/);
92
+ if (ifaceMatch) {
93
+ symbols.push({
94
+ name: ifaceMatch[1], kind: "interface", file, line: lineNum,
95
+ signature: line.trim().replace(/\{.*$/, "").trim(),
96
+ exported: isExported,
97
+ });
98
+ continue;
99
+ }
100
+
101
+ // Type aliases
102
+ const typeMatch = line.match(/(?:export\s+)?type\s+(\w+)\s*=/);
103
+ if (typeMatch) {
104
+ symbols.push({
105
+ name: typeMatch[1], kind: "type", file, line: lineNum,
106
+ signature: line.trim(),
107
+ exported: isExported,
108
+ });
109
+ continue;
110
+ }
111
+
112
+ // Imports (for dependency tracking)
113
+ const importMatch = line.match(/import\s+(?:\{([^}]+)\}|(\w+))\s+from\s+['"]([^'"]+)['"]/);
114
+ if (importMatch) {
115
+ const names = importMatch[1]
116
+ ? importMatch[1].split(",").map(s => s.trim().split(" as ")[0].trim())
117
+ : [importMatch[2]];
118
+ for (const name of names) {
119
+ if (name) {
120
+ symbols.push({
121
+ name, kind: "import", file, line: lineNum,
122
+ signature: `from '${importMatch[3]}'`,
123
+ exported: false,
124
+ });
125
+ }
126
+ }
127
+ continue;
128
+ }
129
+
130
+ // Exported constants/variables
131
+ const constMatch = line.match(/export\s+const\s+(\w+)\s*[=:]/);
132
+ if (constMatch && !arrowMatch) {
133
+ symbols.push({
134
+ name: constMatch[1], kind: "variable", file, line: lineNum,
135
+ signature: line.trim().slice(0, 80),
136
+ exported: true,
137
+ });
138
+ }
139
+ }
140
+
141
+ return symbols;
142
+ }
143
+
144
+ /** Find all source files in a directory */
145
+ async function findSourceFiles(cwd: string, maxFiles: number = 200): Promise<string[]> {
146
+ const excludes = ["node_modules", ".git", "dist", "build", ".next", "coverage", "__pycache__"];
147
+ const excludeArgs = excludes.map(d => `-not -path '*/${d}/*'`).join(" ");
148
+ const extensions = "\\( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \\)";
149
+ const cmd = `find . ${extensions} ${excludeArgs} -type f 2>/dev/null | head -${maxFiles}`;
150
+ const output = await exec(cmd, cwd);
151
+ return output.split("\n").filter(l => l.trim()).map(l => join(cwd, l.trim()));
152
+ }
153
+
154
+ /** Semantic search: find symbols matching a natural language query */
155
+ export async function semanticSearch(
156
+ query: string,
157
+ cwd: string,
158
+ options: { kinds?: CodeSymbol["kind"][]; exportedOnly?: boolean; maxResults?: number } = {}
159
+ ): Promise<SemanticSearchResult> {
160
+ const { kinds, exportedOnly = false, maxResults = 30 } = options;
161
+
162
+ // Find all source files
163
+ const files = await findSourceFiles(cwd);
164
+
165
+ // Extract symbols from all files
166
+ let allSymbols: CodeSymbol[] = [];
167
+ for (const file of files) {
168
+ try {
169
+ allSymbols.push(...extractSymbols(file));
170
+ } catch { /* skip unreadable files */ }
171
+ }
172
+
173
+ // Filter by kind
174
+ if (kinds) {
175
+ allSymbols = allSymbols.filter(s => kinds.includes(s.kind));
176
+ }
177
+
178
+ // Filter by exported
179
+ if (exportedOnly) {
180
+ allSymbols = allSymbols.filter(s => s.exported);
181
+ }
182
+
183
+ // Score each symbol against the query
184
+ const queryLower = query.toLowerCase();
185
+ const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
186
+
187
+ const scored = allSymbols.map(symbol => {
188
+ let score = 0;
189
+ const nameLower = symbol.name.toLowerCase();
190
+ const sigLower = (symbol.signature ?? "").toLowerCase();
191
+ const fileLower = symbol.file.toLowerCase();
192
+
193
+ // Exact name match
194
+ if (queryWords.some(w => nameLower === w)) score += 10;
195
+
196
+ // Name contains query word
197
+ if (queryWords.some(w => nameLower.includes(w))) score += 5;
198
+
199
+ // Signature contains query word
200
+ if (queryWords.some(w => sigLower.includes(w))) score += 3;
201
+
202
+ // File path contains query word
203
+ if (queryWords.some(w => fileLower.includes(w))) score += 2;
204
+
205
+ // Doc contains query word
206
+ if (symbol.doc && queryWords.some(w => symbol.doc!.toLowerCase().includes(w))) score += 4;
207
+
208
+ // Boost exported symbols
209
+ if (symbol.exported) score += 1;
210
+
211
+ // Boost functions/classes over imports
212
+ if (symbol.kind === "function" || symbol.kind === "class") score += 1;
213
+
214
+ // Semantic matching for common patterns
215
+ if (queryLower.includes("component") && symbol.kind === "component") score += 5;
216
+ if (queryLower.includes("hook") && symbol.kind === "hook") score += 5;
217
+ if (queryLower.includes("type") && (symbol.kind === "type" || symbol.kind === "interface")) score += 5;
218
+ if (queryLower.includes("import") && symbol.kind === "import") score += 5;
219
+ if (queryLower.includes("class") && symbol.kind === "class") score += 5;
220
+
221
+ return { symbol, score };
222
+ });
223
+
224
+ // Sort by score, filter zero scores
225
+ const results = scored
226
+ .filter(s => s.score > 0)
227
+ .sort((a, b) => b.score - a.score)
228
+ .slice(0, maxResults)
229
+ .map(s => s.symbol);
230
+
231
+ // Make file paths relative
232
+ for (const r of results) {
233
+ if (r.file.startsWith(cwd)) {
234
+ r.file = "." + r.file.slice(cwd.length);
235
+ }
236
+ }
237
+
238
+ // Estimate token savings
239
+ const rawGrep = await exec(`grep -rn '${queryWords[0] ?? query}' . --include='*.ts' --include='*.tsx' 2>/dev/null | head -100`, cwd);
240
+ const rawTokens = Math.ceil(rawGrep.length / 4);
241
+ const resultTokens = Math.ceil(JSON.stringify(results).length / 4);
242
+
243
+ return {
244
+ query,
245
+ symbols: results,
246
+ totalFiles: files.length,
247
+ tokensSaved: Math.max(0, rawTokens - resultTokens),
248
+ };
249
+ }
250
+
251
+ /** Quick helper: find all exported functions */
252
+ export async function findExports(cwd: string): Promise<CodeSymbol[]> {
253
+ const result = await semanticSearch("export", cwd, { exportedOnly: true, maxResults: 100 });
254
+ return result.symbols;
255
+ }
256
+
257
+ /** Quick helper: find all React components */
258
+ export async function findComponents(cwd: string): Promise<CodeSymbol[]> {
259
+ const result = await semanticSearch("component", cwd, { kinds: ["component"], maxResults: 50 });
260
+ return result.symbols;
261
+ }
262
+
263
+ /** Quick helper: find all hooks */
264
+ export async function findHooks(cwd: string): Promise<CodeSymbol[]> {
265
+ const result = await semanticSearch("hook", cwd, { kinds: ["hook"], maxResults: 50 });
266
+ return result.symbols;
267
+ }
@@ -89,6 +89,11 @@ function collapseNodeModules(paths: string[]): { nodeModulesPaths: string[]; oth
89
89
  /** Smart display: compress file path output into grouped patterns */
90
90
  export function smartDisplay(lines: string[]): string[] {
91
91
  if (lines.length <= 5) return lines;
92
+
93
+ // Try ls -la table compression first
94
+ const lsCompressed = compressLsTable(lines);
95
+ if (lsCompressed) return lsCompressed;
96
+
92
97
  if (!looksLikePaths(lines)) return compressGeneric(lines);
93
98
 
94
99
  const paths = lines.map(l => l.trim()).filter(l => l);
@@ -127,7 +132,9 @@ export function smartDisplay(lines: string[]): string[] {
127
132
  const sorted = files.sort();
128
133
  const pattern = findPattern(sorted);
129
134
  if (pattern) {
130
- result.push(` ${dir}/${pattern} ×${files.length}`);
135
+ const dateRange = collapseDateRange(sorted);
136
+ const rangeStr = dateRange ? ` (${dateRange})` : "";
137
+ result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
131
138
  } else {
132
139
  result.push(` ${dir}/ (${files.length} files)`);
133
140
  // Show first 2 + count
@@ -163,6 +170,95 @@ export function smartDisplay(lines: string[]): string[] {
163
170
  return result;
164
171
  }
165
172
 
173
+ /** Detect date range in timestamps and collapse */
174
+ function collapseDateRange(files: string[]): string | null {
175
+ const timestamps: Date[] = [];
176
+ for (const f of files) {
177
+ const match = f.match(/(\d{4})-(\d{2})-(\d{2})T?(\d{2})?/);
178
+ if (match) {
179
+ const [, y, m, d, h] = match;
180
+ timestamps.push(new Date(`${y}-${m}-${d}T${h ?? "00"}:00:00`));
181
+ }
182
+ }
183
+ if (timestamps.length < 2) return null;
184
+ timestamps.sort((a, b) => a.getTime() - b.getTime());
185
+ const first = timestamps[0];
186
+ const last = timestamps[timestamps.length - 1];
187
+ const fmt = (d: Date) => `${d.getMonth() + 1}/${d.getDate()}`;
188
+ if (first.toDateString() === last.toDateString()) {
189
+ return `${fmt(first)}`;
190
+ }
191
+ return `${fmt(first)}–${fmt(last)}`;
192
+ }
193
+
194
+ /** Detect and compress ls -la style table output */
195
+ function compressLsTable(lines: string[]): string[] | null {
196
+ // Detect ls -la format: permissions size date name
197
+ const lsPattern = /^[dlcbps-][rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+\S+\s+\w+\s+\d+\s+[\d:]+\s+.+$/;
198
+ const isLsOutput = lines.filter(l => lsPattern.test(l.trim())).length > lines.length * 0.5;
199
+ if (!isLsOutput) return null;
200
+
201
+ const result: string[] = [];
202
+ const dirs: string[] = [];
203
+ const files: { name: string; size: string }[] = [];
204
+ let totalSize = 0;
205
+
206
+ for (const line of lines) {
207
+ const match = line.trim().match(/^([dlcbps-])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\S+)\s+\w+\s+\d+\s+[\d:]+\s+(.+)$/);
208
+ if (!match) {
209
+ if (line.trim().startsWith("total ")) continue;
210
+ result.push(line);
211
+ continue;
212
+ }
213
+
214
+ const [, type, sizeStr, name] = match;
215
+ const size = parseInt(sizeStr) || 0;
216
+ totalSize += size;
217
+
218
+ if (type === "d") {
219
+ dirs.push(name);
220
+ } else {
221
+ files.push({ name, size: formatSize(size) });
222
+ }
223
+ }
224
+
225
+ // Compact display
226
+ if (dirs.length > 0) {
227
+ result.push(` 📁 ${dirs.join(" ")}${dirs.length > 5 ? ` (+${dirs.length - 5} more)` : ""}`);
228
+ }
229
+ if (files.length <= 8) {
230
+ for (const f of files) {
231
+ result.push(` ${f.size.padStart(6)} ${f.name}`);
232
+ }
233
+ } else {
234
+ // Show top 5 by size + count
235
+ const sorted = files.sort((a, b) => parseSize(b.size) - parseSize(a.size));
236
+ for (const f of sorted.slice(0, 5)) {
237
+ result.push(` ${f.size.padStart(6)} ${f.name}`);
238
+ }
239
+ result.push(` ... +${files.length - 5} more files (${formatSize(totalSize)} total)`);
240
+ }
241
+
242
+ return result;
243
+ }
244
+
245
+ function formatSize(bytes: number): string {
246
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}M`;
247
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(1)}K`;
248
+ return `${bytes}B`;
249
+ }
250
+
251
+ function parseSize(s: string): number {
252
+ const match = s.match(/([\d.]+)([BKMG])?/);
253
+ if (!match) return 0;
254
+ const n = parseFloat(match[1]);
255
+ const unit = match[2];
256
+ if (unit === "K") return n * 1000;
257
+ if (unit === "M") return n * 1000000;
258
+ if (unit === "G") return n * 1000000000;
259
+ return n;
260
+ }
261
+
166
262
  /** Compress non-path generic output by deduplicating similar lines */
167
263
  function compressGeneric(lines: string[]): string[] {
168
264
  if (lines.length <= 10) return lines;