@hasna/terminal 1.6.7 → 2.0.1

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/ai.js CHANGED
@@ -45,6 +45,8 @@ const IRREVERSIBLE_PATTERNS = [
45
45
  /\bcodemod\b/, /\bsed\s+-i\b/, /\bawk\s.*>\s*\S+\.\w+/, /\bperl\s+-[pi]\b/,
46
46
  // File creation/modification (READ-ONLY terminal)
47
47
  /\btouch\b/, /\bmkdir\b/, /\becho\s.*>/, /\btee\b/, /\bcp\b/, /\bmv\b/,
48
+ // Starting servers/processes (dangerous from NL)
49
+ /\b(bun|npm|pnpm|yarn)\s+run\s+dev\b/, /\b(bun|npm)\s+start\b/,
48
50
  ];
49
51
  // Commands that are ALWAYS safe (read-only git, etc.)
50
52
  const SAFE_OVERRIDES = [
@@ -220,6 +222,8 @@ SEMANTIC MAPPING: When the user references a concept, search the file tree for R
220
222
 
221
223
  ACTION vs CONCEPTUAL: If the prompt starts with "run", "execute", "check", "test", "build", "show output of" — ALWAYS generate an executable command. NEVER read README for action requests. Only read docs for "explain why", "what does X mean", "how was X designed".
222
224
 
225
+ EXISTENCE CHECKS: If the prompt starts with "is there", "does this have", "do we have", "does X exist" — NEVER run/start/launch anything. Use ls, find, or test -d to CHECK existence. These are READ-ONLY questions.
226
+
223
227
  MONOREPO: If the project context says "MONOREPO", search packages/ or apps/ NOT src/. Use: grep -rn "pattern" packages/ --include="*.ts". For specific packages, use packages/PKGNAME/src/.
224
228
  cwd: ${process.cwd()}
225
229
  shell: zsh / macOS${projectContext}${restrictionBlock}${contextBlock}`;
package/dist/cli.js CHANGED
@@ -379,6 +379,32 @@ else if (args[0] === "symbols" && args[1]) {
379
379
  }
380
380
  }
381
381
  }
382
+ // ── History command ──────────────────────────────────────────────────────────
383
+ else if (args[0] === "history") {
384
+ const { loadContext } = await import("./session-context.js");
385
+ const entries = loadContext();
386
+ if (entries.length === 0) {
387
+ console.log("No recent history.");
388
+ }
389
+ else {
390
+ for (const e of entries) {
391
+ const time = new Date(e.timestamp).toLocaleTimeString();
392
+ console.log(` ${time} ${e.prompt}`);
393
+ console.log(` $ ${e.command}`);
394
+ }
395
+ }
396
+ }
397
+ // ── Explain command ─────────────────────────────────────────────────────────
398
+ else if (args[0] === "explain" && args[1]) {
399
+ const command = args.slice(1).join(" ");
400
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
401
+ console.error("explain requires an API key");
402
+ process.exit(1);
403
+ }
404
+ const { explainCommand } = await import("./ai.js");
405
+ const explanation = await explainCommand(command);
406
+ console.log(explanation);
407
+ }
382
408
  // ── Snapshot command ─────────────────────────────────────────────────────────
383
409
  else if (args[0] === "snapshot") {
384
410
  const { captureSnapshot } = await import("./snapshots.js");
@@ -394,13 +420,7 @@ else if (args[0] === "project" && args[1] === "init") {
394
420
  else if (args.length > 0) {
395
421
  // Everything that doesn't match a subcommand is treated as natural language
396
422
  const prompt = args.join(" ");
397
- if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
398
- console.error("terminal: No API key found.");
399
- console.error("Set one of:");
400
- console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
401
- console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
402
- process.exit(1);
403
- }
423
+ const offlineMode = !process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY;
404
424
  const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
405
425
  const { execSync } = await import("child_process");
406
426
  const { compress, stripAnsi } = await import("./compression.js");
@@ -413,36 +433,63 @@ else if (args.length > 0) {
413
433
  const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
414
434
  const { detectLoop } = await import("./loop-detector.js");
415
435
  const { loadConfig } = await import("./history.js");
436
+ const { loadContext, saveContext, formatContext } = await import("./session-context.js");
437
+ const { getLearned, recordMapping } = await import("./usage-cache.js");
416
438
  const config = loadConfig();
417
439
  const perms = config.permissions;
418
- // Step 1: AI translates NL → shell command
440
+ const sessionCtx = formatContext();
441
+ // Check usage learning cache first (zero AI cost for repeated queries)
442
+ const learned = getLearned(prompt);
443
+ if (learned && !offlineMode) {
444
+ console.error(`[open-terminal] cached: $ ${learned}`);
445
+ }
446
+ // Step 1: AI translates NL → shell command (with session context for follow-ups)
419
447
  let command;
420
- try {
421
- command = await translateToCommand(prompt, perms, []);
448
+ if (offlineMode) {
449
+ // Offline: treat prompt as literal command, apply noise filter only
450
+ console.error("[open-terminal] offline mode (no API key) — running as literal command");
451
+ command = prompt;
422
452
  }
423
- catch (e) {
424
- // If BLOCKED, try README fallback ONLY for conceptual questions (not file access)
425
- if (e.message?.startsWith("BLOCKED:")) {
426
- const isConceptual = /\b(explain|why|what does|how does|describe|architecture|overview|summary)\b/i.test(prompt);
427
- const isFileAccess = /\b(cat|show|read|find|ls|list)\b.*\b(\.\w+\/|src\/|packages\/)/i.test(prompt);
428
- if (isConceptual && !isFileAccess) {
429
- try {
430
- const { existsSync, readFileSync } = await import("fs");
431
- if (existsSync("README.md")) {
432
- const readme = readFileSync("README.md", "utf8").slice(0, 3000);
433
- const processed = await processOutput("cat README.md", readme, prompt);
434
- if (processed.aiProcessed) {
435
- console.log(processed.summary);
436
- process.exit(0);
453
+ else if (learned) {
454
+ command = learned;
455
+ }
456
+ else {
457
+ try {
458
+ command = await translateToCommand(sessionCtx ? `${prompt}\n${sessionCtx}` : prompt, perms, []);
459
+ }
460
+ catch (e) {
461
+ // If BLOCKED, try README fallback ONLY for conceptual questions (not file access)
462
+ if (e.message?.startsWith("BLOCKED:")) {
463
+ const isConceptual = /\b(explain|why|what does|how does|describe|architecture|overview|summary)\b/i.test(prompt);
464
+ const isFileAccess = /\b(cat|show|read|find|ls|list)\b.*\b(\.\w+\/|src\/|packages\/)/i.test(prompt);
465
+ if (isConceptual && !isFileAccess) {
466
+ try {
467
+ const { existsSync, readFileSync } = await import("fs");
468
+ if (existsSync("README.md")) {
469
+ const readme = readFileSync("README.md", "utf8").slice(0, 3000);
470
+ const processed = await processOutput("cat README.md", readme, prompt);
471
+ if (processed.aiProcessed) {
472
+ console.log(processed.summary);
473
+ process.exit(0);
474
+ }
437
475
  }
438
476
  }
477
+ catch { }
439
478
  }
440
- catch { }
441
479
  }
480
+ // "I don't know" honesty — better than wrong answer
481
+ if (e.message?.startsWith("BLOCKED:")) {
482
+ console.log(`I don't know how to do this with shell commands. Try running it directly.`);
483
+ }
484
+ else {
485
+ console.error(e.message);
486
+ }
487
+ process.exit(1);
442
488
  }
443
- console.error(e.message);
444
- process.exit(1);
445
- }
489
+ } // close the else (learned/offline) block
490
+ // Record the mapping for usage learning
491
+ if (!offlineMode && !learned)
492
+ recordMapping(prompt, command);
446
493
  // Check permissions
447
494
  const blocked = checkPermissions(command, perms);
448
495
  if (blocked) {
@@ -508,6 +555,7 @@ else if (args.length > 0) {
508
555
  const clean = stripNoise(stripAnsi(raw)).cleaned;
509
556
  const rawTokens = estimateTokens(raw);
510
557
  recordUsage(rawTokens);
558
+ saveContext(prompt, actualCmd, clean.slice(0, 500));
511
559
  // Test output detection
512
560
  // Test output: skip watchlist, let AI framing handle it
513
561
  // The AI reads "42 pass, 0 fail" better than regex parsing bun's mixed output
@@ -548,6 +596,23 @@ else if (args.length > 0) {
548
596
  const errStdout = e.stdout?.toString() ?? "";
549
597
  const errStderr = e.stderr?.toString() ?? "";
550
598
  if (e.status === 1 && !errStdout.trim() && !errStderr.trim()) {
599
+ // Empty result — retry with broader scope before giving up
600
+ if (!actualCmd.includes("#(broadened)")) {
601
+ try {
602
+ const broaderCmd = await translateToCommand(`${prompt} (Previous command found NOTHING. Try searching a BROADER scope: use . or packages/ instead of src/. Use simpler grep pattern.)`, perms, []);
603
+ if (broaderCmd && !isIrreversible(broaderCmd) && !checkPermissions(broaderCmd, perms)) {
604
+ console.error(`[open-terminal] broadening search...`);
605
+ const broaderResult = execSync(broaderCmd + " #(broadened)", { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
606
+ const broaderClean = stripNoise(stripAnsi(broaderResult)).cleaned;
607
+ if (broaderClean.trim()) {
608
+ const processed = await processOutput(broaderCmd, broaderClean, prompt);
609
+ console.log(processed.aiProcessed ? processed.summary : broaderClean);
610
+ process.exit(0);
611
+ }
612
+ }
613
+ }
614
+ catch { /* broader also failed */ }
615
+ }
551
616
  console.log(`No results found for: ${prompt}`);
552
617
  process.exit(0);
553
618
  }
@@ -61,6 +61,61 @@ function extractSymbols(filePath) {
61
61
  const lines = content.split("\n");
62
62
  const symbols = [];
63
63
  const file = filePath;
64
+ const ext = filePath.match(/\.(\w+)$/)?.[1] ?? "";
65
+ // Python support
66
+ if (ext === "py") {
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const line = lines[i];
69
+ const defMatch = line.match(/^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/);
70
+ if (defMatch) {
71
+ symbols.push({ name: defMatch[2], kind: "function", file, line: i + 1, signature: line.trim(), exported: !defMatch[2].startsWith("_") });
72
+ }
73
+ const classMatch = line.match(/^class\s+(\w+)/);
74
+ if (classMatch) {
75
+ symbols.push({ name: classMatch[1], kind: "class", file, line: i + 1, signature: line.trim(), exported: true });
76
+ }
77
+ }
78
+ return symbols;
79
+ }
80
+ // Go support
81
+ if (ext === "go") {
82
+ for (let i = 0; i < lines.length; i++) {
83
+ const line = lines[i];
84
+ const funcMatch = line.match(/^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(/);
85
+ if (funcMatch) {
86
+ symbols.push({ name: funcMatch[1], kind: "function", file, line: i + 1, signature: line.trim(), exported: /^[A-Z]/.test(funcMatch[1]) });
87
+ }
88
+ const typeMatch = line.match(/^type\s+(\w+)\s+(struct|interface)/);
89
+ if (typeMatch) {
90
+ symbols.push({ name: typeMatch[1], kind: typeMatch[2] === "interface" ? "interface" : "class", file, line: i + 1, signature: line.trim(), exported: /^[A-Z]/.test(typeMatch[1]) });
91
+ }
92
+ }
93
+ return symbols;
94
+ }
95
+ // Rust support
96
+ if (ext === "rs") {
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const line = lines[i];
99
+ const fnMatch = line.match(/^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/);
100
+ if (fnMatch) {
101
+ symbols.push({ name: fnMatch[1], kind: "function", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
102
+ }
103
+ const structMatch = line.match(/^\s*(?:pub\s+)?struct\s+(\w+)/);
104
+ if (structMatch) {
105
+ symbols.push({ name: structMatch[1], kind: "class", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
106
+ }
107
+ const enumMatch = line.match(/^\s*(?:pub\s+)?enum\s+(\w+)/);
108
+ if (enumMatch) {
109
+ symbols.push({ name: enumMatch[1], kind: "type", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
110
+ }
111
+ const traitMatch = line.match(/^\s*(?:pub\s+)?trait\s+(\w+)/);
112
+ if (traitMatch) {
113
+ symbols.push({ name: traitMatch[1], kind: "interface", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
114
+ }
115
+ }
116
+ return symbols;
117
+ }
118
+ // TypeScript/JavaScript (default)
64
119
  for (let i = 0; i < lines.length; i++) {
65
120
  const line = lines[i];
66
121
  const lineNum = i + 1;
@@ -0,0 +1,55 @@
1
+ // Session context — stores last N command+output pairs for follow-up queries
2
+ // Enables: terminal "show auth code" → terminal "explain that function"
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ const DIR = join(homedir(), ".terminal");
7
+ const CTX_FILE = join(DIR, "session-context.json");
8
+ const MAX_ENTRIES = 5;
9
+ function ensureDir() {
10
+ if (!existsSync(DIR))
11
+ mkdirSync(DIR, { recursive: true });
12
+ }
13
+ /** Load session context */
14
+ export function loadContext() {
15
+ ensureDir();
16
+ if (!existsSync(CTX_FILE))
17
+ return [];
18
+ try {
19
+ const entries = JSON.parse(readFileSync(CTX_FILE, "utf8"));
20
+ // Only return entries from last 30 minutes (session freshness)
21
+ const cutoff = Date.now() - 30 * 60 * 1000;
22
+ return entries.filter(e => e.timestamp > cutoff).slice(-MAX_ENTRIES);
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ /** Save a command to session context */
29
+ export function saveContext(prompt, command, output) {
30
+ ensureDir();
31
+ const entries = loadContext();
32
+ entries.push({
33
+ prompt,
34
+ command,
35
+ output: output.slice(0, 500),
36
+ timestamp: Date.now(),
37
+ });
38
+ // Keep only last N
39
+ const trimmed = entries.slice(-MAX_ENTRIES);
40
+ writeFileSync(CTX_FILE, JSON.stringify(trimmed, null, 2));
41
+ }
42
+ /** Format context for AI prompt injection */
43
+ export function formatContext() {
44
+ const entries = loadContext();
45
+ if (entries.length === 0)
46
+ return "";
47
+ const lines = ["\nRECENT SESSION CONTEXT (for follow-up references like 'that', 'it', 'the same'):"];
48
+ for (const e of entries.slice(-3)) {
49
+ lines.push(`> ${e.prompt}`);
50
+ lines.push(`$ ${e.command}`);
51
+ if (e.output)
52
+ lines.push(e.output.slice(0, 200));
53
+ }
54
+ return lines.join("\n");
55
+ }
@@ -0,0 +1,65 @@
1
+ // Usage learning cache — zero-cost repeated queries
2
+ // After 3 identical prompt→command mappings, cache locally
3
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { join } from "path";
6
+ import { createHash } from "crypto";
7
+ const DIR = join(homedir(), ".terminal");
8
+ const CACHE_FILE = join(DIR, "learned.json");
9
+ function ensureDir() {
10
+ if (!existsSync(DIR))
11
+ mkdirSync(DIR, { recursive: true });
12
+ }
13
+ function hash(s) {
14
+ return createHash("md5").update(s).digest("hex").slice(0, 12);
15
+ }
16
+ function cacheKey(prompt) {
17
+ const projectHash = hash(process.cwd());
18
+ const promptHash = hash(prompt.toLowerCase().trim());
19
+ return `${projectHash}:${promptHash}`;
20
+ }
21
+ function loadCache() {
22
+ ensureDir();
23
+ if (!existsSync(CACHE_FILE))
24
+ return {};
25
+ try {
26
+ return JSON.parse(readFileSync(CACHE_FILE, "utf8"));
27
+ }
28
+ catch {
29
+ return {};
30
+ }
31
+ }
32
+ function saveCache(cache) {
33
+ ensureDir();
34
+ writeFileSync(CACHE_FILE, JSON.stringify(cache));
35
+ }
36
+ /** Check if we have a learned command for this prompt (3+ identical mappings) */
37
+ export function getLearned(prompt) {
38
+ const key = cacheKey(prompt);
39
+ const cache = loadCache();
40
+ const entry = cache[key];
41
+ if (entry && entry.count >= 3)
42
+ return entry.command;
43
+ return null;
44
+ }
45
+ /** Record a prompt→command mapping */
46
+ export function recordMapping(prompt, command) {
47
+ const key = cacheKey(prompt);
48
+ const cache = loadCache();
49
+ const existing = cache[key];
50
+ if (existing && existing.command === command) {
51
+ existing.count++;
52
+ existing.lastUsed = Date.now();
53
+ }
54
+ else {
55
+ cache[key] = { command, count: 1, lastUsed: Date.now() };
56
+ }
57
+ saveCache(cache);
58
+ }
59
+ /** Get cache stats */
60
+ export function learnedStats() {
61
+ const cache = loadCache();
62
+ const entries = Object.keys(cache).length;
63
+ const cached = Object.values(cache).filter(e => e.count >= 3).length;
64
+ return { entries, cached };
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "1.6.7",
3
+ "version": "2.0.1",
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": {
package/src/ai.ts CHANGED
@@ -53,6 +53,8 @@ const IRREVERSIBLE_PATTERNS = [
53
53
  /\bcodemod\b/, /\bsed\s+-i\b/, /\bawk\s.*>\s*\S+\.\w+/, /\bperl\s+-[pi]\b/,
54
54
  // File creation/modification (READ-ONLY terminal)
55
55
  /\btouch\b/, /\bmkdir\b/, /\becho\s.*>/, /\btee\b/, /\bcp\b/, /\bmv\b/,
56
+ // Starting servers/processes (dangerous from NL)
57
+ /\b(bun|npm|pnpm|yarn)\s+run\s+dev\b/, /\b(bun|npm)\s+start\b/,
56
58
  ];
57
59
 
58
60
  // Commands that are ALWAYS safe (read-only git, etc.)
@@ -259,6 +261,8 @@ SEMANTIC MAPPING: When the user references a concept, search the file tree for R
259
261
 
260
262
  ACTION vs CONCEPTUAL: If the prompt starts with "run", "execute", "check", "test", "build", "show output of" — ALWAYS generate an executable command. NEVER read README for action requests. Only read docs for "explain why", "what does X mean", "how was X designed".
261
263
 
264
+ EXISTENCE CHECKS: If the prompt starts with "is there", "does this have", "do we have", "does X exist" — NEVER run/start/launch anything. Use ls, find, or test -d to CHECK existence. These are READ-ONLY questions.
265
+
262
266
  MONOREPO: If the project context says "MONOREPO", search packages/ or apps/ NOT src/. Use: grep -rn "pattern" packages/ --include="*.ts". For specific packages, use packages/PKGNAME/src/.
263
267
  cwd: ${process.cwd()}
264
268
  shell: zsh / macOS${projectContext}${restrictionBlock}${contextBlock}`;
package/src/cli.tsx CHANGED
@@ -352,6 +352,33 @@ else if (args[0] === "symbols" && args[1]) {
352
352
  }
353
353
  }
354
354
 
355
+ // ── History command ──────────────────────────────────────────────────────────
356
+
357
+ else if (args[0] === "history") {
358
+ const { loadContext } = await import("./session-context.js");
359
+ const entries = loadContext();
360
+ if (entries.length === 0) { console.log("No recent history."); }
361
+ else {
362
+ for (const e of entries) {
363
+ const time = new Date(e.timestamp).toLocaleTimeString();
364
+ console.log(` ${time} ${e.prompt}`);
365
+ console.log(` $ ${e.command}`);
366
+ }
367
+ }
368
+ }
369
+
370
+ // ── Explain command ─────────────────────────────────────────────────────────
371
+
372
+ else if (args[0] === "explain" && args[1]) {
373
+ const command = args.slice(1).join(" ");
374
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
375
+ console.error("explain requires an API key"); process.exit(1);
376
+ }
377
+ const { explainCommand } = await import("./ai.js");
378
+ const explanation = await explainCommand(command);
379
+ console.log(explanation);
380
+ }
381
+
355
382
  // ── Snapshot command ─────────────────────────────────────────────────────────
356
383
 
357
384
  else if (args[0] === "snapshot") {
@@ -373,13 +400,7 @@ else if (args.length > 0) {
373
400
  // Everything that doesn't match a subcommand is treated as natural language
374
401
  const prompt = args.join(" ");
375
402
 
376
- if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
377
- console.error("terminal: No API key found.");
378
- console.error("Set one of:");
379
- console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
380
- console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
381
- process.exit(1);
382
- }
403
+ const offlineMode = !process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY;
383
404
 
384
405
  const { translateToCommand, checkPermissions, isIrreversible } = await import("./ai.js");
385
406
  const { execSync } = await import("child_process");
@@ -393,14 +414,31 @@ else if (args.length > 0) {
393
414
  const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
394
415
  const { detectLoop } = await import("./loop-detector.js");
395
416
  const { loadConfig } = await import("./history.js");
417
+ const { loadContext, saveContext, formatContext } = await import("./session-context.js");
418
+ const { getLearned, recordMapping } = await import("./usage-cache.js");
396
419
 
397
420
  const config = loadConfig();
398
421
  const perms = config.permissions;
422
+ const sessionCtx = formatContext();
423
+
424
+ // Check usage learning cache first (zero AI cost for repeated queries)
425
+ const learned = getLearned(prompt);
426
+ if (learned && !offlineMode) {
427
+ console.error(`[open-terminal] cached: $ ${learned}`);
428
+ }
399
429
 
400
- // Step 1: AI translates NL → shell command
430
+ // Step 1: AI translates NL → shell command (with session context for follow-ups)
401
431
  let command: string;
402
- try {
403
- command = await translateToCommand(prompt, perms, []);
432
+
433
+ if (offlineMode) {
434
+ // Offline: treat prompt as literal command, apply noise filter only
435
+ console.error("[open-terminal] offline mode (no API key) — running as literal command");
436
+ command = prompt;
437
+ } else if (learned) {
438
+ command = learned;
439
+ } else {
440
+ try {
441
+ command = await translateToCommand(sessionCtx ? `${prompt}\n${sessionCtx}` : prompt, perms, []);
404
442
  } catch (e: any) {
405
443
  // If BLOCKED, try README fallback ONLY for conceptual questions (not file access)
406
444
  if (e.message?.startsWith("BLOCKED:")) {
@@ -420,9 +458,18 @@ else if (args.length > 0) {
420
458
  } catch {}
421
459
  }
422
460
  }
423
- console.error(e.message);
461
+ // "I don't know" honesty — better than wrong answer
462
+ if (e.message?.startsWith("BLOCKED:")) {
463
+ console.log(`I don't know how to do this with shell commands. Try running it directly.`);
464
+ } else {
465
+ console.error(e.message);
466
+ }
424
467
  process.exit(1);
425
468
  }
469
+ } // close the else (learned/offline) block
470
+
471
+ // Record the mapping for usage learning
472
+ if (!offlineMode && !learned) recordMapping(prompt, command);
426
473
 
427
474
  // Check permissions
428
475
  const blocked = checkPermissions(command, perms);
@@ -493,6 +540,7 @@ else if (args.length > 0) {
493
540
  const clean = stripNoise(stripAnsi(raw)).cleaned;
494
541
  const rawTokens = estimateTokens(raw);
495
542
  recordUsage(rawTokens);
543
+ saveContext(prompt, actualCmd, clean.slice(0, 500));
496
544
 
497
545
  // Test output detection
498
546
  // Test output: skip watchlist, let AI framing handle it
@@ -531,6 +579,25 @@ else if (args.length > 0) {
531
579
  const errStdout = e.stdout?.toString() ?? "";
532
580
  const errStderr = e.stderr?.toString() ?? "";
533
581
  if (e.status === 1 && !errStdout.trim() && !errStderr.trim()) {
582
+ // Empty result — retry with broader scope before giving up
583
+ if (!actualCmd.includes("#(broadened)")) {
584
+ try {
585
+ const broaderCmd = await translateToCommand(
586
+ `${prompt} (Previous command found NOTHING. Try searching a BROADER scope: use . or packages/ instead of src/. Use simpler grep pattern.)`,
587
+ perms, []
588
+ );
589
+ if (broaderCmd && !isIrreversible(broaderCmd) && !checkPermissions(broaderCmd, perms)) {
590
+ console.error(`[open-terminal] broadening search...`);
591
+ const broaderResult = execSync(broaderCmd + " #(broadened)", { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
592
+ const broaderClean = stripNoise(stripAnsi(broaderResult)).cleaned;
593
+ if (broaderClean.trim()) {
594
+ const processed = await processOutput(broaderCmd, broaderClean, prompt);
595
+ console.log(processed.aiProcessed ? processed.summary : broaderClean);
596
+ process.exit(0);
597
+ }
598
+ }
599
+ } catch { /* broader also failed */ }
600
+ }
534
601
  console.log(`No results found for: ${prompt}`);
535
602
  process.exit(0);
536
603
  }
@@ -77,7 +77,65 @@ function extractSymbols(filePath: string): CodeSymbol[] {
77
77
  const lines = content.split("\n");
78
78
  const symbols: CodeSymbol[] = [];
79
79
  const file = filePath;
80
+ const ext = filePath.match(/\.(\w+)$/)?.[1] ?? "";
81
+
82
+ // Python support
83
+ if (ext === "py") {
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i];
86
+ const defMatch = line.match(/^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/);
87
+ if (defMatch) {
88
+ symbols.push({ name: defMatch[2], kind: "function", file, line: i + 1, signature: line.trim(), exported: !defMatch[2].startsWith("_") });
89
+ }
90
+ const classMatch = line.match(/^class\s+(\w+)/);
91
+ if (classMatch) {
92
+ symbols.push({ name: classMatch[1], kind: "class", file, line: i + 1, signature: line.trim(), exported: true });
93
+ }
94
+ }
95
+ return symbols;
96
+ }
97
+
98
+ // Go support
99
+ if (ext === "go") {
100
+ for (let i = 0; i < lines.length; i++) {
101
+ const line = lines[i];
102
+ const funcMatch = line.match(/^func\s+(?:\(\w+\s+\*?\w+\)\s+)?(\w+)\s*\(/);
103
+ if (funcMatch) {
104
+ symbols.push({ name: funcMatch[1], kind: "function", file, line: i + 1, signature: line.trim(), exported: /^[A-Z]/.test(funcMatch[1]) });
105
+ }
106
+ const typeMatch = line.match(/^type\s+(\w+)\s+(struct|interface)/);
107
+ if (typeMatch) {
108
+ symbols.push({ name: typeMatch[1], kind: typeMatch[2] === "interface" ? "interface" : "class", file, line: i + 1, signature: line.trim(), exported: /^[A-Z]/.test(typeMatch[1]) });
109
+ }
110
+ }
111
+ return symbols;
112
+ }
113
+
114
+ // Rust support
115
+ if (ext === "rs") {
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const line = lines[i];
118
+ const fnMatch = line.match(/^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)/);
119
+ if (fnMatch) {
120
+ symbols.push({ name: fnMatch[1], kind: "function", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
121
+ }
122
+ const structMatch = line.match(/^\s*(?:pub\s+)?struct\s+(\w+)/);
123
+ if (structMatch) {
124
+ symbols.push({ name: structMatch[1], kind: "class", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
125
+ }
126
+ const enumMatch = line.match(/^\s*(?:pub\s+)?enum\s+(\w+)/);
127
+ if (enumMatch) {
128
+ symbols.push({ name: enumMatch[1], kind: "type", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
129
+ }
130
+ const traitMatch = line.match(/^\s*(?:pub\s+)?trait\s+(\w+)/);
131
+ if (traitMatch) {
132
+ symbols.push({ name: traitMatch[1], kind: "interface", file, line: i + 1, signature: line.trim(), exported: line.includes("pub") });
133
+ }
134
+ }
135
+ return symbols;
136
+ }
80
137
 
138
+ // TypeScript/JavaScript (default)
81
139
  for (let i = 0; i < lines.length; i++) {
82
140
  const line = lines[i];
83
141
  const lineNum = i + 1;
@@ -0,0 +1,61 @@
1
+ // Session context — stores last N command+output pairs for follow-up queries
2
+ // Enables: terminal "show auth code" → terminal "explain that function"
3
+
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+
8
+ const DIR = join(homedir(), ".terminal");
9
+ const CTX_FILE = join(DIR, "session-context.json");
10
+ const MAX_ENTRIES = 5;
11
+
12
+ interface ContextEntry {
13
+ prompt: string;
14
+ command: string;
15
+ output: string; // first 500 chars
16
+ timestamp: number;
17
+ }
18
+
19
+ function ensureDir() {
20
+ if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
21
+ }
22
+
23
+ /** Load session context */
24
+ export function loadContext(): ContextEntry[] {
25
+ ensureDir();
26
+ if (!existsSync(CTX_FILE)) return [];
27
+ try {
28
+ const entries = JSON.parse(readFileSync(CTX_FILE, "utf8")) as ContextEntry[];
29
+ // Only return entries from last 30 minutes (session freshness)
30
+ const cutoff = Date.now() - 30 * 60 * 1000;
31
+ return entries.filter(e => e.timestamp > cutoff).slice(-MAX_ENTRIES);
32
+ } catch { return []; }
33
+ }
34
+
35
+ /** Save a command to session context */
36
+ export function saveContext(prompt: string, command: string, output: string): void {
37
+ ensureDir();
38
+ const entries = loadContext();
39
+ entries.push({
40
+ prompt,
41
+ command,
42
+ output: output.slice(0, 500),
43
+ timestamp: Date.now(),
44
+ });
45
+ // Keep only last N
46
+ const trimmed = entries.slice(-MAX_ENTRIES);
47
+ writeFileSync(CTX_FILE, JSON.stringify(trimmed, null, 2));
48
+ }
49
+
50
+ /** Format context for AI prompt injection */
51
+ export function formatContext(): string {
52
+ const entries = loadContext();
53
+ if (entries.length === 0) return "";
54
+ const lines: string[] = ["\nRECENT SESSION CONTEXT (for follow-up references like 'that', 'it', 'the same'):"];
55
+ for (const e of entries.slice(-3)) {
56
+ lines.push(`> ${e.prompt}`);
57
+ lines.push(`$ ${e.command}`);
58
+ if (e.output) lines.push(e.output.slice(0, 200));
59
+ }
60
+ return lines.join("\n");
61
+ }
@@ -0,0 +1,74 @@
1
+ // Usage learning cache — zero-cost repeated queries
2
+ // After 3 identical prompt→command mappings, cache locally
3
+
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
5
+ import { homedir } from "os";
6
+ import { join } from "path";
7
+ import { createHash } from "crypto";
8
+
9
+ const DIR = join(homedir(), ".terminal");
10
+ const CACHE_FILE = join(DIR, "learned.json");
11
+
12
+ interface LearnedEntry {
13
+ command: string;
14
+ count: number;
15
+ lastUsed: number;
16
+ }
17
+
18
+ type LearnedCache = Record<string, LearnedEntry>; // key = projectHash:promptHash
19
+
20
+ function ensureDir() {
21
+ if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
22
+ }
23
+
24
+ function hash(s: string): string {
25
+ return createHash("md5").update(s).digest("hex").slice(0, 12);
26
+ }
27
+
28
+ function cacheKey(prompt: string): string {
29
+ const projectHash = hash(process.cwd());
30
+ const promptHash = hash(prompt.toLowerCase().trim());
31
+ return `${projectHash}:${promptHash}`;
32
+ }
33
+
34
+ function loadCache(): LearnedCache {
35
+ ensureDir();
36
+ if (!existsSync(CACHE_FILE)) return {};
37
+ try { return JSON.parse(readFileSync(CACHE_FILE, "utf8")); } catch { return {}; }
38
+ }
39
+
40
+ function saveCache(cache: LearnedCache): void {
41
+ ensureDir();
42
+ writeFileSync(CACHE_FILE, JSON.stringify(cache));
43
+ }
44
+
45
+ /** Check if we have a learned command for this prompt (3+ identical mappings) */
46
+ export function getLearned(prompt: string): string | null {
47
+ const key = cacheKey(prompt);
48
+ const cache = loadCache();
49
+ const entry = cache[key];
50
+ if (entry && entry.count >= 3) return entry.command;
51
+ return null;
52
+ }
53
+
54
+ /** Record a prompt→command mapping */
55
+ export function recordMapping(prompt: string, command: string): void {
56
+ const key = cacheKey(prompt);
57
+ const cache = loadCache();
58
+ const existing = cache[key];
59
+ if (existing && existing.command === command) {
60
+ existing.count++;
61
+ existing.lastUsed = Date.now();
62
+ } else {
63
+ cache[key] = { command, count: 1, lastUsed: Date.now() };
64
+ }
65
+ saveCache(cache);
66
+ }
67
+
68
+ /** Get cache stats */
69
+ export function learnedStats(): { entries: number; cached: number } {
70
+ const cache = loadCache();
71
+ const entries = Object.keys(cache).length;
72
+ const cached = Object.values(cache).filter(e => e.count >= 3).length;
73
+ return { entries, cached };
74
+ }