@hasna/terminal 1.6.6 → 2.0.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/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,32 +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 fallback: read README or package.json for conceptual questions
425
- if (e.message?.startsWith("BLOCKED:")) {
426
- try {
427
- const { existsSync, readFileSync } = await import("fs");
428
- if (existsSync("README.md")) {
429
- const readme = readFileSync("README.md", "utf8").slice(0, 3000);
430
- const processed = await processOutput("cat README.md", readme, prompt);
431
- if (processed.aiProcessed) {
432
- console.log(processed.summary);
433
- 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
+ }
475
+ }
434
476
  }
477
+ catch { }
435
478
  }
436
479
  }
437
- catch { }
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);
438
488
  }
439
- console.error(e.message);
440
- process.exit(1);
441
- }
489
+ } // close the else (learned/offline) block
490
+ // Record the mapping for usage learning
491
+ if (!offlineMode && !learned)
492
+ recordMapping(prompt, command);
442
493
  // Check permissions
443
494
  const blocked = checkPermissions(command, perms);
444
495
  if (blocked) {
@@ -504,6 +555,7 @@ else if (args.length > 0) {
504
555
  const clean = stripNoise(stripAnsi(raw)).cleaned;
505
556
  const rawTokens = estimateTokens(raw);
506
557
  recordUsage(rawTokens);
558
+ saveContext(prompt, actualCmd, clean.slice(0, 500));
507
559
  // Test output detection
508
560
  // Test output: skip watchlist, let AI framing handle it
509
561
  // The AI reads "42 pass, 0 fail" better than regex parsing bun's mixed output
@@ -544,6 +596,23 @@ else if (args.length > 0) {
544
596
  const errStdout = e.stdout?.toString() ?? "";
545
597
  const errStderr = e.stderr?.toString() ?? "";
546
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
+ }
547
616
  console.log(`No results found for: ${prompt}`);
548
617
  process.exit(0);
549
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.6",
3
+ "version": "2.0.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": {
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,32 +414,62 @@ 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
- // If BLOCKED, try fallback: read README or package.json for conceptual questions
443
+ // If BLOCKED, try README fallback ONLY for conceptual questions (not file access)
406
444
  if (e.message?.startsWith("BLOCKED:")) {
407
- try {
408
- const { existsSync, readFileSync } = await import("fs");
409
- if (existsSync("README.md")) {
410
- const readme = readFileSync("README.md", "utf8").slice(0, 3000);
411
- const processed = await processOutput("cat README.md", readme, prompt);
412
- if (processed.aiProcessed) {
413
- console.log(processed.summary);
414
- process.exit(0);
445
+ const isConceptual = /\b(explain|why|what does|how does|describe|architecture|overview|summary)\b/i.test(prompt);
446
+ const isFileAccess = /\b(cat|show|read|find|ls|list)\b.*\b(\.\w+\/|src\/|packages\/)/i.test(prompt);
447
+ if (isConceptual && !isFileAccess) {
448
+ try {
449
+ const { existsSync, readFileSync } = await import("fs");
450
+ if (existsSync("README.md")) {
451
+ const readme = readFileSync("README.md", "utf8").slice(0, 3000);
452
+ const processed = await processOutput("cat README.md", readme, prompt);
453
+ if (processed.aiProcessed) {
454
+ console.log(processed.summary);
455
+ process.exit(0);
456
+ }
415
457
  }
416
- }
417
- } catch {}
458
+ } catch {}
459
+ }
460
+ }
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);
418
466
  }
419
- console.error(e.message);
420
467
  process.exit(1);
421
468
  }
469
+ } // close the else (learned/offline) block
470
+
471
+ // Record the mapping for usage learning
472
+ if (!offlineMode && !learned) recordMapping(prompt, command);
422
473
 
423
474
  // Check permissions
424
475
  const blocked = checkPermissions(command, perms);
@@ -489,6 +540,7 @@ else if (args.length > 0) {
489
540
  const clean = stripNoise(stripAnsi(raw)).cleaned;
490
541
  const rawTokens = estimateTokens(raw);
491
542
  recordUsage(rawTokens);
543
+ saveContext(prompt, actualCmd, clean.slice(0, 500));
492
544
 
493
545
  // Test output detection
494
546
  // Test output: skip watchlist, let AI framing handle it
@@ -527,6 +579,25 @@ else if (args.length > 0) {
527
579
  const errStdout = e.stdout?.toString() ?? "";
528
580
  const errStderr = e.stderr?.toString() ?? "";
529
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
+ }
530
601
  console.log(`No results found for: ${prompt}`);
531
602
  process.exit(0);
532
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
+ }