@hasna/terminal 2.3.1 → 2.3.2

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.
Files changed (66) hide show
  1. package/dist/App.js +404 -0
  2. package/dist/Browse.js +79 -0
  3. package/dist/FuzzyPicker.js +47 -0
  4. package/dist/Onboarding.js +51 -0
  5. package/dist/Spinner.js +12 -0
  6. package/dist/StatusBar.js +49 -0
  7. package/dist/ai.js +322 -0
  8. package/dist/cache.js +41 -0
  9. package/dist/command-rewriter.js +64 -0
  10. package/dist/command-validator.js +86 -0
  11. package/dist/compression.js +107 -0
  12. package/dist/context-hints.js +275 -0
  13. package/dist/diff-cache.js +107 -0
  14. package/dist/discover.js +212 -0
  15. package/dist/economy.js +123 -0
  16. package/dist/expand-store.js +38 -0
  17. package/dist/file-cache.js +72 -0
  18. package/dist/file-index.js +62 -0
  19. package/dist/history.js +62 -0
  20. package/dist/lazy-executor.js +54 -0
  21. package/dist/line-dedup.js +59 -0
  22. package/dist/loop-detector.js +75 -0
  23. package/dist/mcp/install.js +98 -0
  24. package/dist/mcp/server.js +569 -0
  25. package/dist/noise-filter.js +86 -0
  26. package/dist/output-processor.js +129 -0
  27. package/dist/output-router.js +41 -0
  28. package/dist/output-store.js +111 -0
  29. package/dist/parsers/base.js +2 -0
  30. package/dist/parsers/build.js +64 -0
  31. package/dist/parsers/errors.js +101 -0
  32. package/dist/parsers/files.js +78 -0
  33. package/dist/parsers/git.js +99 -0
  34. package/dist/parsers/index.js +48 -0
  35. package/dist/parsers/tests.js +89 -0
  36. package/dist/providers/anthropic.js +39 -0
  37. package/dist/providers/base.js +4 -0
  38. package/dist/providers/cerebras.js +95 -0
  39. package/dist/providers/groq.js +95 -0
  40. package/dist/providers/index.js +73 -0
  41. package/dist/providers/xai.js +95 -0
  42. package/dist/recipes/model.js +20 -0
  43. package/dist/recipes/storage.js +136 -0
  44. package/dist/search/content-search.js +68 -0
  45. package/dist/search/file-search.js +61 -0
  46. package/dist/search/filters.js +34 -0
  47. package/dist/search/index.js +5 -0
  48. package/dist/search/semantic.js +320 -0
  49. package/dist/session-boot.js +59 -0
  50. package/dist/session-context.js +55 -0
  51. package/dist/sessions-db.js +173 -0
  52. package/dist/smart-display.js +286 -0
  53. package/dist/snapshots.js +51 -0
  54. package/dist/supervisor.js +112 -0
  55. package/dist/test-watchlist.js +131 -0
  56. package/dist/tool-profiles.js +122 -0
  57. package/dist/tree.js +94 -0
  58. package/dist/usage-cache.js +65 -0
  59. package/package.json +8 -1
  60. package/.claude/scheduled_tasks.lock +0 -1
  61. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -20
  62. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -14
  63. package/CONTRIBUTING.md +0 -80
  64. package/benchmarks/benchmark.mjs +0 -115
  65. package/imported_modules.txt +0 -0
  66. package/tsconfig.json +0 -15
@@ -0,0 +1,275 @@
1
+ // Context hints — discover context via lightweight checks, inject into AI prompt
2
+ // Regex DISCOVERS, AI DECIDES. No hardcoded logic that makes decisions.
3
+ import { existsSync, readFileSync, readdirSync } from "fs";
4
+ import { join } from "path";
5
+ /** Discover project context from the filesystem */
6
+ export function discoverProjectHints(cwd) {
7
+ const hints = [];
8
+ // Package managers and project files
9
+ const projectFiles = [
10
+ ["package.json", "Node.js/TypeScript"],
11
+ ["pyproject.toml", "Python"],
12
+ ["requirements.txt", "Python"],
13
+ ["go.mod", "Go"],
14
+ ["Cargo.toml", "Rust"],
15
+ ["pom.xml", "Java/Maven"],
16
+ ["build.gradle", "Java/Gradle"],
17
+ ["build.gradle.kts", "Java/Gradle (Kotlin DSL)"],
18
+ ["Makefile", "Has Makefile"],
19
+ ["Dockerfile", "Has Docker"],
20
+ ["docker-compose.yml", "Has Docker Compose"],
21
+ ["docker-compose.yaml", "Has Docker Compose"],
22
+ [".github/workflows", "Has GitHub Actions CI"],
23
+ ["Gemfile", "Ruby"],
24
+ ["composer.json", "PHP"],
25
+ ["mix.exs", "Elixir"],
26
+ ["build.zig", "Zig"],
27
+ ["CMakeLists.txt", "C/C++ (CMake)"],
28
+ ];
29
+ for (const [file, lang] of projectFiles) {
30
+ if (existsSync(join(cwd, file))) {
31
+ hints.push(`Project type: ${lang} (${file} found)`);
32
+ }
33
+ }
34
+ // Extract rich metadata from package.json
35
+ const pkgPath = join(cwd, "package.json");
36
+ if (existsSync(pkgPath)) {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
39
+ if (pkg.name)
40
+ hints.push(`Package name: ${pkg.name}@${pkg.version ?? "unknown"}`);
41
+ if (pkg.scripts) {
42
+ hints.push(`Available scripts: ${Object.entries(pkg.scripts).map(([k, v]) => `${k}: ${v}`).slice(0, 10).join(", ")}`);
43
+ }
44
+ if (pkg.dependencies)
45
+ hints.push(`Dependencies: ${Object.keys(pkg.dependencies).join(", ")}`);
46
+ }
47
+ catch { }
48
+ }
49
+ // Extract from pyproject.toml
50
+ const pyPath = join(cwd, "pyproject.toml");
51
+ if (existsSync(pyPath)) {
52
+ try {
53
+ const py = readFileSync(pyPath, "utf8");
54
+ const name = py.match(/name\s*=\s*"([^"]+)"/)?.[1];
55
+ if (name)
56
+ hints.push(`Python package: ${name}`);
57
+ }
58
+ catch { }
59
+ }
60
+ // Extract from go.mod
61
+ const goPath = join(cwd, "go.mod");
62
+ if (existsSync(goPath)) {
63
+ try {
64
+ const go = readFileSync(goPath, "utf8");
65
+ const mod = go.match(/module\s+(\S+)/)?.[1];
66
+ if (mod)
67
+ hints.push(`Go module: ${mod}`);
68
+ }
69
+ catch { }
70
+ }
71
+ // Extract from Cargo.toml
72
+ const cargoPath = join(cwd, "Cargo.toml");
73
+ if (existsSync(cargoPath)) {
74
+ try {
75
+ const cargo = readFileSync(cargoPath, "utf8");
76
+ const name = cargo.match(/name\s*=\s*"([^"]+)"/)?.[1];
77
+ if (name)
78
+ hints.push(`Rust crate: ${name}`);
79
+ }
80
+ catch { }
81
+ }
82
+ // Monorepo detection
83
+ if (existsSync(join(cwd, "packages"))) {
84
+ try {
85
+ const pkgs = readdirSync(join(cwd, "packages")).filter(d => !d.startsWith("."));
86
+ hints.push(`MONOREPO: ${pkgs.length} packages in packages/ — search packages/ not src/`);
87
+ hints.push(`Packages: ${pkgs.slice(0, 10).join(", ")}`);
88
+ }
89
+ catch { }
90
+ }
91
+ if (existsSync(join(cwd, "apps"))) {
92
+ hints.push("MONOREPO: apps/ directory detected");
93
+ }
94
+ // Makefile targets
95
+ if (existsSync(join(cwd, "Makefile"))) {
96
+ try {
97
+ const { execSync } = require("child_process");
98
+ const targets = execSync("grep -E '^[a-zA-Z_-]+:' Makefile | head -10 | cut -d: -f1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
99
+ if (targets)
100
+ hints.push(`Makefile targets: ${targets.split("\n").join(", ")}`);
101
+ }
102
+ catch { }
103
+ }
104
+ // Source directory structure
105
+ try {
106
+ const { execSync } = require("child_process");
107
+ const srcDirs = ["src", "lib", "app", "packages"];
108
+ for (const dir of srcDirs) {
109
+ if (existsSync(join(cwd, dir))) {
110
+ const tree = execSync(`find ${dir} -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/dist/*' -not -name '*.test.*' 2>/dev/null | sort | head -60`, { cwd, encoding: "utf8", timeout: 3000 }).trim();
111
+ if (tree)
112
+ hints.push(`Files in ${dir}/:\n${tree}`);
113
+ break;
114
+ }
115
+ }
116
+ // Top-level files
117
+ const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
118
+ hints.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
119
+ }
120
+ catch { }
121
+ return hints;
122
+ }
123
+ /** Discover output-specific hints (observations about command output) */
124
+ export function discoverOutputHints(output, command) {
125
+ const hints = [];
126
+ const lines = output.split("\n");
127
+ hints.push(`Output: ${lines.length} lines, ${output.length} chars`);
128
+ // Only detect test results from actual test runners (not grep output containing "pass"/"fail" in code)
129
+ const isGrepOutput = /^\s*(src\/|\.\/|packages\/).*:\d+:/.test(output);
130
+ if (!isGrepOutput) {
131
+ const passMatch = output.match(/(\d+)\s+pass(?:ed|ing)?\b/i);
132
+ const failMatch = output.match(/(\d+)\s+fail(?:ed|ing|ure)?\b/i);
133
+ if (passMatch)
134
+ hints.push(`Test results detected: ${passMatch[0]}`);
135
+ if (failMatch)
136
+ hints.push(`Test results detected: ${failMatch[0]}`);
137
+ // Error patterns (only from actual command output, not code search)
138
+ if (output.match(/error\s*TS\d+/i))
139
+ hints.push("TypeScript errors detected in output");
140
+ if (output.match(/ENOENT|EACCES|EADDRINUSE/))
141
+ hints.push("System error code detected in output");
142
+ }
143
+ // Coverage patterns
144
+ if (output.match(/%\s*Funcs|%\s*Lines|coverage/i))
145
+ hints.push("Code coverage data detected in output");
146
+ // Large/repetitive output
147
+ if (lines.length > 100)
148
+ hints.push(`Large output (${lines.length} lines) — consider summarizing`);
149
+ const uniqueLines = new Set(lines.map(l => l.trim())).size;
150
+ if (uniqueLines < lines.length * 0.5)
151
+ hints.push("Output has many duplicate/similar lines");
152
+ // Sensitive data (only env var assignments, not code containing the word KEY/TOKEN)
153
+ if (output.match(/^[A-Z_]+(KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S+/m))
154
+ hints.push("Output may contain sensitive data — redact credentials");
155
+ // Error block extraction — state machine that captures multi-line errors
156
+ if (!isGrepOutput) {
157
+ const errorBlocks = extractErrorBlocks(output);
158
+ if (errorBlocks.length > 0) {
159
+ const summary = errorBlocks.slice(0, 3).map(b => b.trim().split("\n").slice(0, 5).join("\n")).join("\n---\n");
160
+ hints.push(`ERROR BLOCKS FOUND (${errorBlocks.length}):\n${summary}`);
161
+ }
162
+ }
163
+ return hints;
164
+ }
165
+ /** Extract multi-line error blocks using a state machine */
166
+ function extractErrorBlocks(output) {
167
+ const lines = output.split("\n");
168
+ const blocks = [];
169
+ let currentBlock = [];
170
+ let inErrorBlock = false;
171
+ let blankCount = 0;
172
+ // Patterns that START an error block
173
+ const errorStarters = [
174
+ /^error/i, /^Error:/i, /^ERROR/,
175
+ /^Traceback/i, /^panic:/i, /^fatal:/i,
176
+ /^FAIL/i, /^✗/, /^✘/,
177
+ /error\s*TS\d+/i, /error\[E\d+\]/,
178
+ /^SyntaxError/i, /^TypeError/i, /^ReferenceError/i,
179
+ /^Unhandled/i, /^Exception/i,
180
+ /ENOENT|EACCES|EADDRINUSE|ECONNREFUSED/,
181
+ ];
182
+ for (const line of lines) {
183
+ const trimmed = line.trim();
184
+ if (!trimmed) {
185
+ blankCount++;
186
+ if (inErrorBlock) {
187
+ currentBlock.push(line);
188
+ // 2+ blank lines = end of error block
189
+ if (blankCount >= 2) {
190
+ blocks.push(currentBlock.join("\n").trim());
191
+ currentBlock = [];
192
+ inErrorBlock = false;
193
+ }
194
+ }
195
+ continue;
196
+ }
197
+ blankCount = 0;
198
+ // Check if this line starts a new error block
199
+ if (!inErrorBlock && errorStarters.some(p => p.test(trimmed))) {
200
+ inErrorBlock = true;
201
+ currentBlock = [line];
202
+ continue;
203
+ }
204
+ if (inErrorBlock) {
205
+ // Continuation: indented lines, "at ..." stack frames, "--->" pointers, "File ..." python traces
206
+ const isContinuation = /^\s+/.test(line) ||
207
+ /^\s*at\s/.test(trimmed) ||
208
+ /^\s*-+>/.test(trimmed) ||
209
+ /^\s*\|/.test(trimmed) ||
210
+ /^\s*File "/.test(trimmed) ||
211
+ /^\s*\d+\s*\|/.test(trimmed) || // rust/compiler line numbers
212
+ /^Caused by:/i.test(trimmed);
213
+ if (isContinuation) {
214
+ currentBlock.push(line);
215
+ }
216
+ else {
217
+ // Non-continuation, non-blank = end of error block
218
+ blocks.push(currentBlock.join("\n").trim());
219
+ currentBlock = [];
220
+ inErrorBlock = false;
221
+ // Check if THIS line starts a new error block
222
+ if (errorStarters.some(p => p.test(trimmed))) {
223
+ inErrorBlock = true;
224
+ currentBlock = [line];
225
+ }
226
+ }
227
+ }
228
+ }
229
+ // Flush remaining block
230
+ if (currentBlock.length > 0) {
231
+ blocks.push(currentBlock.join("\n").trim());
232
+ }
233
+ return blocks;
234
+ }
235
+ /** Discover safety hints about a command */
236
+ export function discoverSafetyHints(command) {
237
+ const hints = [];
238
+ // Observations about the command (AI decides if it's safe)
239
+ if (command.match(/\brm\b|\brmdir\b|\btruncate\b/))
240
+ hints.push("SAFETY: command contains file deletion (rm/rmdir/truncate)");
241
+ if (command.match(/\bkill\b|\bkillall\b|\bpkill\b/))
242
+ hints.push("SAFETY: command kills processes");
243
+ if (command.match(/\bgit\s+push\b|\bgit\s+reset\s+--hard\b/))
244
+ hints.push("SAFETY: command pushes/resets git");
245
+ if (command.match(/\bnpx\b|\bnpm\s+install\b|\bpip\s+install\b/))
246
+ hints.push("SAFETY: command installs packages");
247
+ if (command.match(/\bsed\s+-i\b|\bcodemod\b/))
248
+ hints.push("SAFETY: command modifies files in-place");
249
+ if (command.match(/\btouch\b|\bmkdir\b/))
250
+ hints.push("SAFETY: command creates files/directories");
251
+ if (command.match(/>\s*\S+\.\w+/))
252
+ hints.push("SAFETY: command writes to a file via redirect");
253
+ if (command.match(/\b(bun|npm|pnpm)\s+run\s+dev\b|\bstart\b/))
254
+ hints.push("SAFETY: command starts a server/process");
255
+ // Read-only observations
256
+ if (command.match(/^\s*git\s+(log|show|diff|status|branch|blame|tag)\b/))
257
+ hints.push("This is a read-only git command");
258
+ if (command.match(/^\s*(ls|cat|head|tail|grep|find|wc|du|df|uptime|whoami|pwd)\b/))
259
+ hints.push("This is a read-only command");
260
+ return hints;
261
+ }
262
+ /** Format all hints for system prompt injection */
263
+ export function formatHints(project, output, safety) {
264
+ const sections = [];
265
+ if (project.length > 0) {
266
+ sections.push("PROJECT CONTEXT:\n" + project.join("\n"));
267
+ }
268
+ if (output && output.length > 0) {
269
+ sections.push("OUTPUT OBSERVATIONS:\n" + output.join("\n"));
270
+ }
271
+ if (safety && safety.length > 0) {
272
+ sections.push("SAFETY OBSERVATIONS:\n" + safety.join("\n"));
273
+ }
274
+ return sections.join("\n\n");
275
+ }
@@ -0,0 +1,107 @@
1
+ // Diff-aware output caching — when same command runs again, return only what changed
2
+ import { estimateTokens } from "./parsers/index.js";
3
+ const cache = new Map();
4
+ function cacheKey(command, cwd) {
5
+ return `${cwd}:${command}`;
6
+ }
7
+ /** Compute a simple line diff between two outputs */
8
+ function lineDiff(prev, curr) {
9
+ const prevLines = new Set(prev.split("\n"));
10
+ const currLines = curr.split("\n");
11
+ const added = [];
12
+ const removed = [];
13
+ let unchanged = 0;
14
+ for (const line of currLines) {
15
+ if (prevLines.has(line)) {
16
+ unchanged++;
17
+ prevLines.delete(line);
18
+ }
19
+ else {
20
+ added.push(line);
21
+ }
22
+ }
23
+ for (const line of prevLines) {
24
+ removed.push(line);
25
+ }
26
+ return { added, removed, unchanged };
27
+ }
28
+ /** Generate a human-readable diff summary */
29
+ function summarizeDiff(diff) {
30
+ const parts = [];
31
+ if (diff.added.length > 0)
32
+ parts.push(`+${diff.added.length} new lines`);
33
+ if (diff.removed.length > 0)
34
+ parts.push(`-${diff.removed.length} removed lines`);
35
+ parts.push(`${diff.unchanged} unchanged`);
36
+ return parts.join(", ");
37
+ }
38
+ /** Run diffing on command output. Caches the output for next comparison. */
39
+ export function diffOutput(command, cwd, output) {
40
+ const key = cacheKey(command, cwd);
41
+ const prev = cache.get(key);
42
+ // Store current for next time
43
+ cache.set(key, { command, cwd, output, timestamp: Date.now() });
44
+ if (!prev) {
45
+ return {
46
+ full: output,
47
+ hasPrevious: false,
48
+ added: [],
49
+ removed: [],
50
+ diffSummary: "first run",
51
+ unchanged: false,
52
+ tokensSaved: 0,
53
+ };
54
+ }
55
+ if (prev.output === output) {
56
+ const fullTokens = estimateTokens(output);
57
+ return {
58
+ full: output,
59
+ hasPrevious: true,
60
+ added: [],
61
+ removed: [],
62
+ diffSummary: "identical to previous run",
63
+ unchanged: true,
64
+ tokensSaved: fullTokens - 10, // ~10 tokens for the "unchanged" message
65
+ };
66
+ }
67
+ const diff = lineDiff(prev.output, output);
68
+ const total = diff.added.length + diff.removed.length + diff.unchanged;
69
+ const similarity = total > 0 ? diff.unchanged / total : 0;
70
+ // Fuzzy threshold: if >80% similar, return diff-only (massive token savings)
71
+ const fullTokens = estimateTokens(output);
72
+ if (similarity > 0.8 && diff.added.length + diff.removed.length > 0) {
73
+ const diffContent = [
74
+ ...diff.added.map(l => `+ ${l}`),
75
+ ...diff.removed.map(l => `- ${l}`),
76
+ ].join("\n");
77
+ const diffTokens = estimateTokens(diffContent);
78
+ return {
79
+ full: output,
80
+ hasPrevious: true,
81
+ added: diff.added,
82
+ removed: diff.removed,
83
+ diffSummary: `${Math.round(similarity * 100)}% similar — ${summarizeDiff(diff)}`,
84
+ unchanged: false,
85
+ tokensSaved: Math.max(0, fullTokens - diffTokens),
86
+ };
87
+ }
88
+ // Less than 80% similar — return full output with diff info
89
+ const diffContent = [
90
+ ...diff.added.map(l => `+ ${l}`),
91
+ ...diff.removed.map(l => `- ${l}`),
92
+ ].join("\n");
93
+ const diffTokens = estimateTokens(diffContent);
94
+ return {
95
+ full: output,
96
+ hasPrevious: true,
97
+ added: diff.added,
98
+ removed: diff.removed,
99
+ diffSummary: summarizeDiff(diff),
100
+ unchanged: false,
101
+ tokensSaved: Math.max(0, fullTokens - diffTokens),
102
+ };
103
+ }
104
+ /** Clear the diff cache */
105
+ export function clearDiffCache() {
106
+ cache.clear();
107
+ }
@@ -0,0 +1,212 @@
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
+ import { readdirSync, readFileSync, statSync, existsSync } from "fs";
5
+ import { join } from "path";
6
+ import { estimateTokens } from "./parsers/index.js";
7
+ /** Find all Claude session JSONL files */
8
+ function findSessionFiles(claudeDir, maxAge) {
9
+ const files = [];
10
+ const projectsDir = join(claudeDir, "projects");
11
+ if (!existsSync(projectsDir))
12
+ return files;
13
+ const now = Date.now();
14
+ const cutoff = maxAge ? now - maxAge : 0;
15
+ try {
16
+ for (const project of readdirSync(projectsDir)) {
17
+ const projectPath = join(projectsDir, project);
18
+ // Look for session JSONL files (not subagents)
19
+ try {
20
+ for (const entry of readdirSync(projectPath)) {
21
+ if (entry.endsWith(".jsonl")) {
22
+ const filePath = join(projectPath, entry);
23
+ try {
24
+ const stat = statSync(filePath);
25
+ if (stat.mtimeMs > cutoff)
26
+ files.push(filePath);
27
+ }
28
+ catch { }
29
+ }
30
+ }
31
+ }
32
+ catch { }
33
+ }
34
+ }
35
+ catch { }
36
+ return files;
37
+ }
38
+ /** Extract Bash commands and their output sizes from a session file */
39
+ function extractCommands(sessionFile) {
40
+ const commands = [];
41
+ try {
42
+ const content = readFileSync(sessionFile, "utf8");
43
+ const lines = content.split("\n").filter(l => l.trim());
44
+ // Track tool_use IDs to match with tool_results
45
+ const pendingToolUses = new Map(); // id -> command
46
+ for (const line of lines) {
47
+ try {
48
+ const obj = JSON.parse(line);
49
+ const msg = obj.message;
50
+ if (!msg?.content || !Array.isArray(msg.content))
51
+ continue;
52
+ for (const block of msg.content) {
53
+ // Capture Bash tool_use commands
54
+ if (block.type === "tool_use" && block.name === "Bash" && block.input?.command) {
55
+ pendingToolUses.set(block.id, block.input.command);
56
+ }
57
+ // Capture tool_result outputs and match to commands
58
+ if (block.type === "tool_result" && block.tool_use_id) {
59
+ const command = pendingToolUses.get(block.tool_use_id);
60
+ if (command) {
61
+ let outputText = "";
62
+ if (typeof block.content === "string") {
63
+ outputText = block.content;
64
+ }
65
+ else if (Array.isArray(block.content)) {
66
+ outputText = block.content
67
+ .filter((c) => c.type === "text")
68
+ .map((c) => c.text)
69
+ .join("\n");
70
+ }
71
+ if (outputText.length > 0) {
72
+ commands.push({
73
+ command,
74
+ outputTokens: estimateTokens(outputText),
75
+ outputChars: outputText.length,
76
+ sessionFile,
77
+ });
78
+ }
79
+ pendingToolUses.delete(block.tool_use_id);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ catch { } // skip malformed lines
85
+ }
86
+ }
87
+ catch { } // skip unreadable files
88
+ return commands;
89
+ }
90
+ /** Categorize a command into a bucket */
91
+ function categorizeCommand(cmd) {
92
+ const trimmed = cmd.trim();
93
+ if (/^git\b/.test(trimmed))
94
+ return "git";
95
+ if (/\b(bun|npm|yarn|pnpm)\s+(test|run\s+test)/.test(trimmed))
96
+ return "test";
97
+ if (/\b(bun|npm|yarn|pnpm)\s+run\s+(build|typecheck|lint)/.test(trimmed))
98
+ return "build";
99
+ if (/^(grep|rg)\b/.test(trimmed))
100
+ return "grep";
101
+ if (/^find\b/.test(trimmed))
102
+ return "find";
103
+ if (/^(cat|head|tail|less)\b/.test(trimmed))
104
+ return "read";
105
+ if (/^(ls|tree|du|wc)\b/.test(trimmed))
106
+ return "list";
107
+ if (/^(curl|wget|fetch)\b/.test(trimmed))
108
+ return "network";
109
+ if (/^(docker|kubectl|helm)\b/.test(trimmed))
110
+ return "infra";
111
+ if (/^(python|pip|pytest)\b/.test(trimmed))
112
+ return "python";
113
+ if (/^(cargo|rustc)\b/.test(trimmed))
114
+ return "rust";
115
+ if (/^(go\s|golangci)\b/.test(trimmed))
116
+ return "go";
117
+ return "other";
118
+ }
119
+ /** Normalize command for grouping (strip variable parts like paths, hashes) */
120
+ function normalizeCommand(cmd) {
121
+ return cmd
122
+ .replace(/[0-9a-f]{7,40}/g, "{hash}") // git hashes
123
+ .replace(/\/[\w./-]+\.(ts|tsx|js|json|py|rs|go)\b/g, "{file}") // file paths
124
+ .replace(/\d{4}-\d{2}-\d{2}/g, "{date}") // dates
125
+ .replace(/:\d+/g, ":{line}") // line numbers
126
+ .trim();
127
+ }
128
+ /** Run discover across all Claude sessions */
129
+ export function discover(options = {}) {
130
+ const claudeDir = join(process.env.HOME ?? "~", ".claude");
131
+ const maxAge = (options.maxAgeDays ?? 30) * 24 * 60 * 60 * 1000;
132
+ const minTokens = options.minTokens ?? 50;
133
+ const sessionFiles = findSessionFiles(claudeDir, maxAge);
134
+ const allCommands = [];
135
+ for (const file of sessionFiles) {
136
+ allCommands.push(...extractCommands(file));
137
+ }
138
+ // Filter to commands with meaningful output
139
+ const significant = allCommands.filter(c => c.outputTokens >= minTokens);
140
+ // Group by normalized command
141
+ const groups = new Map();
142
+ for (const cmd of significant) {
143
+ const key = normalizeCommand(cmd.command);
144
+ const existing = groups.get(key) ?? { count: 0, totalTokens: 0, example: cmd.command };
145
+ existing.count++;
146
+ existing.totalTokens += cmd.outputTokens;
147
+ groups.set(key, existing);
148
+ }
149
+ // Top commands by total tokens
150
+ const topCommands = [...groups.entries()]
151
+ .map(([cmd, data]) => ({
152
+ command: data.example,
153
+ count: data.count,
154
+ totalTokens: data.totalTokens,
155
+ avgTokens: Math.round(data.totalTokens / data.count),
156
+ }))
157
+ .sort((a, b) => b.totalTokens - a.totalTokens)
158
+ .slice(0, 20);
159
+ // Category breakdown
160
+ const commandsByCategory = {};
161
+ for (const cmd of significant) {
162
+ const cat = categorizeCommand(cmd.command);
163
+ if (!commandsByCategory[cat])
164
+ commandsByCategory[cat] = { count: 0, tokens: 0 };
165
+ commandsByCategory[cat].count++;
166
+ commandsByCategory[cat].tokens += cmd.outputTokens;
167
+ }
168
+ const totalOutputTokens = significant.reduce((sum, c) => sum + c.outputTokens, 0);
169
+ // Conservative 70% compression estimate (RTK claims 60-90%)
170
+ const estimatedSavings = Math.round(totalOutputTokens * 0.7);
171
+ // Each saved input token is repeated across ~5 turns on average before compaction
172
+ const multipliedSavings = estimatedSavings * 5;
173
+ // At Opus rates ($5/M input tokens)
174
+ const estimatedSavingsUsd = (multipliedSavings * 5) / 1_000_000;
175
+ return {
176
+ totalSessions: sessionFiles.length,
177
+ totalCommands: significant.length,
178
+ totalOutputTokens,
179
+ estimatedSavings,
180
+ estimatedSavingsUsd,
181
+ topCommands,
182
+ commandsByCategory,
183
+ };
184
+ }
185
+ /** Format discover report for CLI display */
186
+ export function formatDiscoverReport(report) {
187
+ const lines = [];
188
+ lines.push(`📊 Terminal Discover — Token Savings Analysis`);
189
+ lines.push(` Scanned ${report.totalSessions} sessions, ${report.totalCommands} commands with >50 token output\n`);
190
+ lines.push(`💰 Estimated savings with open-terminal:`);
191
+ lines.push(` Output tokens: ${report.totalOutputTokens.toLocaleString()}`);
192
+ lines.push(` Compressible: ${report.estimatedSavings.toLocaleString()} tokens (70% avg)`);
193
+ lines.push(` Repeated ~5x before compaction = ${(report.estimatedSavings * 5).toLocaleString()} billable tokens`);
194
+ lines.push(` At Opus rates: $${report.estimatedSavingsUsd.toFixed(2)} saved\n`);
195
+ if (report.topCommands.length > 0) {
196
+ lines.push(`🔝 Top commands by token cost:`);
197
+ for (const cmd of report.topCommands.slice(0, 15)) {
198
+ const avg = cmd.avgTokens.toLocaleString().padStart(6);
199
+ const total = cmd.totalTokens.toLocaleString().padStart(8);
200
+ lines.push(` ${String(cmd.count).padStart(4)}× ${avg} avg → ${total} total ${cmd.command.slice(0, 60)}`);
201
+ }
202
+ lines.push("");
203
+ }
204
+ if (Object.keys(report.commandsByCategory).length > 0) {
205
+ lines.push(`📁 By category:`);
206
+ const sorted = Object.entries(report.commandsByCategory).sort((a, b) => b[1].tokens - a[1].tokens);
207
+ for (const [cat, data] of sorted) {
208
+ lines.push(` ${cat.padEnd(10)} ${String(data.count).padStart(5)} cmds ${data.tokens.toLocaleString().padStart(10)} tokens`);
209
+ }
210
+ }
211
+ return lines.join("\n");
212
+ }