@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 +92 -27
- package/package.json +1 -1
- package/src/ai.ts +58 -36
- package/src/cli.tsx +85 -29
- package/src/context-hints.ts +89 -0
- package/src/discover.ts +238 -0
- package/src/economy.ts +53 -0
- package/src/output-processor.ts +6 -1
- package/src/output-store.ts +111 -0
- package/src/providers/index.ts +4 -4
- package/src/sessions-db.ts +81 -0
- package/src/tool-profiles.ts +139 -0
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 {
|
|
257
|
-
|
|
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
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
632
|
-
if (e.status >= 2
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
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
|
-
//
|
|
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,
|
|
17
|
-
/\b(inside|within|under)\b/i,
|
|
18
|
-
/[|&;]{2}/,
|
|
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
|
-
/**
|
|
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:
|
|
53
|
-
smart:
|
|
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 {
|
|
247
|
-
|
|
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
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
617
|
-
if (e.status >= 2
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
)
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
}
|
|
689
|
+
}
|
|
634
690
|
}
|
|
635
691
|
|
|
636
692
|
// Combine stdout+stderr and try AI answer framing (for audit/lint/test commands)
|
package/src/context-hints.ts
CHANGED
|
@@ -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[] = [];
|
package/src/discover.ts
ADDED
|
@@ -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
|
+
}
|
package/src/output-processor.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/index.ts
CHANGED
|
@@ -51,13 +51,13 @@ function resolveProvider(config: ProviderConfig): LLMProvider {
|
|
|
51
51
|
return p;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
// auto: prefer
|
|
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
|
|
package/src/sessions-db.ts
CHANGED
|
@@ -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
|
+
}
|