@hasna/terminal 2.2.0 → 2.3.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/cli.js CHANGED
@@ -28,6 +28,7 @@ SUBCOMMANDS:
28
28
  collection create|list Recipe collections
29
29
  mcp serve Start MCP server for AI agents
30
30
  mcp install --claude|--codex Install MCP server
31
+ discover [--days=N] [--json] Scan Claude sessions, show token savings potential
31
32
  snapshot Terminal state as JSON
32
33
  --help Show this help
33
34
  --version Show version
@@ -253,17 +254,8 @@ else if (args[0] === "collection") {
253
254
  }
254
255
  // ── Stats command ────────────────────────────────────────────────────────────
255
256
  else if (args[0] === "stats") {
256
- const { getEconomyStats, formatTokens } = await import("./economy.js");
257
- const s = getEconomyStats();
258
- console.log("Token Economy:");
259
- console.log(` Total saved: ${formatTokens(s.totalTokensSaved)}`);
260
- console.log(` Total used: ${formatTokens(s.totalTokensUsed)}`);
261
- console.log(` By feature:`);
262
- console.log(` Structured: ${formatTokens(s.savingsByFeature.structured)}`);
263
- console.log(` Compressed: ${formatTokens(s.savingsByFeature.compressed)}`);
264
- console.log(` Diff cache: ${formatTokens(s.savingsByFeature.diff)}`);
265
- console.log(` NL cache: ${formatTokens(s.savingsByFeature.cache)}`);
266
- console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
257
+ const { formatEconomicsSummary } = await import("./economy.js");
258
+ console.log(formatEconomicsSummary());
267
259
  }
268
260
  // ── Sessions command ─────────────────────────────────────────────────────────
269
261
  else if (args[0] === "sessions") {
@@ -369,17 +361,52 @@ else if (args[0] === "repo") {
369
361
  else if (args[0] === "symbols" && args[1]) {
370
362
  const { extractSymbolsFromFile } = await import("./search/semantic.js");
371
363
  const { resolve } = await import("path");
372
- const filePath = resolve(args[1]);
373
- const symbols = extractSymbolsFromFile(filePath);
374
- if (symbols.length === 0) {
375
- console.log("No symbols found.");
364
+ const { statSync, readdirSync } = await import("fs");
365
+ const target = resolve(args[1]);
366
+ const filter = args[2]; // optional: grep-like filter on symbol name
367
+ // Support directories — recurse and extract symbols from all source files
368
+ const files = [];
369
+ try {
370
+ if (statSync(target).isDirectory()) {
371
+ const walk = (dir) => {
372
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
373
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist")
374
+ continue;
375
+ const full = resolve(dir, entry.name);
376
+ if (entry.isDirectory())
377
+ walk(full);
378
+ else if (/\.(ts|tsx|py|go|rs)$/.test(entry.name) && !/\.(test|spec)\.\w+$/.test(entry.name))
379
+ files.push(full);
380
+ }
381
+ };
382
+ walk(target);
383
+ }
384
+ else {
385
+ files.push(target);
386
+ }
376
387
  }
377
- else {
378
- for (const s of symbols) {
388
+ catch {
389
+ files.push(target);
390
+ }
391
+ let totalSymbols = 0;
392
+ for (const file of files) {
393
+ const symbols = extractSymbolsFromFile(file);
394
+ const filtered = filter ? symbols.filter(s => s.name.toLowerCase().includes(filter.toLowerCase()) || s.kind.toLowerCase().includes(filter.toLowerCase())) : symbols;
395
+ if (filtered.length === 0)
396
+ continue;
397
+ const relPath = file.replace(process.cwd() + "/", "");
398
+ if (files.length > 1)
399
+ console.log(`\n${relPath}:`);
400
+ for (const s of filtered) {
379
401
  const exp = s.exported ? "⬡" : "·";
380
402
  console.log(` ${exp} ${s.kind.padEnd(10)} L${String(s.line).padStart(4)} ${s.name}`);
381
403
  }
404
+ totalSymbols += filtered.length;
382
405
  }
406
+ if (totalSymbols === 0)
407
+ console.log("No symbols found.");
408
+ else if (files.length > 1)
409
+ console.log(`\n${totalSymbols} symbols across ${files.length} files`);
383
410
  }
384
411
  // ── History command ──────────────────────────────────────────────────────────
385
412
  else if (args[0] === "history") {
@@ -407,6 +434,19 @@ else if (args[0] === "explain" && args[1]) {
407
434
  const explanation = await explainCommand(command);
408
435
  console.log(explanation);
409
436
  }
437
+ // ── Discover command ─────────────────────────────────────────────────────────
438
+ else if (args[0] === "discover") {
439
+ const { discover, formatDiscoverReport } = await import("./discover.js");
440
+ const days = parseInt(args.find(a => a.startsWith("--days="))?.split("=")[1] ?? "30");
441
+ const json = args.includes("--json");
442
+ const report = discover({ maxAgeDays: days });
443
+ if (json) {
444
+ console.log(JSON.stringify(report, null, 2));
445
+ }
446
+ else {
447
+ console.log(formatDiscoverReport(report));
448
+ }
449
+ }
410
450
  // ── Snapshot command ─────────────────────────────────────────────────────────
411
451
  else if (args[0] === "snapshot") {
412
452
  const { captureSnapshot } = await import("./snapshots.js");
@@ -430,6 +470,7 @@ else if (args.length > 0) {
430
470
  const { processOutput, shouldProcess } = await import("./output-processor.js");
431
471
  const { rewriteCommand } = await import("./command-rewriter.js");
432
472
  const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
473
+ const { saveOutput, formatOutputHint } = await import("./output-store.js");
433
474
  const { parseOutput, estimateTokens } = await import("./parsers/index.js");
434
475
  const { recordSaving, recordUsage } = await import("./economy.js");
435
476
  const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
@@ -437,6 +478,7 @@ else if (args.length > 0) {
437
478
  const { loadConfig } = await import("./history.js");
438
479
  const { loadContext, saveContext, formatContext } = await import("./session-context.js");
439
480
  const { getLearned, recordMapping } = await import("./usage-cache.js");
481
+ const { recordCorrection, findSimilarCorrections, recordOutput } = await import("./sessions-db.js");
440
482
  const config = loadConfig();
441
483
  const perms = config.permissions;
442
484
  const sessionCtx = formatContext();
@@ -580,7 +622,15 @@ else if (args.length > 0) {
580
622
  if (processed.aiProcessed) {
581
623
  if (processed.tokensSaved > 0)
582
624
  recordSaving("compressed", processed.tokensSaved);
583
- console.log(processed.summary);
625
+ // Save full output for lazy recovery — agents can read the file
626
+ if (processed.tokensSaved > 50) {
627
+ const outputPath = saveOutput(actualCmd, clean);
628
+ console.log(processed.summary);
629
+ console.log(formatOutputHint(outputPath));
630
+ }
631
+ else {
632
+ console.log(processed.summary);
633
+ }
584
634
  if (processed.tokensSaved > 10)
585
635
  console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved})`);
586
636
  process.exit(0);
@@ -606,7 +656,7 @@ else if (args.length > 0) {
606
656
  catch (e) {
607
657
  // Empty result (grep exit 1 = no matches) — not a real error
608
658
  const errStdout = e.stdout?.toString() ?? "";
609
- const errStderr = e.stderr?.toString() ?? "";
659
+ let errStderr = e.stderr?.toString() ?? "";
610
660
  if (e.status === 1 && !errStdout.trim() && !errStderr.trim()) {
611
661
  // Empty result — retry with broader scope before giving up
612
662
  if (!actualCmd.includes("#(broadened)")) {
@@ -628,22 +678,37 @@ else if (args.length > 0) {
628
678
  console.log(`No results found for: ${prompt}`);
629
679
  process.exit(0);
630
680
  }
631
- // Auto-retry: if command failed (exit 2+), ask AI for a simpler alternative
632
- if (e.status >= 2 && !actualCmd.includes("(retry)")) {
633
- try {
634
- const retryCmd = await translateToCommand(`${prompt} (The previous command failed with: ${errStderr.slice(0, 200)}. Try a SIMPLER approach. Use basic commands only.)`, perms, []);
635
- if (retryCmd && !isIrreversible(retryCmd) && !checkPermissions(retryCmd, perms)) {
636
- console.error(`[open-terminal] retrying: $ ${retryCmd}`);
637
- const retryResult = execSync(retryCmd + " #(retry)", { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
681
+ // 3-retry learning loop: each attempt learns from the previous failure
682
+ if (e.status >= 2) {
683
+ const retryStrategies = [
684
+ // Attempt 2: inject error context
685
+ `${prompt} (Command "${actualCmd}" failed with: ${errStderr.slice(0, 300)}. Fix this specific error. Keep the approach but correct the issue.)`,
686
+ // Attempt 3: inject corrections + force simplicity
687
+ `${prompt} (TWO commands already failed for this query. Use the ABSOLUTE SIMPLEST approach: basic grep -rn, find, wc -l, cat. No awk, no xargs, no subshells. Must work on macOS BSD.)`,
688
+ ];
689
+ for (let attempt = 0; attempt < retryStrategies.length; attempt++) {
690
+ try {
691
+ const retryCmd = await translateToCommand(retryStrategies[attempt], perms, []);
692
+ if (!retryCmd || retryCmd === actualCmd || isIrreversible(retryCmd) || checkPermissions(retryCmd, perms))
693
+ continue;
694
+ console.error(`[open-terminal] retry ${attempt + 2}/3: $ ${retryCmd}`);
695
+ const retryResult = execSync(retryCmd + ` #(retry${attempt + 2})`, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
638
696
  const retryClean = stripNoise(stripAnsi(retryResult)).cleaned;
639
697
  if (retryClean.length > 5) {
698
+ // Record correction — AI learns for next time
699
+ recordCorrection(prompt, actualCmd, errStderr.slice(0, 500), retryCmd, true);
640
700
  const processed = await processOutput(retryCmd, retryClean, prompt);
641
701
  console.log(processed.aiProcessed ? processed.summary : retryClean);
642
702
  process.exit(0);
643
703
  }
644
704
  }
705
+ catch (retryErr) {
706
+ // This attempt also failed — record it and try next strategy
707
+ const retryStderr = retryErr.stderr?.toString() ?? "";
708
+ errStderr = retryStderr; // update for next attempt's context
709
+ continue;
710
+ }
645
711
  }
646
- catch { /* retry also failed, fall through */ }
647
712
  }
648
713
  // Combine stdout+stderr and try AI answer framing (for audit/lint/test commands)
649
714
  const combined = errStderr && errStdout.includes(errStderr.trim()) ? errStdout : errStdout + errStderr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "2.2.0",
3
+ "version": "2.3.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
@@ -6,51 +6,48 @@ import { join } from "path";
6
6
  import { discoverProjectHints, discoverSafetyHints, formatHints } from "./context-hints.js";
7
7
 
8
8
  // ── model routing ─────────────────────────────────────────────────────────────
9
- // Simple queries fast model. Complex/ambiguous smart model.
9
+ // Config-driven model selection. Defaults per provider, user can override in ~/.terminal/config.json
10
10
 
11
11
  const COMPLEX_SIGNALS = [
12
12
  /\b(undo|revert|rollback|previous|last)\b/i,
13
13
  /\b(all files?|recursively|bulk|batch)\b/i,
14
14
  /\b(pipeline|chain|then|and then|after)\b/i,
15
15
  /\b(if|when|unless|only if)\b/i,
16
- /\b(go into|go to|navigate|cd into|enter)\b.*\b(and|then)\b/i, // multi-step navigation
17
- /\b(inside|within|under)\b/i, // relative references need context awareness
18
- /[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
16
+ /\b(go into|go to|navigate|cd into|enter)\b.*\b(and|then)\b/i,
17
+ /\b(inside|within|under)\b/i,
18
+ /[|&;]{2}/,
19
19
  ];
20
20
 
21
- /** Model routing per provider */
21
+ /** Default models per provider — user can override in ~/.terminal/config.json under "models" */
22
+ const MODEL_DEFAULTS: Record<string, { fast: string; smart: string }> = {
23
+ cerebras: { fast: "qwen-3-235b-a22b-instruct-2507", smart: "qwen-3-235b-a22b-instruct-2507" },
24
+ groq: { fast: "openai/gpt-oss-120b", smart: "moonshotai/kimi-k2-instruct" },
25
+ xai: { fast: "grok-code-fast-1", smart: "grok-4-fast-non-reasoning" },
26
+ anthropic: { fast: "claude-haiku-4-5-20251001", smart: "claude-sonnet-4-6" },
27
+ };
28
+
29
+ /** Load user model overrides from ~/.terminal/config.json */
30
+ function loadModelOverrides(): Record<string, { fast?: string; smart?: string }> {
31
+ try {
32
+ const configPath = join(process.env.HOME ?? "~", ".terminal", "config.json");
33
+ if (existsSync(configPath)) {
34
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
35
+ return config.models ?? {};
36
+ }
37
+ } catch {}
38
+ return {};
39
+ }
40
+
41
+ /** Model routing per provider — config-driven with defaults */
22
42
  function pickModel(nl: string): { fast: string; smart: string; pick: "fast" | "smart" } {
23
43
  const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
24
44
  const provider = getProvider();
45
+ const defaults = MODEL_DEFAULTS[provider.name] ?? MODEL_DEFAULTS.cerebras;
46
+ const overrides = loadModelOverrides()[provider.name] ?? {};
25
47
 
26
- if (provider.name === "anthropic") {
27
- return {
28
- fast: "claude-haiku-4-5-20251001",
29
- smart: "claude-sonnet-4-6",
30
- pick: isComplex ? "smart" : "fast",
31
- };
32
- }
33
-
34
- if (provider.name === "groq") {
35
- return {
36
- fast: "openai/gpt-oss-120b",
37
- smart: "moonshotai/kimi-k2-instruct",
38
- pick: isComplex ? "smart" : "fast",
39
- };
40
- }
41
-
42
- if (provider.name === "xai") {
43
- return {
44
- fast: "grok-code-fast-1",
45
- smart: "grok-4-fast-non-reasoning",
46
- pick: isComplex ? "smart" : "fast",
47
- };
48
- }
49
-
50
- // Cerebras — qwen for everything (llama3.1-8b too unreliable)
51
48
  return {
52
- fast: "qwen-3-235b-a22b-instruct-2507",
53
- smart: "qwen-3-235b-a22b-instruct-2507",
49
+ fast: overrides.fast ?? defaults.fast,
50
+ smart: overrides.smart ?? defaults.smart,
54
51
  pick: isComplex ? "smart" : "fast",
55
52
  };
56
53
  }
@@ -124,6 +121,23 @@ export interface SessionEntry {
124
121
  error?: boolean;
125
122
  }
126
123
 
124
+ // ── correction memory ───────────────────────────────────────────────────────
125
+
126
+ /** Load past corrections relevant to a prompt — injected as negative examples */
127
+ function loadCorrectionHints(prompt: string): string {
128
+ try {
129
+ // Dynamic import to avoid circular deps
130
+ const { findSimilarCorrections } = require("./sessions-db.js");
131
+ const corrections = findSimilarCorrections(prompt, 3);
132
+ if (corrections.length === 0) return "";
133
+
134
+ const lines = corrections.map((c: any) =>
135
+ `AVOID: "${c.failed_command}" (failed: ${c.error_type}). USE: "${c.corrected_command}" instead.`
136
+ );
137
+ return `\n\nLEARNED CORRECTIONS (from past failures):\n${lines.join("\n")}`;
138
+ } catch { return ""; }
139
+ }
140
+
127
141
  // ── project context (powered by context-hints) ──────────────────────────────
128
142
 
129
143
  function detectProjectContext(): string {
@@ -133,7 +147,7 @@ function detectProjectContext(): string {
133
147
 
134
148
  // ── system prompt ─────────────────────────────────────────────────────────────
135
149
 
136
- function buildSystemPrompt(perms: Permissions, sessionEntries: SessionEntry[]): string {
150
+ function buildSystemPrompt(perms: Permissions, sessionEntries: SessionEntry[], currentPrompt?: string): string {
137
151
  const restrictions: string[] = [];
138
152
  if (!perms.destructive)
139
153
  restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
@@ -202,6 +216,14 @@ RULES:
202
216
  - For conceptual questions about what code does: use cat on the relevant file, the AI summary will explain it.
203
217
  - For DESTRUCTIVE requests (delete, remove, install, push): output BLOCKED: <reason>. NEVER try to execute destructive commands.
204
218
 
219
+ AST-POWERED QUERIES: For code STRUCTURE questions, use the built-in AST tool instead of grep:
220
+ - "find all exported functions" → terminal symbols src/ (lists all functions, classes, interfaces with line numbers)
221
+ - "show all interfaces" → terminal symbols src/ | grep interface
222
+ - "what does file X export" → terminal symbols src/file.ts
223
+ - "show me the class hierarchy" → terminal symbols src/
224
+ The "terminal symbols" command uses AST parsing (not regex) — it understands TypeScript, Python, Go, Rust code structure.
225
+ For TEXT search (TODO, string matches, imports) → use grep as normal.
226
+
205
227
  COMPOUND QUESTIONS: For questions asking multiple things, prefer ONE command that captures all info. Extract multiple answers from a single output.
206
228
  - "how many tests and do they pass" → bun test (extract count AND pass/fail from output)
207
229
  - "what files changed and how many lines" → git log --stat -3 (shows files AND line counts)
@@ -233,7 +255,7 @@ EXISTENCE CHECKS: If the prompt starts with "is there", "does this have", "do we
233
255
 
234
256
  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/.
235
257
  cwd: ${process.cwd()}
236
- shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}`;
258
+ shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}${currentPrompt ? loadCorrectionHints(currentPrompt) : ""}`;
237
259
  }
238
260
 
239
261
  // ── streaming translate ───────────────────────────────────────────────────────
@@ -253,7 +275,7 @@ export async function translateToCommand(
253
275
  const provider = getProvider();
254
276
  const routing = pickModel(nl);
255
277
  const model = routing.pick === "smart" ? routing.smart : routing.fast;
256
- const system = buildSystemPrompt(perms, sessionEntries);
278
+ const system = buildSystemPrompt(perms, sessionEntries, nl);
257
279
 
258
280
  let text: string;
259
281
 
@@ -332,7 +354,7 @@ export async function fixCommand(
332
354
  {
333
355
  model: routing.smart, // always use smart model for fixes
334
356
  maxTokens: 256,
335
- system: buildSystemPrompt(perms, sessionEntries),
357
+ system: buildSystemPrompt(perms, sessionEntries, originalNl),
336
358
  }
337
359
  );
338
360
  if (text.startsWith("BLOCKED:")) throw new Error(text);
package/src/cli.tsx CHANGED
@@ -31,6 +31,7 @@ SUBCOMMANDS:
31
31
  collection create|list Recipe collections
32
32
  mcp serve Start MCP server for AI agents
33
33
  mcp install --claude|--codex Install MCP server
34
+ discover [--days=N] [--json] Scan Claude sessions, show token savings potential
34
35
  snapshot Terminal state as JSON
35
36
  --help Show this help
36
37
  --version Show version
@@ -243,17 +244,8 @@ else if (args[0] === "collection") {
243
244
  // ── Stats command ────────────────────────────────────────────────────────────
244
245
 
245
246
  else if (args[0] === "stats") {
246
- const { getEconomyStats, formatTokens } = await import("./economy.js");
247
- const s = getEconomyStats();
248
- console.log("Token Economy:");
249
- console.log(` Total saved: ${formatTokens(s.totalTokensSaved)}`);
250
- console.log(` Total used: ${formatTokens(s.totalTokensUsed)}`);
251
- console.log(` By feature:`);
252
- console.log(` Structured: ${formatTokens(s.savingsByFeature.structured)}`);
253
- console.log(` Compressed: ${formatTokens(s.savingsByFeature.compressed)}`);
254
- console.log(` Diff cache: ${formatTokens(s.savingsByFeature.diff)}`);
255
- console.log(` NL cache: ${formatTokens(s.savingsByFeature.cache)}`);
256
- console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
247
+ const { formatEconomicsSummary } = await import("./economy.js");
248
+ console.log(formatEconomicsSummary());
257
249
  }
258
250
 
259
251
  // ── Sessions command ─────────────────────────────────────────────────────────
@@ -343,15 +335,43 @@ else if (args[0] === "repo") {
343
335
  else if (args[0] === "symbols" && args[1]) {
344
336
  const { extractSymbolsFromFile } = await import("./search/semantic.js");
345
337
  const { resolve } = await import("path");
346
- const filePath = resolve(args[1]);
347
- const symbols = extractSymbolsFromFile(filePath);
348
- if (symbols.length === 0) { console.log("No symbols found."); }
349
- else {
350
- for (const s of symbols) {
338
+ const { statSync, readdirSync } = await import("fs");
339
+ const target = resolve(args[1]);
340
+ const filter = args[2]; // optional: grep-like filter on symbol name
341
+
342
+ // Support directories recurse and extract symbols from all source files
343
+ const files: string[] = [];
344
+ try {
345
+ if (statSync(target).isDirectory()) {
346
+ const walk = (dir: string) => {
347
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
348
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") continue;
349
+ const full = resolve(dir, entry.name);
350
+ if (entry.isDirectory()) walk(full);
351
+ else if (/\.(ts|tsx|py|go|rs)$/.test(entry.name) && !/\.(test|spec)\.\w+$/.test(entry.name)) files.push(full);
352
+ }
353
+ };
354
+ walk(target);
355
+ } else {
356
+ files.push(target);
357
+ }
358
+ } catch { files.push(target); }
359
+
360
+ let totalSymbols = 0;
361
+ for (const file of files) {
362
+ const symbols = extractSymbolsFromFile(file);
363
+ const filtered = filter ? symbols.filter(s => s.name.toLowerCase().includes(filter.toLowerCase()) || s.kind.toLowerCase().includes(filter.toLowerCase())) : symbols;
364
+ if (filtered.length === 0) continue;
365
+ const relPath = file.replace(process.cwd() + "/", "");
366
+ if (files.length > 1) console.log(`\n${relPath}:`);
367
+ for (const s of filtered) {
351
368
  const exp = s.exported ? "⬡" : "·";
352
369
  console.log(` ${exp} ${s.kind.padEnd(10)} L${String(s.line).padStart(4)} ${s.name}`);
353
370
  }
371
+ totalSymbols += filtered.length;
354
372
  }
373
+ if (totalSymbols === 0) console.log("No symbols found.");
374
+ else if (files.length > 1) console.log(`\n${totalSymbols} symbols across ${files.length} files`);
355
375
  }
356
376
 
357
377
  // ── History command ──────────────────────────────────────────────────────────
@@ -381,6 +401,20 @@ else if (args[0] === "explain" && args[1]) {
381
401
  console.log(explanation);
382
402
  }
383
403
 
404
+ // ── Discover command ─────────────────────────────────────────────────────────
405
+
406
+ else if (args[0] === "discover") {
407
+ const { discover, formatDiscoverReport } = await import("./discover.js");
408
+ const days = parseInt(args.find(a => a.startsWith("--days="))?.split("=")[1] ?? "30");
409
+ const json = args.includes("--json");
410
+ const report = discover({ maxAgeDays: days });
411
+ if (json) {
412
+ console.log(JSON.stringify(report, null, 2));
413
+ } else {
414
+ console.log(formatDiscoverReport(report));
415
+ }
416
+ }
417
+
384
418
  // ── Snapshot command ─────────────────────────────────────────────────────────
385
419
 
386
420
  else if (args[0] === "snapshot") {
@@ -411,6 +445,7 @@ else if (args.length > 0) {
411
445
  const { processOutput, shouldProcess } = await import("./output-processor.js");
412
446
  const { rewriteCommand } = await import("./command-rewriter.js");
413
447
  const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
448
+ const { saveOutput, formatOutputHint } = await import("./output-store.js");
414
449
  const { parseOutput, estimateTokens } = await import("./parsers/index.js");
415
450
  const { recordSaving, recordUsage } = await import("./economy.js");
416
451
  const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
@@ -418,6 +453,7 @@ else if (args.length > 0) {
418
453
  const { loadConfig } = await import("./history.js");
419
454
  const { loadContext, saveContext, formatContext } = await import("./session-context.js");
420
455
  const { getLearned, recordMapping } = await import("./usage-cache.js");
456
+ const { recordCorrection, findSimilarCorrections, recordOutput } = await import("./sessions-db.js");
421
457
 
422
458
  const config = loadConfig();
423
459
  const perms = config.permissions;
@@ -566,7 +602,14 @@ else if (args.length > 0) {
566
602
  const processed = await processOutput(actualCmd, clean, prompt);
567
603
  if (processed.aiProcessed) {
568
604
  if (processed.tokensSaved > 0) recordSaving("compressed", processed.tokensSaved);
569
- console.log(processed.summary);
605
+ // Save full output for lazy recovery — agents can read the file
606
+ if (processed.tokensSaved > 50) {
607
+ const outputPath = saveOutput(actualCmd, clean);
608
+ console.log(processed.summary);
609
+ console.log(formatOutputHint(outputPath));
610
+ } else {
611
+ console.log(processed.summary);
612
+ }
570
613
  if (processed.tokensSaved > 10) console.error(`[open-terminal] ${rawTokens} → ${rawTokens - processed.tokensSaved} tokens (saved ${processed.tokensSaved})`);
571
614
  process.exit(0);
572
615
  }
@@ -588,7 +631,7 @@ else if (args.length > 0) {
588
631
  } catch (e: any) {
589
632
  // Empty result (grep exit 1 = no matches) — not a real error
590
633
  const errStdout = e.stdout?.toString() ?? "";
591
- const errStderr = e.stderr?.toString() ?? "";
634
+ let errStderr = e.stderr?.toString() ?? "";
592
635
  if (e.status === 1 && !errStdout.trim() && !errStderr.trim()) {
593
636
  // Empty result — retry with broader scope before giving up
594
637
  if (!actualCmd.includes("#(broadened)")) {
@@ -613,24 +656,37 @@ else if (args.length > 0) {
613
656
  process.exit(0);
614
657
  }
615
658
 
616
- // Auto-retry: if command failed (exit 2+), ask AI for a simpler alternative
617
- if (e.status >= 2 && !actualCmd.includes("(retry)")) {
618
- try {
619
- const retryCmd = await translateToCommand(
620
- `${prompt} (The previous command failed with: ${errStderr.slice(0, 200)}. Try a SIMPLER approach. Use basic commands only.)`,
621
- perms, []
622
- );
623
- if (retryCmd && !isIrreversible(retryCmd) && !checkPermissions(retryCmd, perms)) {
624
- console.error(`[open-terminal] retrying: $ ${retryCmd}`);
625
- const retryResult = execSync(retryCmd + " #(retry)", { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
659
+ // 3-retry learning loop: each attempt learns from the previous failure
660
+ if (e.status >= 2) {
661
+ const retryStrategies = [
662
+ // Attempt 2: inject error context
663
+ `${prompt} (Command "${actualCmd}" failed with: ${errStderr.slice(0, 300)}. Fix this specific error. Keep the approach but correct the issue.)`,
664
+ // Attempt 3: inject corrections + force simplicity
665
+ `${prompt} (TWO commands already failed for this query. Use the ABSOLUTE SIMPLEST approach: basic grep -rn, find, wc -l, cat. No awk, no xargs, no subshells. Must work on macOS BSD.)`,
666
+ ];
667
+
668
+ for (let attempt = 0; attempt < retryStrategies.length; attempt++) {
669
+ try {
670
+ const retryCmd = await translateToCommand(retryStrategies[attempt], perms, []);
671
+ if (!retryCmd || retryCmd === actualCmd || isIrreversible(retryCmd) || checkPermissions(retryCmd, perms)) continue;
672
+
673
+ console.error(`[open-terminal] retry ${attempt + 2}/3: $ ${retryCmd}`);
674
+ const retryResult = execSync(retryCmd + ` #(retry${attempt + 2})`, { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, cwd: process.cwd() });
626
675
  const retryClean = stripNoise(stripAnsi(retryResult)).cleaned;
627
676
  if (retryClean.length > 5) {
677
+ // Record correction — AI learns for next time
678
+ recordCorrection(prompt, actualCmd, errStderr.slice(0, 500), retryCmd, true);
628
679
  const processed = await processOutput(retryCmd, retryClean, prompt);
629
680
  console.log(processed.aiProcessed ? processed.summary : retryClean);
630
681
  process.exit(0);
631
682
  }
683
+ } catch (retryErr: any) {
684
+ // This attempt also failed — record it and try next strategy
685
+ const retryStderr = retryErr.stderr?.toString() ?? "";
686
+ errStderr = retryStderr; // update for next attempt's context
687
+ continue;
632
688
  }
633
- } catch { /* retry also failed, fall through */ }
689
+ }
634
690
  }
635
691
 
636
692
  // Combine stdout+stderr and try AI answer framing (for audit/lint/test commands)
@@ -160,9 +160,98 @@ export function discoverOutputHints(output: string, command: string): string[] {
160
160
  // Sensitive data (only env var assignments, not code containing the word KEY/TOKEN)
161
161
  if (output.match(/^[A-Z_]+(KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S+/m)) hints.push("Output may contain sensitive data — redact credentials");
162
162
 
163
+ // Error block extraction — state machine that captures multi-line errors
164
+ if (!isGrepOutput) {
165
+ const errorBlocks = extractErrorBlocks(output);
166
+ if (errorBlocks.length > 0) {
167
+ const summary = errorBlocks.slice(0, 3).map(b => b.trim().split("\n").slice(0, 5).join("\n")).join("\n---\n");
168
+ hints.push(`ERROR BLOCKS FOUND (${errorBlocks.length}):\n${summary}`);
169
+ }
170
+ }
171
+
163
172
  return hints;
164
173
  }
165
174
 
175
+ /** Extract multi-line error blocks using a state machine */
176
+ function extractErrorBlocks(output: string): string[] {
177
+ const lines = output.split("\n");
178
+ const blocks: string[] = [];
179
+ let currentBlock: string[] = [];
180
+ let inErrorBlock = false;
181
+ let blankCount = 0;
182
+
183
+ // Patterns that START an error block
184
+ const errorStarters = [
185
+ /^error/i, /^Error:/i, /^ERROR/,
186
+ /^Traceback/i, /^panic:/i, /^fatal:/i,
187
+ /^FAIL/i, /^✗/, /^✘/,
188
+ /error\s*TS\d+/i, /error\[E\d+\]/,
189
+ /^SyntaxError/i, /^TypeError/i, /^ReferenceError/i,
190
+ /^Unhandled/i, /^Exception/i,
191
+ /ENOENT|EACCES|EADDRINUSE|ECONNREFUSED/,
192
+ ];
193
+
194
+ for (const line of lines) {
195
+ const trimmed = line.trim();
196
+
197
+ if (!trimmed) {
198
+ blankCount++;
199
+ if (inErrorBlock) {
200
+ currentBlock.push(line);
201
+ // 2+ blank lines = end of error block
202
+ if (blankCount >= 2) {
203
+ blocks.push(currentBlock.join("\n").trim());
204
+ currentBlock = [];
205
+ inErrorBlock = false;
206
+ }
207
+ }
208
+ continue;
209
+ }
210
+ blankCount = 0;
211
+
212
+ // Check if this line starts a new error block
213
+ if (!inErrorBlock && errorStarters.some(p => p.test(trimmed))) {
214
+ inErrorBlock = true;
215
+ currentBlock = [line];
216
+ continue;
217
+ }
218
+
219
+ if (inErrorBlock) {
220
+ // Continuation: indented lines, "at ..." stack frames, "--->" pointers, "File ..." python traces
221
+ const isContinuation =
222
+ /^\s+/.test(line) ||
223
+ /^\s*at\s/.test(trimmed) ||
224
+ /^\s*-+>/.test(trimmed) ||
225
+ /^\s*\|/.test(trimmed) ||
226
+ /^\s*File "/.test(trimmed) ||
227
+ /^\s*\d+\s*\|/.test(trimmed) || // rust/compiler line numbers
228
+ /^Caused by:/i.test(trimmed);
229
+
230
+ if (isContinuation) {
231
+ currentBlock.push(line);
232
+ } else {
233
+ // Non-continuation, non-blank = end of error block
234
+ blocks.push(currentBlock.join("\n").trim());
235
+ currentBlock = [];
236
+ inErrorBlock = false;
237
+
238
+ // Check if THIS line starts a new error block
239
+ if (errorStarters.some(p => p.test(trimmed))) {
240
+ inErrorBlock = true;
241
+ currentBlock = [line];
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ // Flush remaining block
248
+ if (currentBlock.length > 0) {
249
+ blocks.push(currentBlock.join("\n").trim());
250
+ }
251
+
252
+ return blocks;
253
+ }
254
+
166
255
  /** Discover safety hints about a command */
167
256
  export function discoverSafetyHints(command: string): string[] {
168
257
  const hints: string[] = [];
@@ -0,0 +1,238 @@
1
+ // Discover — scan Claude Code session history to find token savings opportunities
2
+ // Reads ~/.claude/projects/*/sessions/*.jsonl, extracts Bash commands + output sizes,
3
+ // estimates how much terminal would have saved.
4
+
5
+ import { readdirSync, readFileSync, statSync, existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { estimateTokens } from "./parsers/index.js";
8
+
9
+ export interface DiscoveredCommand {
10
+ command: string;
11
+ outputTokens: number;
12
+ outputChars: number;
13
+ sessionFile: string;
14
+ timestamp?: string;
15
+ }
16
+
17
+ export interface DiscoverReport {
18
+ totalSessions: number;
19
+ totalCommands: number;
20
+ totalOutputTokens: number;
21
+ estimatedSavings: number; // tokens saved at 70% compression
22
+ estimatedSavingsUsd: number; // at Opus rates ($5/M input)
23
+ topCommands: { command: string; count: number; totalTokens: number; avgTokens: number }[];
24
+ commandsByCategory: Record<string, { count: number; tokens: number }>;
25
+ }
26
+
27
+ /** Find all Claude session JSONL files */
28
+ function findSessionFiles(claudeDir: string, maxAge?: number): string[] {
29
+ const files: string[] = [];
30
+ const projectsDir = join(claudeDir, "projects");
31
+ if (!existsSync(projectsDir)) return files;
32
+
33
+ const now = Date.now();
34
+ const cutoff = maxAge ? now - maxAge : 0;
35
+
36
+ try {
37
+ for (const project of readdirSync(projectsDir)) {
38
+ const projectPath = join(projectsDir, project);
39
+ // Look for session JSONL files (not subagents)
40
+ try {
41
+ for (const entry of readdirSync(projectPath)) {
42
+ if (entry.endsWith(".jsonl")) {
43
+ const filePath = join(projectPath, entry);
44
+ try {
45
+ const stat = statSync(filePath);
46
+ if (stat.mtimeMs > cutoff) files.push(filePath);
47
+ } catch {}
48
+ }
49
+ }
50
+ } catch {}
51
+ }
52
+ } catch {}
53
+
54
+ return files;
55
+ }
56
+
57
+ /** Extract Bash commands and their output sizes from a session file */
58
+ function extractCommands(sessionFile: string): DiscoveredCommand[] {
59
+ const commands: DiscoveredCommand[] = [];
60
+
61
+ try {
62
+ const content = readFileSync(sessionFile, "utf8");
63
+ const lines = content.split("\n").filter(l => l.trim());
64
+
65
+ // Track tool_use IDs to match with tool_results
66
+ const pendingToolUses: Map<string, string> = new Map(); // id -> command
67
+
68
+ for (const line of lines) {
69
+ try {
70
+ const obj = JSON.parse(line);
71
+ const msg = obj.message;
72
+ if (!msg?.content || !Array.isArray(msg.content)) continue;
73
+
74
+ for (const block of msg.content) {
75
+ // Capture Bash tool_use commands
76
+ if (block.type === "tool_use" && block.name === "Bash" && block.input?.command) {
77
+ pendingToolUses.set(block.id, block.input.command);
78
+ }
79
+
80
+ // Capture tool_result outputs and match to commands
81
+ if (block.type === "tool_result" && block.tool_use_id) {
82
+ const command = pendingToolUses.get(block.tool_use_id);
83
+ if (command) {
84
+ let outputText = "";
85
+ if (typeof block.content === "string") {
86
+ outputText = block.content;
87
+ } else if (Array.isArray(block.content)) {
88
+ outputText = block.content
89
+ .filter((c: any) => c.type === "text")
90
+ .map((c: any) => c.text)
91
+ .join("\n");
92
+ }
93
+
94
+ if (outputText.length > 0) {
95
+ commands.push({
96
+ command,
97
+ outputTokens: estimateTokens(outputText),
98
+ outputChars: outputText.length,
99
+ sessionFile,
100
+ });
101
+ }
102
+ pendingToolUses.delete(block.tool_use_id);
103
+ }
104
+ }
105
+ }
106
+ } catch {} // skip malformed lines
107
+ }
108
+ } catch {} // skip unreadable files
109
+
110
+ return commands;
111
+ }
112
+
113
+ /** Categorize a command into a bucket */
114
+ function categorizeCommand(cmd: string): string {
115
+ const trimmed = cmd.trim();
116
+ if (/^git\b/.test(trimmed)) return "git";
117
+ if (/\b(bun|npm|yarn|pnpm)\s+(test|run\s+test)/.test(trimmed)) return "test";
118
+ if (/\b(bun|npm|yarn|pnpm)\s+run\s+(build|typecheck|lint)/.test(trimmed)) return "build";
119
+ if (/^(grep|rg)\b/.test(trimmed)) return "grep";
120
+ if (/^find\b/.test(trimmed)) return "find";
121
+ if (/^(cat|head|tail|less)\b/.test(trimmed)) return "read";
122
+ if (/^(ls|tree|du|wc)\b/.test(trimmed)) return "list";
123
+ if (/^(curl|wget|fetch)\b/.test(trimmed)) return "network";
124
+ if (/^(docker|kubectl|helm)\b/.test(trimmed)) return "infra";
125
+ if (/^(python|pip|pytest)\b/.test(trimmed)) return "python";
126
+ if (/^(cargo|rustc)\b/.test(trimmed)) return "rust";
127
+ if (/^(go\s|golangci)\b/.test(trimmed)) return "go";
128
+ return "other";
129
+ }
130
+
131
+ /** Normalize command for grouping (strip variable parts like paths, hashes) */
132
+ function normalizeCommand(cmd: string): string {
133
+ return cmd
134
+ .replace(/[0-9a-f]{7,40}/g, "{hash}") // git hashes
135
+ .replace(/\/[\w./-]+\.(ts|tsx|js|json|py|rs|go)\b/g, "{file}") // file paths
136
+ .replace(/\d{4}-\d{2}-\d{2}/g, "{date}") // dates
137
+ .replace(/:\d+/g, ":{line}") // line numbers
138
+ .trim();
139
+ }
140
+
141
+ /** Run discover across all Claude sessions */
142
+ export function discover(options: { maxAgeDays?: number; minTokens?: number } = {}): DiscoverReport {
143
+ const claudeDir = join(process.env.HOME ?? "~", ".claude");
144
+ const maxAge = (options.maxAgeDays ?? 30) * 24 * 60 * 60 * 1000;
145
+ const minTokens = options.minTokens ?? 50;
146
+
147
+ const sessionFiles = findSessionFiles(claudeDir, maxAge);
148
+ const allCommands: DiscoveredCommand[] = [];
149
+
150
+ for (const file of sessionFiles) {
151
+ allCommands.push(...extractCommands(file));
152
+ }
153
+
154
+ // Filter to commands with meaningful output
155
+ const significant = allCommands.filter(c => c.outputTokens >= minTokens);
156
+
157
+ // Group by normalized command
158
+ const groups = new Map<string, { count: number; totalTokens: number; example: string }>();
159
+ for (const cmd of significant) {
160
+ const key = normalizeCommand(cmd.command);
161
+ const existing = groups.get(key) ?? { count: 0, totalTokens: 0, example: cmd.command };
162
+ existing.count++;
163
+ existing.totalTokens += cmd.outputTokens;
164
+ groups.set(key, existing);
165
+ }
166
+
167
+ // Top commands by total tokens
168
+ const topCommands = [...groups.entries()]
169
+ .map(([cmd, data]) => ({
170
+ command: data.example,
171
+ count: data.count,
172
+ totalTokens: data.totalTokens,
173
+ avgTokens: Math.round(data.totalTokens / data.count),
174
+ }))
175
+ .sort((a, b) => b.totalTokens - a.totalTokens)
176
+ .slice(0, 20);
177
+
178
+ // Category breakdown
179
+ const commandsByCategory: Record<string, { count: number; tokens: number }> = {};
180
+ for (const cmd of significant) {
181
+ const cat = categorizeCommand(cmd.command);
182
+ if (!commandsByCategory[cat]) commandsByCategory[cat] = { count: 0, tokens: 0 };
183
+ commandsByCategory[cat].count++;
184
+ commandsByCategory[cat].tokens += cmd.outputTokens;
185
+ }
186
+
187
+ const totalOutputTokens = significant.reduce((sum, c) => sum + c.outputTokens, 0);
188
+ // Conservative 70% compression estimate (RTK claims 60-90%)
189
+ const estimatedSavings = Math.round(totalOutputTokens * 0.7);
190
+ // Each saved input token is repeated across ~5 turns on average before compaction
191
+ const multipliedSavings = estimatedSavings * 5;
192
+ // At Opus rates ($5/M input tokens)
193
+ const estimatedSavingsUsd = (multipliedSavings * 5) / 1_000_000;
194
+
195
+ return {
196
+ totalSessions: sessionFiles.length,
197
+ totalCommands: significant.length,
198
+ totalOutputTokens,
199
+ estimatedSavings,
200
+ estimatedSavingsUsd,
201
+ topCommands,
202
+ commandsByCategory,
203
+ };
204
+ }
205
+
206
+ /** Format discover report for CLI display */
207
+ export function formatDiscoverReport(report: DiscoverReport): string {
208
+ const lines: string[] = [];
209
+
210
+ lines.push(`📊 Terminal Discover — Token Savings Analysis`);
211
+ lines.push(` Scanned ${report.totalSessions} sessions, ${report.totalCommands} commands with >50 token output\n`);
212
+
213
+ lines.push(`💰 Estimated savings with open-terminal:`);
214
+ lines.push(` Output tokens: ${report.totalOutputTokens.toLocaleString()}`);
215
+ lines.push(` Compressible: ${report.estimatedSavings.toLocaleString()} tokens (70% avg)`);
216
+ lines.push(` Repeated ~5x before compaction = ${(report.estimatedSavings * 5).toLocaleString()} billable tokens`);
217
+ lines.push(` At Opus rates: $${report.estimatedSavingsUsd.toFixed(2)} saved\n`);
218
+
219
+ if (report.topCommands.length > 0) {
220
+ lines.push(`🔝 Top commands by token cost:`);
221
+ for (const cmd of report.topCommands.slice(0, 15)) {
222
+ const avg = cmd.avgTokens.toLocaleString().padStart(6);
223
+ const total = cmd.totalTokens.toLocaleString().padStart(8);
224
+ lines.push(` ${String(cmd.count).padStart(4)}× ${avg} avg → ${total} total ${cmd.command.slice(0, 60)}`);
225
+ }
226
+ lines.push("");
227
+ }
228
+
229
+ if (Object.keys(report.commandsByCategory).length > 0) {
230
+ lines.push(`📁 By category:`);
231
+ const sorted = Object.entries(report.commandsByCategory).sort((a, b) => b[1].tokens - a[1].tokens);
232
+ for (const [cat, data] of sorted) {
233
+ lines.push(` ${cat.padEnd(10)} ${String(data.count).padStart(5)} cmds ${data.tokens.toLocaleString().padStart(10)} tokens`);
234
+ }
235
+ }
236
+
237
+ return lines.join("\n");
238
+ }
package/src/economy.ts CHANGED
@@ -97,3 +97,56 @@ export function formatTokens(n: number): string {
97
97
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
98
98
  return `${n}`;
99
99
  }
100
+
101
+ // ── Weighted economics ──────────────────────────────────────────────────────
102
+ // Saved input tokens are repeated across multiple turns before compaction.
103
+ // Weighted pricing accounts for the actual billing impact.
104
+
105
+ /** Provider pricing per million tokens */
106
+ const PROVIDER_PRICING: Record<string, { input: number; output: number }> = {
107
+ cerebras: { input: 0.60, output: 1.20 },
108
+ groq: { input: 0.15, output: 0.60 },
109
+ xai: { input: 0.20, output: 1.50 },
110
+ anthropic: { input: 0.80, output: 4.00 }, // Haiku
111
+ "anthropic-sonnet": { input: 3.00, output: 15.00 },
112
+ "anthropic-opus": { input: 5.00, output: 25.00 },
113
+ };
114
+
115
+ /** Estimate USD savings from compressed tokens */
116
+ export function estimateSavingsUsd(
117
+ tokensSaved: number,
118
+ consumerModel: string = "anthropic-opus",
119
+ avgTurnsBeforeCompaction: number = 5,
120
+ ): { savingsUsd: number; multipliedTokens: number; ratePerMillion: number } {
121
+ const pricing = PROVIDER_PRICING[consumerModel] ?? PROVIDER_PRICING["anthropic-opus"];
122
+ const multipliedTokens = tokensSaved * avgTurnsBeforeCompaction;
123
+ const savingsUsd = (multipliedTokens * pricing.input) / 1_000_000;
124
+ return { savingsUsd, multipliedTokens, ratePerMillion: pricing.input };
125
+ }
126
+
127
+ /** Format a full economics summary */
128
+ export function formatEconomicsSummary(): string {
129
+ const s = loadStats();
130
+ const opus = estimateSavingsUsd(s.totalTokensSaved, "anthropic-opus");
131
+ const sonnet = estimateSavingsUsd(s.totalTokensSaved, "anthropic-sonnet");
132
+ const haiku = estimateSavingsUsd(s.totalTokensSaved, "anthropic");
133
+
134
+ return [
135
+ `Token Economy:`,
136
+ ` Tokens saved: ${formatTokens(s.totalTokensSaved)}`,
137
+ ` Tokens used: ${formatTokens(s.totalTokensUsed)}`,
138
+ ` Ratio: ${s.totalTokensUsed > 0 ? (s.totalTokensSaved / s.totalTokensUsed).toFixed(1) : "∞"}x return`,
139
+ ``,
140
+ ` Estimated USD savings (×5 turns before compaction):`,
141
+ ` Opus ($5/M): $${opus.savingsUsd.toFixed(2)} (${formatTokens(opus.multipliedTokens)} billable tokens)`,
142
+ ` Sonnet ($3/M): $${sonnet.savingsUsd.toFixed(2)}`,
143
+ ` Haiku ($0.8/M): $${haiku.savingsUsd.toFixed(2)}`,
144
+ ``,
145
+ ` By feature:`,
146
+ ` Compressed: ${formatTokens(s.savingsByFeature.compressed)}`,
147
+ ` Structured: ${formatTokens(s.savingsByFeature.structured)}`,
148
+ ` Diff cache: ${formatTokens(s.savingsByFeature.diff)}`,
149
+ ` NL cache: ${formatTokens(s.savingsByFeature.cache)}`,
150
+ ` Search: ${formatTokens(s.savingsByFeature.search)}`,
151
+ ].join("\n");
152
+ }
@@ -5,6 +5,7 @@ import { getProvider } from "./providers/index.js";
5
5
  import { estimateTokens } from "./parsers/index.js";
6
6
  import { recordSaving } from "./economy.js";
7
7
  import { discoverOutputHints } from "./context-hints.js";
8
+ import { formatProfileHints } from "./tool-profiles.js";
8
9
 
9
10
  export interface ProcessedOutput {
10
11
  /** AI-generated summary (concise, structured) */
@@ -86,9 +87,13 @@ export async function processOutput(
86
87
  ? `\n\nOUTPUT OBSERVATIONS:\n${outputHints.join("\n")}`
87
88
  : "";
88
89
 
90
+ // Inject tool-specific profile hints
91
+ const profileBlock = formatProfileHints(command);
92
+ const profileHints = profileBlock ? `\n\n${profileBlock}` : "";
93
+
89
94
  const provider = getProvider();
90
95
  const summary = await provider.complete(
91
- `${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}${hintsBlock}`,
96
+ `${originalPrompt ? `User asked: ${originalPrompt}\n` : ""}Command: ${command}\nOutput (${lines.length} lines):\n${toSummarize}${hintsBlock}${profileHints}`,
92
97
  {
93
98
  system: SUMMARIZE_PROMPT,
94
99
  maxTokens: 300,
@@ -0,0 +1,111 @@
1
+ // Output store — saves full raw output to disk when AI compresses it
2
+ // Agents can read the file for full detail. Tiered retention strategy.
3
+
4
+ import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from "fs";
5
+ import { join } from "path";
6
+ import { createHash } from "crypto";
7
+
8
+ const OUTPUTS_DIR = join(process.env.HOME ?? "~", ".terminal", "outputs");
9
+
10
+ /** Ensure outputs directory exists */
11
+ function ensureDir() {
12
+ if (!existsSync(OUTPUTS_DIR)) mkdirSync(OUTPUTS_DIR, { recursive: true });
13
+ }
14
+
15
+ /** Generate a short hash for an output */
16
+ function hashOutput(command: string, output: string): string {
17
+ return createHash("md5").update(command + output.slice(0, 1000)).digest("hex").slice(0, 12);
18
+ }
19
+
20
+ /** Tiered retention: recent = keep all, older = keep only high-value */
21
+ function rotate() {
22
+ try {
23
+ const now = Date.now();
24
+ const ONE_HOUR = 60 * 60 * 1000;
25
+ const ONE_DAY = 24 * ONE_HOUR;
26
+
27
+ const files = readdirSync(OUTPUTS_DIR)
28
+ .filter(f => f.endsWith(".txt"))
29
+ .map(f => {
30
+ const path = join(OUTPUTS_DIR, f);
31
+ const stat = statSync(path);
32
+ return { name: f, path, mtime: stat.mtimeMs, size: stat.size };
33
+ })
34
+ .sort((a, b) => b.mtime - a.mtime); // newest first
35
+
36
+ for (const file of files) {
37
+ const age = now - file.mtime;
38
+
39
+ // Last 1 hour: keep everything
40
+ if (age < ONE_HOUR) continue;
41
+
42
+ // Last 24 hours: keep outputs >2KB (meaningful compression)
43
+ if (age < ONE_DAY) {
44
+ if (file.size < 2000) {
45
+ try { unlinkSync(file.path); } catch {}
46
+ }
47
+ continue;
48
+ }
49
+
50
+ // Older than 24h: keep only >10KB (high-value saves)
51
+ if (file.size < 10000) {
52
+ try { unlinkSync(file.path); } catch {}
53
+ continue;
54
+ }
55
+
56
+ // Older than 7 days: remove everything
57
+ if (age > 7 * ONE_DAY) {
58
+ try { unlinkSync(file.path); } catch {}
59
+ }
60
+ }
61
+
62
+ // Hard cap: never exceed 100 files or 10MB total
63
+ const remaining = readdirSync(OUTPUTS_DIR)
64
+ .filter(f => f.endsWith(".txt"))
65
+ .map(f => ({ path: join(OUTPUTS_DIR, f), mtime: statSync(join(OUTPUTS_DIR, f)).mtimeMs, size: statSync(join(OUTPUTS_DIR, f)).size }))
66
+ .sort((a, b) => b.mtime - a.mtime);
67
+
68
+ let totalSize = 0;
69
+ for (let i = 0; i < remaining.length; i++) {
70
+ totalSize += remaining[i].size;
71
+ if (i >= 100 || totalSize > 10 * 1024 * 1024) {
72
+ try { unlinkSync(remaining[i].path); } catch {}
73
+ }
74
+ }
75
+ } catch {}
76
+ }
77
+
78
+ /** Save full output to disk, return the file path */
79
+ export function saveOutput(command: string, rawOutput: string): string {
80
+ ensureDir();
81
+
82
+ const hash = hashOutput(command, rawOutput);
83
+ const filename = `${hash}.txt`;
84
+ const filepath = join(OUTPUTS_DIR, filename);
85
+
86
+ const content = `$ ${command}\n${"─".repeat(60)}\n${rawOutput}`;
87
+ writeFileSync(filepath, content, "utf8");
88
+
89
+ rotate();
90
+ return filepath;
91
+ }
92
+
93
+ /** Format the hint line that tells agents where to find full output */
94
+ export function formatOutputHint(filepath: string): string {
95
+ return `[full output: ${filepath}]`;
96
+ }
97
+
98
+ /** Get the outputs directory path */
99
+ export function getOutputsDir(): string {
100
+ return OUTPUTS_DIR;
101
+ }
102
+
103
+ /** Manually purge all outputs */
104
+ export function purgeOutputs(): number {
105
+ if (!existsSync(OUTPUTS_DIR)) return 0;
106
+ let count = 0;
107
+ for (const f of readdirSync(OUTPUTS_DIR)) {
108
+ try { unlinkSync(join(OUTPUTS_DIR, f)); count++; } catch {}
109
+ }
110
+ return count;
111
+ }
@@ -51,13 +51,13 @@ function resolveProvider(config: ProviderConfig): LLMProvider {
51
51
  return p;
52
52
  }
53
53
 
54
- // auto: prefer xAI (code-optimized), then Cerebras, then Groq, then Anthropic
55
- const xai = new XaiProvider();
56
- if (xai.isAvailable()) return xai;
57
-
54
+ // auto: prefer Cerebras (qwen-235b, fast + accurate), then xAI, then Groq, then Anthropic
58
55
  const cerebras = new CerebrasProvider();
59
56
  if (cerebras.isAvailable()) return cerebras;
60
57
 
58
+ const xai = new XaiProvider();
59
+ if (xai.isAvailable()) return xai;
60
+
61
61
  const groq = new GroqProvider();
62
62
  if (groq.isAvailable()) return groq;
63
63
 
@@ -45,6 +45,32 @@ function getDb(): Database {
45
45
 
46
46
  CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id);
47
47
  CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
48
+
49
+ CREATE TABLE IF NOT EXISTS corrections (
50
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
51
+ prompt TEXT NOT NULL,
52
+ failed_command TEXT NOT NULL,
53
+ error_output TEXT,
54
+ corrected_command TEXT NOT NULL,
55
+ worked INTEGER DEFAULT 1,
56
+ error_type TEXT,
57
+ created_at INTEGER NOT NULL
58
+ );
59
+
60
+ CREATE TABLE IF NOT EXISTS outputs (
61
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
62
+ session_id TEXT,
63
+ command TEXT NOT NULL,
64
+ raw_output_path TEXT,
65
+ compressed_summary TEXT,
66
+ tokens_raw INTEGER DEFAULT 0,
67
+ tokens_compressed INTEGER DEFAULT 0,
68
+ provider TEXT,
69
+ model TEXT,
70
+ created_at INTEGER NOT NULL
71
+ );
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_corrections_prompt ON corrections(prompt);
48
74
  `);
49
75
 
50
76
  return db;
@@ -186,6 +212,61 @@ export function getSessionStats(): SessionStats {
186
212
  };
187
213
  }
188
214
 
215
+ // ── Corrections ─────────────────────────────────────────────────────────────
216
+
217
+ /** Record a correction: command failed, then AI retried with a better one */
218
+ export function recordCorrection(
219
+ prompt: string,
220
+ failedCommand: string,
221
+ errorOutput: string,
222
+ correctedCommand: string,
223
+ worked: boolean,
224
+ errorType?: string,
225
+ ): void {
226
+ getDb().prepare(
227
+ "INSERT INTO corrections (prompt, failed_command, error_output, corrected_command, worked, error_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
228
+ ).run(prompt, failedCommand, errorOutput?.slice(0, 2000) ?? "", correctedCommand, worked ? 1 : 0, errorType ?? null, Date.now());
229
+ }
230
+
231
+ /** Find similar corrections for a prompt — used to inject as negative examples */
232
+ export function findSimilarCorrections(prompt: string, limit: number = 5): { failed_command: string; corrected_command: string; error_type: string }[] {
233
+ // Simple keyword matching — extract significant words from prompt
234
+ const words = prompt.toLowerCase().split(/\s+/).filter(w => w.length > 3);
235
+ if (words.length === 0) return [];
236
+
237
+ // Search corrections where the prompt shares keywords
238
+ const all = getDb().prepare(
239
+ "SELECT prompt, failed_command, corrected_command, error_type FROM corrections WHERE worked = 1 ORDER BY created_at DESC LIMIT 100"
240
+ ).all() as any[];
241
+
242
+ return all
243
+ .filter(c => {
244
+ const cWords = c.prompt.toLowerCase().split(/\s+/);
245
+ const overlap = words.filter((w: string) => cWords.some((cw: string) => cw.includes(w) || w.includes(cw)));
246
+ return overlap.length >= Math.min(2, words.length);
247
+ })
248
+ .slice(0, limit)
249
+ .map(c => ({ failed_command: c.failed_command, corrected_command: c.corrected_command, error_type: c.error_type ?? "unknown" }));
250
+ }
251
+
252
+ // ── Output tracking ─────────────────────────────────────────────────────────
253
+
254
+ /** Record a compressed output for audit trail */
255
+ export function recordOutput(
256
+ command: string,
257
+ rawOutputPath: string | null,
258
+ compressedSummary: string,
259
+ tokensRaw: number,
260
+ tokensCompressed: number,
261
+ provider?: string,
262
+ model?: string,
263
+ sessionId?: string,
264
+ ): void {
265
+ getDb().prepare(
266
+ "INSERT INTO outputs (session_id, command, raw_output_path, compressed_summary, tokens_raw, tokens_compressed, provider, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
267
+ ).run(sessionId ?? null, command, rawOutputPath ?? null, compressedSummary?.slice(0, 5000) ?? "", tokensRaw, tokensCompressed, provider ?? null, model ?? null, Date.now());
268
+ }
269
+
189
270
  /** Close the database connection */
190
271
  export function closeDb(): void {
191
272
  if (db) { db.close(); db = null; }
@@ -0,0 +1,139 @@
1
+ // Tool profiles — config-driven AI enhancement for specific command categories
2
+ // Profiles are loaded from ~/.terminal/profiles/ (user-customizable)
3
+ // Each profile tells the AI how to handle a specific tool's output
4
+
5
+ import { existsSync, readFileSync, readdirSync } from "fs";
6
+ import { join } from "path";
7
+
8
+ export interface ToolProfile {
9
+ name: string;
10
+ /** Regex pattern to detect this tool in a command */
11
+ detect: string;
12
+ /** Hints injected into the AI output processor prompt */
13
+ hints: {
14
+ compress?: string; // How to compress this tool's output
15
+ errors?: string; // How to extract errors from this tool
16
+ success?: string; // What success looks like
17
+ };
18
+ /** Output handling */
19
+ output?: {
20
+ maxLines?: number; // Cap output before AI processing
21
+ preservePatterns?: string[]; // Regex patterns to always keep
22
+ stripPatterns?: string[]; // Regex patterns to always remove
23
+ };
24
+ }
25
+
26
+ const PROFILES_DIR = join(process.env.HOME ?? "~", ".terminal", "profiles");
27
+
28
+ /** Built-in profiles — sensible defaults, user can override */
29
+ const BUILTIN_PROFILES: ToolProfile[] = [
30
+ {
31
+ name: "git",
32
+ detect: "^git\\b",
33
+ hints: {
34
+ compress: "For git output: show branch, file counts, insertions/deletions summary. Collapse individual diffs to file-level stats.",
35
+ errors: "Git errors often include a suggested fix (e.g., 'did you mean X?'). Extract the suggestion.",
36
+ success: "Clean working tree, successful push/pull, merge complete.",
37
+ },
38
+ output: { preservePatterns: ["conflict", "CONFLICT", "fatal", "error", "diverged"] },
39
+ },
40
+ {
41
+ name: "test",
42
+ detect: "\\b(bun|npm|yarn|pnpm)\\s+(test|run\\s+test)|\\bpytest\\b|\\bcargo\\s+test\\b|\\bgo\\s+test\\b",
43
+ hints: {
44
+ compress: "For test output: show pass/fail counts FIRST, then list ONLY failing test names with error snippets. Skip passing tests entirely.",
45
+ errors: "Test failures have: test name, expected vs actual, stack trace. Extract all three.",
46
+ success: "All tests passing = one line: '✓ N tests pass, 0 fail'",
47
+ },
48
+ output: { preservePatterns: ["FAIL", "fail", "Error", "✗", "expected", "received"] },
49
+ },
50
+ {
51
+ name: "build",
52
+ detect: "\\b(tsc|bun\\s+run\\s+build|npm\\s+run\\s+build|cargo\\s+build|go\\s+build|make)\\b",
53
+ hints: {
54
+ compress: "For build output: if success with no errors, say '✓ Build succeeded'. If errors, list each error with file:line and message.",
55
+ errors: "Build errors have file:line:column format. Group by file.",
56
+ success: "Empty output or exit 0 = build succeeded.",
57
+ },
58
+ },
59
+ {
60
+ name: "lint",
61
+ detect: "\\b(eslint|biome|ruff|clippy|golangci-lint|prettier|tsc\\s+--noEmit)\\b",
62
+ hints: {
63
+ compress: "For lint output: group violations by rule name, show count per rule, one example per rule. Skip clean files.",
64
+ errors: "Lint violations: file:line rule-name message. Group by rule.",
65
+ },
66
+ output: { maxLines: 100 },
67
+ },
68
+ {
69
+ name: "install",
70
+ detect: "\\b(npm\\s+install|bun\\s+install|yarn|pip\\s+install|cargo\\s+build|go\\s+mod)\\b",
71
+ hints: {
72
+ compress: "For install output: show only errors and final summary (packages added/removed/updated). Strip progress bars, funding notices, deprecation warnings.",
73
+ },
74
+ output: { stripPatterns: ["npm warn", "packages are looking for funding", "run `npm fund`"] },
75
+ },
76
+ {
77
+ name: "find",
78
+ detect: "^find\\b",
79
+ hints: {
80
+ compress: "For find output: if >50 results, group by top-level directory with counts. Show first 10 results as examples.",
81
+ },
82
+ },
83
+ {
84
+ name: "docker",
85
+ detect: "\\b(docker|kubectl|helm)\\b",
86
+ hints: {
87
+ compress: "For container output: show container status, image, ports. Strip pull progress and layer hashes.",
88
+ errors: "Docker errors: extract the error message after 'Error response from daemon:'",
89
+ },
90
+ },
91
+ ];
92
+
93
+ /** Load user profiles from ~/.terminal/profiles/ */
94
+ function loadUserProfiles(): ToolProfile[] {
95
+ if (!existsSync(PROFILES_DIR)) return [];
96
+
97
+ const profiles: ToolProfile[] = [];
98
+ try {
99
+ for (const file of readdirSync(PROFILES_DIR)) {
100
+ if (!file.endsWith(".json")) continue;
101
+ try {
102
+ const content = JSON.parse(readFileSync(join(PROFILES_DIR, file), "utf8"));
103
+ if (content.name && content.detect) profiles.push(content as ToolProfile);
104
+ } catch {}
105
+ }
106
+ } catch {}
107
+ return profiles;
108
+ }
109
+
110
+ /** Get all profiles — user profiles override builtins by name */
111
+ export function getProfiles(): ToolProfile[] {
112
+ const user = loadUserProfiles();
113
+ const userNames = new Set(user.map(p => p.name));
114
+ const builtins = BUILTIN_PROFILES.filter(p => !userNames.has(p.name));
115
+ return [...user, ...builtins];
116
+ }
117
+
118
+ /** Find the matching profile for a command */
119
+ export function matchProfile(command: string): ToolProfile | null {
120
+ for (const profile of getProfiles()) {
121
+ try {
122
+ if (new RegExp(profile.detect).test(command)) return profile;
123
+ } catch {}
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /** Format profile hints for injection into AI prompt */
129
+ export function formatProfileHints(command: string): string {
130
+ const profile = matchProfile(command);
131
+ if (!profile) return "";
132
+
133
+ const lines: string[] = [`TOOL PROFILE (${profile.name}):`];
134
+ if (profile.hints.compress) lines.push(` Compression: ${profile.hints.compress}`);
135
+ if (profile.hints.errors) lines.push(` Errors: ${profile.hints.errors}`);
136
+ if (profile.hints.success) lines.push(` Success: ${profile.hints.success}`);
137
+
138
+ return lines.join("\n");
139
+ }