@hasna/terminal 2.3.1 → 3.0.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.
Files changed (99) 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 +296 -0
  8. package/dist/cache.js +42 -0
  9. package/dist/cli.js +1 -1
  10. package/dist/command-rewriter.js +64 -0
  11. package/dist/command-validator.js +86 -0
  12. package/dist/compression.js +85 -0
  13. package/dist/context-hints.js +285 -0
  14. package/dist/diff-cache.js +107 -0
  15. package/dist/discover.js +212 -0
  16. package/dist/economy.js +155 -0
  17. package/dist/expand-store.js +44 -0
  18. package/dist/file-cache.js +72 -0
  19. package/dist/file-index.js +62 -0
  20. package/dist/history.js +62 -0
  21. package/dist/lazy-executor.js +54 -0
  22. package/dist/line-dedup.js +59 -0
  23. package/dist/loop-detector.js +75 -0
  24. package/dist/mcp/install.js +98 -0
  25. package/dist/mcp/server.js +545 -0
  26. package/dist/noise-filter.js +86 -0
  27. package/dist/output-processor.js +132 -0
  28. package/dist/output-router.js +41 -0
  29. package/dist/output-store.js +111 -0
  30. package/dist/parsers/base.js +2 -0
  31. package/dist/parsers/build.js +64 -0
  32. package/dist/parsers/errors.js +101 -0
  33. package/dist/parsers/files.js +78 -0
  34. package/dist/parsers/git.js +99 -0
  35. package/dist/parsers/index.js +48 -0
  36. package/dist/parsers/tests.js +89 -0
  37. package/dist/providers/anthropic.js +43 -0
  38. package/dist/providers/base.js +4 -0
  39. package/dist/providers/cerebras.js +8 -0
  40. package/dist/providers/groq.js +8 -0
  41. package/dist/providers/index.js +122 -0
  42. package/dist/providers/openai-compat.js +93 -0
  43. package/dist/providers/xai.js +8 -0
  44. package/dist/recipes/model.js +20 -0
  45. package/dist/recipes/storage.js +136 -0
  46. package/dist/search/content-search.js +68 -0
  47. package/dist/search/file-search.js +61 -0
  48. package/dist/search/filters.js +34 -0
  49. package/dist/search/index.js +5 -0
  50. package/dist/search/semantic.js +320 -0
  51. package/dist/session-boot.js +59 -0
  52. package/dist/session-context.js +55 -0
  53. package/dist/sessions-db.js +173 -0
  54. package/dist/smart-display.js +286 -0
  55. package/dist/snapshots.js +51 -0
  56. package/dist/supervisor.js +112 -0
  57. package/dist/test-watchlist.js +131 -0
  58. package/dist/tokens.js +17 -0
  59. package/dist/tool-profiles.js +129 -0
  60. package/dist/tree.js +94 -0
  61. package/dist/usage-cache.js +65 -0
  62. package/package.json +8 -1
  63. package/src/ai.ts +60 -90
  64. package/src/cache.ts +3 -2
  65. package/src/cli.tsx +1 -1
  66. package/src/compression.ts +8 -35
  67. package/src/context-hints.ts +20 -10
  68. package/src/diff-cache.ts +1 -1
  69. package/src/discover.ts +1 -1
  70. package/src/economy.ts +37 -5
  71. package/src/expand-store.ts +8 -1
  72. package/src/mcp/server.ts +45 -73
  73. package/src/output-processor.ts +11 -8
  74. package/src/providers/anthropic.ts +6 -2
  75. package/src/providers/base.ts +2 -0
  76. package/src/providers/cerebras.ts +6 -105
  77. package/src/providers/groq.ts +6 -105
  78. package/src/providers/index.ts +84 -33
  79. package/src/providers/openai-compat.ts +109 -0
  80. package/src/providers/xai.ts +6 -105
  81. package/src/tokens.ts +18 -0
  82. package/src/tool-profiles.ts +9 -2
  83. package/.claude/scheduled_tasks.lock +0 -1
  84. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -20
  85. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -14
  86. package/CONTRIBUTING.md +0 -80
  87. package/benchmarks/benchmark.mjs +0 -115
  88. package/imported_modules.txt +0 -0
  89. package/src/compression.test.ts +0 -49
  90. package/src/output-router.ts +0 -56
  91. package/src/parsers/base.ts +0 -72
  92. package/src/parsers/build.ts +0 -73
  93. package/src/parsers/errors.ts +0 -107
  94. package/src/parsers/files.ts +0 -91
  95. package/src/parsers/git.ts +0 -101
  96. package/src/parsers/index.ts +0 -66
  97. package/src/parsers/parsers.test.ts +0 -153
  98. package/src/parsers/tests.ts +0 -98
  99. package/tsconfig.json +0 -15
@@ -0,0 +1,285 @@
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 metadata from package.json — trimmed to save tokens
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: ${pkg.name}@${pkg.version ?? "?"}`);
41
+ if (pkg.scripts) {
42
+ // Only top-5 most useful scripts
43
+ const priority = ["dev", "build", "test", "lint", "start", "typecheck", "check"];
44
+ const scripts = Object.keys(pkg.scripts);
45
+ const top = priority.filter(s => scripts.includes(s));
46
+ const rest = scripts.filter(s => !priority.includes(s)).slice(0, Math.max(0, 5 - top.length));
47
+ hints.push(`Scripts: ${[...top, ...rest].join(", ")}`);
48
+ }
49
+ if (pkg.dependencies) {
50
+ // Only framework/major deps — skip utility libs
51
+ const major = ["react", "next", "express", "fastify", "hono", "vue", "angular", "svelte",
52
+ "prisma", "drizzle", "mongoose", "typeorm", "zod", "trpc", "graphql", "tailwindcss",
53
+ "electron", "bun", "elysia", "nest", "nuxt", "remix", "astro", "vite"];
54
+ const deps = Object.keys(pkg.dependencies);
55
+ const found = deps.filter(d => major.some(m => d.includes(m)));
56
+ if (found.length > 0)
57
+ hints.push(`Key deps: ${found.slice(0, 10).join(", ")}`);
58
+ }
59
+ }
60
+ catch { }
61
+ }
62
+ // Extract from pyproject.toml
63
+ const pyPath = join(cwd, "pyproject.toml");
64
+ if (existsSync(pyPath)) {
65
+ try {
66
+ const py = readFileSync(pyPath, "utf8");
67
+ const name = py.match(/name\s*=\s*"([^"]+)"/)?.[1];
68
+ if (name)
69
+ hints.push(`Python package: ${name}`);
70
+ }
71
+ catch { }
72
+ }
73
+ // Extract from go.mod
74
+ const goPath = join(cwd, "go.mod");
75
+ if (existsSync(goPath)) {
76
+ try {
77
+ const go = readFileSync(goPath, "utf8");
78
+ const mod = go.match(/module\s+(\S+)/)?.[1];
79
+ if (mod)
80
+ hints.push(`Go module: ${mod}`);
81
+ }
82
+ catch { }
83
+ }
84
+ // Extract from Cargo.toml
85
+ const cargoPath = join(cwd, "Cargo.toml");
86
+ if (existsSync(cargoPath)) {
87
+ try {
88
+ const cargo = readFileSync(cargoPath, "utf8");
89
+ const name = cargo.match(/name\s*=\s*"([^"]+)"/)?.[1];
90
+ if (name)
91
+ hints.push(`Rust crate: ${name}`);
92
+ }
93
+ catch { }
94
+ }
95
+ // Monorepo detection
96
+ if (existsSync(join(cwd, "packages"))) {
97
+ try {
98
+ const pkgs = readdirSync(join(cwd, "packages")).filter(d => !d.startsWith("."));
99
+ hints.push(`MONOREPO: ${pkgs.length} packages in packages/ — search packages/ not src/`);
100
+ hints.push(`Packages: ${pkgs.slice(0, 10).join(", ")}`);
101
+ }
102
+ catch { }
103
+ }
104
+ if (existsSync(join(cwd, "apps"))) {
105
+ hints.push("MONOREPO: apps/ directory detected");
106
+ }
107
+ // Makefile targets
108
+ if (existsSync(join(cwd, "Makefile"))) {
109
+ try {
110
+ const { execSync } = require("child_process");
111
+ const targets = execSync("grep -E '^[a-zA-Z_-]+:' Makefile | head -10 | cut -d: -f1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
112
+ if (targets)
113
+ hints.push(`Makefile targets: ${targets.split("\n").join(", ")}`);
114
+ }
115
+ catch { }
116
+ }
117
+ // Source directory structure — max 20 files to save tokens
118
+ try {
119
+ const { execSync } = require("child_process");
120
+ const srcDirs = ["src", "lib", "app", "packages"];
121
+ for (const dir of srcDirs) {
122
+ if (existsSync(join(cwd, dir))) {
123
+ const tree = execSync(`find ${dir} -maxdepth 2 -not -path '*/node_modules/*' -not -path '*/dist/*' -not -name '*.test.*' -not -name '*.spec.*' 2>/dev/null | sort | head -20`, { cwd, encoding: "utf8", timeout: 2000 }).trim();
124
+ if (tree)
125
+ hints.push(`Files in ${dir}/:\n${tree}`);
126
+ break;
127
+ }
128
+ }
129
+ }
130
+ catch { }
131
+ return hints;
132
+ }
133
+ /** Discover output-specific hints (observations about command output) */
134
+ export function discoverOutputHints(output, command) {
135
+ const hints = [];
136
+ const lines = output.split("\n");
137
+ hints.push(`Output: ${lines.length} lines, ${output.length} chars`);
138
+ // Only detect test results from actual test runners (not grep output containing "pass"/"fail" in code)
139
+ const isGrepOutput = /^\s*(src\/|\.\/|packages\/).*:\d+:/.test(output);
140
+ if (!isGrepOutput) {
141
+ const passMatch = output.match(/(\d+)\s+pass(?:ed|ing)?\b/i);
142
+ const failMatch = output.match(/(\d+)\s+fail(?:ed|ing|ure)?\b/i);
143
+ if (passMatch)
144
+ hints.push(`Test results detected: ${passMatch[0]}`);
145
+ if (failMatch)
146
+ hints.push(`Test results detected: ${failMatch[0]}`);
147
+ // Error patterns (only from actual command output, not code search)
148
+ if (output.match(/error\s*TS\d+/i))
149
+ hints.push("TypeScript errors detected in output");
150
+ if (output.match(/ENOENT|EACCES|EADDRINUSE/))
151
+ hints.push("System error code detected in output");
152
+ }
153
+ // Coverage patterns
154
+ if (output.match(/%\s*Funcs|%\s*Lines|coverage/i))
155
+ hints.push("Code coverage data detected in output");
156
+ // Large/repetitive output
157
+ if (lines.length > 100)
158
+ hints.push(`Large output (${lines.length} lines) — consider summarizing`);
159
+ const uniqueLines = new Set(lines.map(l => l.trim())).size;
160
+ if (uniqueLines < lines.length * 0.5)
161
+ hints.push("Output has many duplicate/similar lines");
162
+ // Sensitive data (only env var assignments, not code containing the word KEY/TOKEN)
163
+ if (output.match(/^[A-Z_]+(KEY|TOKEN|SECRET|PASSWORD)\s*=\s*\S+/m))
164
+ hints.push("Output may contain sensitive data — redact credentials");
165
+ // Error block extraction — state machine that captures multi-line errors
166
+ if (!isGrepOutput) {
167
+ const errorBlocks = extractErrorBlocks(output);
168
+ if (errorBlocks.length > 0) {
169
+ const summary = errorBlocks.slice(0, 3).map(b => b.trim().split("\n").slice(0, 5).join("\n")).join("\n---\n");
170
+ hints.push(`ERROR BLOCKS FOUND (${errorBlocks.length}):\n${summary}`);
171
+ }
172
+ }
173
+ return hints;
174
+ }
175
+ /** Extract multi-line error blocks using a state machine */
176
+ function extractErrorBlocks(output) {
177
+ const lines = output.split("\n");
178
+ const blocks = [];
179
+ let currentBlock = [];
180
+ let inErrorBlock = false;
181
+ let blankCount = 0;
182
+ // Patterns that START an error block
183
+ const errorStarters = [
184
+ /^error/i, /^Error:/i, /^ERROR/,
185
+ /^Traceback/i, /^panic:/i, /^fatal:/i,
186
+ /^FAIL/i, /^✗/, /^✘/,
187
+ /error\s*TS\d+/i, /error\[E\d+\]/,
188
+ /^SyntaxError/i, /^TypeError/i, /^ReferenceError/i,
189
+ /^Unhandled/i, /^Exception/i,
190
+ /ENOENT|EACCES|EADDRINUSE|ECONNREFUSED/,
191
+ ];
192
+ for (const line of lines) {
193
+ const trimmed = line.trim();
194
+ if (!trimmed) {
195
+ blankCount++;
196
+ if (inErrorBlock) {
197
+ currentBlock.push(line);
198
+ // 2+ blank lines = end of error block
199
+ if (blankCount >= 2) {
200
+ blocks.push(currentBlock.join("\n").trim());
201
+ currentBlock = [];
202
+ inErrorBlock = false;
203
+ }
204
+ }
205
+ continue;
206
+ }
207
+ blankCount = 0;
208
+ // Check if this line starts a new error block
209
+ if (!inErrorBlock && errorStarters.some(p => p.test(trimmed))) {
210
+ inErrorBlock = true;
211
+ currentBlock = [line];
212
+ continue;
213
+ }
214
+ if (inErrorBlock) {
215
+ // Continuation: indented lines, "at ..." stack frames, "--->" pointers, "File ..." python traces
216
+ const isContinuation = /^\s+/.test(line) ||
217
+ /^\s*at\s/.test(trimmed) ||
218
+ /^\s*-+>/.test(trimmed) ||
219
+ /^\s*\|/.test(trimmed) ||
220
+ /^\s*File "/.test(trimmed) ||
221
+ /^\s*\d+\s*\|/.test(trimmed) || // rust/compiler line numbers
222
+ /^Caused by:/i.test(trimmed);
223
+ if (isContinuation) {
224
+ currentBlock.push(line);
225
+ }
226
+ else {
227
+ // Non-continuation, non-blank = end of error block
228
+ blocks.push(currentBlock.join("\n").trim());
229
+ currentBlock = [];
230
+ inErrorBlock = false;
231
+ // Check if THIS line starts a new error block
232
+ if (errorStarters.some(p => p.test(trimmed))) {
233
+ inErrorBlock = true;
234
+ currentBlock = [line];
235
+ }
236
+ }
237
+ }
238
+ }
239
+ // Flush remaining block
240
+ if (currentBlock.length > 0) {
241
+ blocks.push(currentBlock.join("\n").trim());
242
+ }
243
+ return blocks;
244
+ }
245
+ /** Discover safety hints about a command */
246
+ export function discoverSafetyHints(command) {
247
+ const hints = [];
248
+ // Observations about the command (AI decides if it's safe)
249
+ if (command.match(/\brm\b|\brmdir\b|\btruncate\b/))
250
+ hints.push("SAFETY: command contains file deletion (rm/rmdir/truncate)");
251
+ if (command.match(/\bkill\b|\bkillall\b|\bpkill\b/))
252
+ hints.push("SAFETY: command kills processes");
253
+ if (command.match(/\bgit\s+push\b|\bgit\s+reset\s+--hard\b/))
254
+ hints.push("SAFETY: command pushes/resets git");
255
+ if (command.match(/\bnpx\b|\bnpm\s+install\b|\bpip\s+install\b/))
256
+ hints.push("SAFETY: command installs packages");
257
+ if (command.match(/\bsed\s+-i\b|\bcodemod\b/))
258
+ hints.push("SAFETY: command modifies files in-place");
259
+ if (command.match(/\btouch\b|\bmkdir\b/))
260
+ hints.push("SAFETY: command creates files/directories");
261
+ if (command.match(/>\s*\S+\.\w+/))
262
+ hints.push("SAFETY: command writes to a file via redirect");
263
+ if (command.match(/\b(bun|npm|pnpm)\s+run\s+dev\b|\bstart\b/))
264
+ hints.push("SAFETY: command starts a server/process");
265
+ // Read-only observations
266
+ if (command.match(/^\s*git\s+(log|show|diff|status|branch|blame|tag)\b/))
267
+ hints.push("This is a read-only git command");
268
+ if (command.match(/^\s*(ls|cat|head|tail|grep|find|wc|du|df|uptime|whoami|pwd)\b/))
269
+ hints.push("This is a read-only command");
270
+ return hints;
271
+ }
272
+ /** Format all hints for system prompt injection */
273
+ export function formatHints(project, output, safety) {
274
+ const sections = [];
275
+ if (project.length > 0) {
276
+ sections.push("PROJECT CONTEXT:\n" + project.join("\n"));
277
+ }
278
+ if (output && output.length > 0) {
279
+ sections.push("OUTPUT OBSERVATIONS:\n" + output.join("\n"));
280
+ }
281
+ if (safety && safety.length > 0) {
282
+ sections.push("SAFETY OBSERVATIONS:\n" + safety.join("\n"));
283
+ }
284
+ return sections.join("\n\n");
285
+ }
@@ -0,0 +1,107 @@
1
+ // Diff-aware output caching — when same command runs again, return only what changed
2
+ import { estimateTokens } from "./tokens.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 "./tokens.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
+ }