@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 +94 -25
- package/dist/search/semantic.js +55 -0
- package/dist/session-context.js +55 -0
- package/dist/usage-cache.js +65 -0
- package/package.json +1 -1
- package/src/cli.tsx +93 -22
- package/src/search/semantic.ts +58 -0
- package/src/session-context.ts +61 -0
- package/src/usage-cache.ts +74 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
-
|
|
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
|
}
|
package/dist/search/semantic.js
CHANGED
|
@@ -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
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
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
|
443
|
+
// If BLOCKED, try README fallback ONLY for conceptual questions (not file access)
|
|
406
444
|
if (e.message?.startsWith("BLOCKED:")) {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
412
|
-
if (
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
}
|
|
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
|
}
|
package/src/search/semantic.ts
CHANGED
|
@@ -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
|
+
}
|