@hasna/terminal 2.3.2 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/ai.js +78 -85
  2. package/dist/cache.js +3 -2
  3. package/dist/cli.js +1 -1
  4. package/dist/compression.js +8 -30
  5. package/dist/context-hints.js +20 -10
  6. package/dist/diff-cache.js +1 -1
  7. package/dist/discover.js +1 -1
  8. package/dist/economy.js +37 -5
  9. package/dist/expand-store.js +7 -1
  10. package/dist/mcp/server.js +44 -68
  11. package/dist/output-processor.js +10 -7
  12. package/dist/providers/anthropic.js +6 -2
  13. package/dist/providers/cerebras.js +6 -93
  14. package/dist/providers/groq.js +6 -93
  15. package/dist/providers/index.js +85 -36
  16. package/dist/providers/openai-compat.js +93 -0
  17. package/dist/providers/xai.js +6 -93
  18. package/dist/tokens.js +17 -0
  19. package/dist/tool-profiles.js +9 -2
  20. package/package.json +1 -1
  21. package/src/ai.ts +83 -94
  22. package/src/cache.ts +3 -2
  23. package/src/cli.tsx +1 -1
  24. package/src/compression.ts +8 -35
  25. package/src/context-hints.ts +20 -10
  26. package/src/diff-cache.ts +1 -1
  27. package/src/discover.ts +1 -1
  28. package/src/economy.ts +37 -5
  29. package/src/expand-store.ts +8 -1
  30. package/src/mcp/server.ts +45 -73
  31. package/src/output-processor.ts +11 -8
  32. package/src/providers/anthropic.ts +6 -2
  33. package/src/providers/base.ts +2 -0
  34. package/src/providers/cerebras.ts +6 -105
  35. package/src/providers/groq.ts +6 -105
  36. package/src/providers/index.ts +84 -33
  37. package/src/providers/openai-compat.ts +109 -0
  38. package/src/providers/xai.ts +6 -105
  39. package/src/tokens.ts +18 -0
  40. package/src/tool-profiles.ts +9 -2
  41. package/src/compression.test.ts +0 -49
  42. package/src/output-router.ts +0 -56
  43. package/src/parsers/base.ts +0 -72
  44. package/src/parsers/build.ts +0 -73
  45. package/src/parsers/errors.ts +0 -107
  46. package/src/parsers/files.ts +0 -91
  47. package/src/parsers/git.ts +0 -101
  48. package/src/parsers/index.ts +0 -66
  49. package/src/parsers/parsers.test.ts +0 -153
  50. package/src/parsers/tests.ts +0 -98
package/dist/ai.js CHANGED
@@ -21,17 +21,26 @@ const MODEL_DEFAULTS = {
21
21
  xai: { fast: "grok-code-fast-1", smart: "grok-4-fast-non-reasoning" },
22
22
  anthropic: { fast: "claude-haiku-4-5-20251001", smart: "claude-sonnet-4-6" },
23
23
  };
24
- /** Load user model overrides from ~/.terminal/config.json */
24
+ /** Load user model overrides from ~/.terminal/config.json (cached 30s) */
25
+ let _modelOverrides = null;
26
+ let _modelOverridesAt = 0;
25
27
  function loadModelOverrides() {
28
+ const now = Date.now();
29
+ if (_modelOverrides && now - _modelOverridesAt < 30_000)
30
+ return _modelOverrides;
26
31
  try {
27
32
  const configPath = join(process.env.HOME ?? "~", ".terminal", "config.json");
28
33
  if (existsSync(configPath)) {
29
34
  const config = JSON.parse(readFileSync(configPath, "utf8"));
30
- return config.models ?? {};
35
+ _modelOverrides = config.models ?? {};
36
+ _modelOverridesAt = now;
37
+ return _modelOverrides;
31
38
  }
32
39
  }
33
40
  catch { }
34
- return {};
41
+ _modelOverrides = {};
42
+ _modelOverridesAt = now;
43
+ return _modelOverrides;
35
44
  }
36
45
  /** Model routing per provider — config-driven with defaults */
37
46
  function pickModel(nl) {
@@ -122,6 +131,7 @@ function detectProjectContext() {
122
131
  }
123
132
  // ── system prompt ─────────────────────────────────────────────────────────────
124
133
  function buildSystemPrompt(perms, sessionEntries, currentPrompt) {
134
+ const nl = currentPrompt?.toLowerCase() ?? "";
125
135
  const restrictions = [];
126
136
  if (!perms.destructive)
127
137
  restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
@@ -150,7 +160,6 @@ function buildSystemPrompt(perms, sessionEntries, currentPrompt) {
150
160
  contextBlock = `\n\nSESSION HISTORY (user intent > command $ output):\n${lines.join("\n")}`;
151
161
  }
152
162
  const projectContext = detectProjectContext();
153
- // Inject safety hints for the command being generated (AI sees what's risky)
154
163
  const safetyBlock = sessionEntries.length > 0
155
164
  ? (() => {
156
165
  const lastCmd = sessionEntries[sessionEntries.length - 1]?.cmd;
@@ -161,72 +170,50 @@ function buildSystemPrompt(perms, sessionEntries, currentPrompt) {
161
170
  return "";
162
171
  })()
163
172
  : "";
164
- return `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
165
- The user describes what they want in plain English. You translate to the exact shell command.
173
+ // ── Conditional sections (only included when relevant) ──
174
+ const wantsStructure = /\b(function|class|interface|export|symbol|structure|hierarchy|outline)\b/i.test(nl);
175
+ const astBlock = wantsStructure ? `\nAST-POWERED QUERIES: For code STRUCTURE questions, use "terminal symbols" instead of grep. It uses AST parsing for TypeScript, Python, Go, Rust.` : "";
176
+ const wantsMultiple = /\b(and|both|also|plus|as well)\b/i.test(nl);
177
+ const compoundBlock = wantsMultiple ? `\nCOMPOUND QUESTIONS: Prefer ONE command that captures all info. NEVER split into separate expensive commands.` : "";
178
+ const wantsAnalysis = /\b(quality|lint|coverage|complexity|unused|dead code|security|audit|scan|dependency)\b/i.test(nl);
179
+ const blockedAltBlock = wantsAnalysis ? `\nBLOCKED ALTERNATIVES: If your preferred command needs installing packages, try READ-ONLY alternatives (grep, cat, wc, awk). NEVER give up on analysis questions.` : "";
180
+ return `Translate to bash. One command. Simplest form. No explanation.
166
181
 
167
- RULES:
168
- - SIMPLICITY FIRST: Use the simplest command that works. Prefer grep | sort | head over 10-pipe chains. Complex pipelines are OK when needed, but NEVER pass file:line output to wc or xargs without cleaning it first.
169
- - ALWAYS use grep -rn (with -r) when searching directories. NEVER use grep without -r on src/ or any directory.
170
- - When user refers to items from previous output, use the EXACT names shown (e.g., "feature/auth" not "auth", "open-skills" not "open_skills")
171
- - When user says "the largest/smallest/first/second", look at the previous output to identify the correct item
172
- - When user says "them all" or "combine them", refer to items from the most recent command output
173
- - For "show who changed each line" use git blame, for "show remote urls" use git remote -v
174
- - For text search in code, use grep -rn, NOT nm or objdump (those are for compiled binaries)
175
- - On macOS: for memory use vm_stat or top -l 1, for disk use df -h, for processes use ps aux
176
- - macOS uses BSD tools, NOT GNU. Use: du -d 1 (not --max-depth), ls (not ls --color), sort -r (not sort --reverse), ps aux (not ps --sort)
177
- - NEVER use grep -P (PCRE). macOS grep has NO -P flag. Use grep -E for extended regex, or sed/awk for complex extraction.
178
- - NEVER invent commands that don't exist. Stick to standard Unix/macOS commands.
179
- - NEVER install packages (npx, npm install, pip install, brew install). This is a READ-ONLY terminal.
180
- - NEVER modify source code (sed -i, codemod, awk with redirect). Only observe, never change.
181
- - Search src/ directory, NOT dist/ or node_modules/ for code queries.
182
- - Use exact file paths from the project context below. Do NOT guess paths.
183
- - For "what would break if I deleted X": use grep -rn "from.*X\\|import.*X\\|require.*X" src/ to find all importers.
184
- - For "find where X is defined": use grep -rn "export.*function X\\|export.*class X\\|export.*const X" src/
185
- - For "show me the code of function X": if you know the file, use grep -A 30 "function X" src/file.ts. If not, use grep -rn -A 30 "function X" src/ --include="*.ts"
186
- - ALWAYS use grep -rn (recursive) when searching directories. NEVER use grep without -r on a directory — it will fail.
187
- - For conceptual questions about what code does: use cat on the relevant file, the AI summary will explain it.
188
- - For DESTRUCTIVE requests (delete, remove, install, push): output BLOCKED: <reason>. NEVER try to execute destructive commands.
189
-
190
- AST-POWERED QUERIES: For code STRUCTURE questions, use the built-in AST tool instead of grep:
191
- - "find all exported functions" terminal symbols src/ (lists all functions, classes, interfaces with line numbers)
192
- - "show all interfaces" terminal symbols src/ | grep interface
193
- - "what does file X export" terminal symbols src/file.ts
194
- - "show me the class hierarchy" terminal symbols src/
195
- The "terminal symbols" command uses AST parsing (not regex) — it understands TypeScript, Python, Go, Rust code structure.
196
- For TEXT search (TODO, string matches, imports) use grep as normal.
197
-
198
- COMPOUND QUESTIONS: For questions asking multiple things, prefer ONE command that captures all info. Extract multiple answers from a single output.
199
- - "how many tests and do they pass" → bun test (extract count AND pass/fail from output)
200
- - "what files changed and how many lines" → git log --stat -3 (shows files AND line counts)
201
- - "what version of node and bun" → node -v && bun -v (only use && for trivial non-failing commands)
202
- NEVER split into separate test runs or expensive commands chained with &&.
203
-
204
- BLOCKED ALTERNATIVES: If your preferred command would require installing packages (npx, npm install), ALWAYS try a READ-ONLY alternative:
205
- - Code quality analysis → grep -rn "TODO\\|FIXME\\|HACK\\|XXX" src/
206
- - Linting → check if "lint" or "typecheck" exists in package.json scripts, run that
207
- - Security scan → grep -rn "eval\\|exec\\|spawn\\|password\\|secret" src/
208
- - Dependency audit → cat package.json | grep -A 50 dependencies
209
- - Test coverage → bun test --coverage (or npm run test:coverage if available)
210
- NEVER give up. NEVER output BLOCKED for analysis questions. Always try a grep/find/cat/wc/awk read-only alternative.
211
- - Cyclomatic complexity → grep -rn "if\\|else\\|for\\|while\\|switch\\|case\\|catch\\|&&\\|||" src/ --include="*.ts" | wc -l
212
- - Unused exports → grep -rn "export function\|export const\|export class" src/ --include="*.ts" | sed 's/.*export [a-z]* //' | sed 's/[(<:].*//' | sort -u
213
- - Dead code → for each exported name, grep -rn "name" src/ --include="*.ts" | wc -l (if only 1 match = unused)
214
- - Dependency graph → grep -rn "from " src/ --include="*.ts" | sed 's/:.*from "/→/' | sed 's/".*//' | sort -u
215
- - Most parameters → grep -rn "function " src/ --include="*.ts" | awk -F'[()]' '{print gsub(/,/,",",$2)+1, $0}' | sort -nr | head -10
216
- ALWAYS try a heuristic shell approach before giving up. NEVER say BLOCKED for analysis questions.
217
-
218
- SEMANTIC MAPPING: When the user references a concept, search the file tree for RELATED terms:
219
- - Look at directory names: src/agent/ likely contains "agentic" code
220
- - Look at file names: lazy-executor.ts likely handles "lazy mode"
221
- - When uncertain: grep -rn "keyword" src/ --include="*.ts" -l (list matching files)
222
-
223
- ACTION vs CONCEPTUAL: If the prompt starts with "run", "execute", "check", "test", "build", "show output of" — ALWAYS generate an executable command. NEVER read README for action requests. Only read docs for "explain why", "what does X mean", "how was X designed".
224
-
225
- EXISTENCE CHECKS: If the prompt starts with "is there", "does this have", "do we have", "does X exist" — NEVER run/start/launch anything. Use ls, find, or test -d to CHECK existence. These are READ-ONLY questions.
226
-
227
- 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/.
182
+ list files in current directory → ls
183
+ list all files including hidden ls -a
184
+ show open files lsof
185
+ create copy of a.txt as b.txt cp a.txt b.txt
186
+ create file test.txt touch test.txt
187
+ make directory testdir mkdir testdir
188
+ display routing table route
189
+ show last logged in users last
190
+ show file stats stat file
191
+ print directory tree 2 levels tree -L 2
192
+ count word occurrences in file grep -c "word" file
193
+ print number of files in dir ls -1 | wc -l
194
+ print first line of file head -1 file
195
+ print last line of file tail -1 file
196
+ print lines 3 to 5 of file sed -n '3,5p' file
197
+ print every other line awk 'NR%2==1' file
198
+ count words in file wc -w file
199
+ find empty files not in subdirs find . -maxdepth 1 -type f -empty
200
+ show system load w
201
+ system utilization stats vmstat
202
+ DNS servers cat /etc/resolv.conf | grep nameserver
203
+ long integer size getconf LONG_BIT
204
+ base64 decode string → echo 'str' | base64 -d
205
+ change owner to nobody chown nobody file
206
+ unique lines in fileuniq file
207
+ max cpu timeulimit -t
208
+ memory infolsmem
209
+ process prioritynice
210
+ bash profile cat ~/.bashrc
211
+ search recursively → grep -rn "pattern" src/
212
+ ${astBlock}${compoundBlock}${blockedAltBlock}
228
213
  cwd: ${process.cwd()}
229
- shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}${currentPrompt ? loadCorrectionHints(currentPrompt) : ""}`;
214
+ shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}${currentPrompt ? loadCorrectionHints(currentPrompt) : ""}
215
+
216
+ Q:`;
230
217
  }
231
218
  // ── streaming translate ───────────────────────────────────────────────────────
232
219
  export async function translateToCommand(nl, perms, sessionEntries, onToken) {
@@ -244,12 +231,12 @@ export async function translateToCommand(nl, perms, sessionEntries, onToken) {
244
231
  const system = buildSystemPrompt(perms, sessionEntries, nl);
245
232
  let text;
246
233
  if (onToken) {
247
- text = await provider.stream(nl, { model, maxTokens: 256, system }, {
234
+ text = await provider.stream(nl, { model, maxTokens: 256, temperature: 0, stop: ["\n"], system }, {
248
235
  onToken: (partial) => onToken(partial),
249
236
  });
250
237
  }
251
238
  else {
252
- text = await provider.complete(nl, { model, maxTokens: 256, system });
239
+ text = await provider.complete(nl, { model, maxTokens: 256, temperature: 0, stop: ["\n"], system });
253
240
  }
254
241
  if (text.startsWith("BLOCKED:"))
255
242
  throw new Error(text);
@@ -294,29 +281,35 @@ export async function explainCommand(command) {
294
281
  return provider.complete(command, {
295
282
  model: routing.fast,
296
283
  maxTokens: 128,
284
+ temperature: 0,
297
285
  system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
298
286
  });
299
287
  }
300
288
  // ── auto-fix ──────────────────────────────────────────────────────────────────
301
- export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionEntries) {
289
+ export async function fixCommand(originalNl, failedCommand, errorOutput, perms, _sessionEntries) {
302
290
  const provider = getProvider();
303
291
  const routing = pickModel(originalNl);
304
- const text = await provider.complete(`I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`, {
305
- model: routing.smart, // always use smart model for fixes
292
+ // Lightweight fix prompt no full project context, just rules + restrictions
293
+ const restrictions = [];
294
+ if (!perms.destructive)
295
+ restrictions.push("- NEVER delete/remove/overwrite files");
296
+ if (!perms.network)
297
+ restrictions.push("- NEVER make network requests");
298
+ if (!perms.install)
299
+ restrictions.push("- NEVER install packages");
300
+ const fixSystem = `You are a terminal assistant. Output ONLY the corrected shell command — no explanation.
301
+ macOS/BSD tools. NEVER use grep -P. Use grep -E for extended regex.
302
+ NEVER install packages. READ-ONLY terminal.
303
+ cwd: ${process.cwd()}${restrictions.length > 0 ? `\nRESTRICTIONS:\n${restrictions.join("\n")}` : ""}`;
304
+ const text = await provider.complete(`I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput.slice(0, 2000)}\n\nGive me the corrected command only.`, {
305
+ model: routing.smart,
306
306
  maxTokens: 256,
307
- system: buildSystemPrompt(perms, sessionEntries, originalNl),
307
+ temperature: 0,
308
+ stop: ["\n"],
309
+ system: fixSystem,
308
310
  });
309
311
  if (text.startsWith("BLOCKED:"))
310
312
  throw new Error(text);
311
- return text;
312
- }
313
- // ── summarize output (for MCP/agent use) ──────────────────────────────────────
314
- export async function summarizeOutput(command, output, maxTokens = 200) {
315
- const provider = getProvider();
316
- const routing = pickModel("summarize");
317
- return provider.complete(`Command: ${command}\nOutput:\n${output}\n\nSummarize this output concisely for an AI agent. Focus on: status, key results, errors. Be terse.`, {
318
- model: routing.fast,
319
- maxTokens,
320
- system: "You summarize command output for AI agents. Be extremely concise. Return structured info. No prose.",
321
- });
313
+ return text.trim();
322
314
  }
315
+ // summarizeOutput() removed — all output processing goes through processOutput() in output-processor.ts
package/dist/cache.js CHANGED
@@ -19,12 +19,13 @@ function persistCache() {
19
19
  }
20
20
  catch { }
21
21
  }
22
- /** Normalize a natural language query for cache lookup */
22
+ /** Normalize a natural language query for cache lookup.
23
+ * Keeps . / - _ which are meaningful in file paths and shell context. */
23
24
  export function normalizeNl(nl) {
24
25
  return nl
25
26
  .toLowerCase()
26
27
  .trim()
27
- .replace(/[^a-z0-9\s]/g, "") // strip punctuation
28
+ .replace(/[^a-z0-9\s.\/_-]/g, "") // keep meaningful shell chars
28
29
  .replace(/\s+/g, " ");
29
30
  }
30
31
  export function cacheGet(nl) {
package/dist/cli.js CHANGED
@@ -471,7 +471,7 @@ else if (args.length > 0) {
471
471
  const { rewriteCommand } = await import("./command-rewriter.js");
472
472
  const { shouldBeLazy, toLazy } = await import("./lazy-executor.js");
473
473
  const { saveOutput, formatOutputHint } = await import("./output-store.js");
474
- const { parseOutput, estimateTokens } = await import("./parsers/index.js");
474
+ const { estimateTokens } = await import("./tokens.js");
475
475
  const { recordSaving, recordUsage } = await import("./economy.js");
476
476
  const { isTestOutput, trackTests, formatWatchResult } = await import("./test-watchlist.js");
477
477
  const { detectLoop } = await import("./loop-detector.js");
@@ -1,5 +1,7 @@
1
1
  // Token compression engine — reduces CLI output to fit within token budgets
2
- import { parseOutput, estimateTokens, tokenSavings } from "./parsers/index.js";
2
+ // No regex parsing — just ANSI stripping, deduplication, and smart truncation.
3
+ // All intelligent output processing goes through AI via processOutput().
4
+ import { estimateTokens } from "./tokens.js";
3
5
  /** Strip ANSI escape codes from text */
4
6
  export function stripAnsi(text) {
5
7
  // eslint-disable-next-line no-control-regex
@@ -14,7 +16,6 @@ function deduplicateLines(lines) {
14
16
  let repeatPattern = "";
15
17
  for (let i = 0; i < lines.length; i++) {
16
18
  const line = lines[i];
17
- // Extract a "pattern" — the line without numbers, paths, specific identifiers
18
19
  const pattern = line.replace(/[0-9]+/g, "N").replace(/\/\S+/g, "/PATH").replace(/\s+/g, " ").trim();
19
20
  if (pattern === repeatPattern) {
20
21
  repeatCount++;
@@ -24,7 +25,6 @@ function deduplicateLines(lines) {
24
25
  result.push(` ... (${repeatCount} similar lines)`);
25
26
  }
26
27
  else if (repeatCount > 0) {
27
- // Push the skipped lines back
28
28
  for (let j = i - repeatCount; j < i; j++) {
29
29
  result.push(lines[j]);
30
30
  }
@@ -44,13 +44,12 @@ function deduplicateLines(lines) {
44
44
  }
45
45
  return result;
46
46
  }
47
- /** Smart truncation: keep first N + last M lines */
47
+ /** Smart truncation: keep first 60% + last 40% of lines */
48
48
  function smartTruncate(text, maxTokens) {
49
49
  const lines = text.split("\n");
50
50
  const currentTokens = estimateTokens(text);
51
51
  if (currentTokens <= maxTokens)
52
52
  return text;
53
- // Keep proportional first/last, with first getting more
54
53
  const targetLines = Math.floor((maxTokens * lines.length) / currentTokens);
55
54
  const firstCount = Math.ceil(targetLines * 0.6);
56
55
  const lastCount = Math.floor(targetLines * 0.4);
@@ -61,44 +60,23 @@ function smartTruncate(text, maxTokens) {
61
60
  const hiddenCount = lines.length - firstCount - lastCount;
62
61
  return [...first, `\n--- ${hiddenCount} lines hidden ---\n`, ...last].join("\n");
63
62
  }
64
- /** Compress command output to fit within a token budget */
63
+ /** Compress command output ANSI strip, dedup, truncate. No parsing. */
65
64
  export function compress(command, output, options = {}) {
66
- const { maxTokens, format = "text", stripAnsi: doStrip = true } = options;
65
+ const { maxTokens, stripAnsi: doStrip = true } = options;
67
66
  const originalTokens = estimateTokens(output);
68
67
  // Step 1: Strip ANSI codes
69
68
  let text = doStrip ? stripAnsi(output) : output;
70
- // Step 2: Try structured parsing (format=json or when it saves tokens)
71
- if (format === "json" || format === "summary") {
72
- const parsed = parseOutput(command, text);
73
- if (parsed) {
74
- const json = JSON.stringify(parsed.data, null, format === "summary" ? 0 : 2);
75
- const savings = tokenSavings(output, parsed.data);
76
- const compressedTokens = estimateTokens(json);
77
- // ONLY use JSON if it actually saves tokens (never return larger output)
78
- if (savings.saved > 0 && (!maxTokens || compressedTokens <= maxTokens)) {
79
- return {
80
- content: json,
81
- format: "json",
82
- originalTokens,
83
- compressedTokens,
84
- tokensSaved: savings.saved,
85
- savingsPercent: savings.percent,
86
- };
87
- }
88
- }
89
- }
90
- // Step 3: Deduplicate similar lines
69
+ // Step 2: Deduplicate similar lines
91
70
  const lines = text.split("\n");
92
71
  const deduped = deduplicateLines(lines);
93
72
  text = deduped.join("\n");
94
- // Step 4: Smart truncation if over budget
73
+ // Step 3: Smart truncation if over budget
95
74
  if (maxTokens) {
96
75
  text = smartTruncate(text, maxTokens);
97
76
  }
98
77
  const compressedTokens = estimateTokens(text);
99
78
  return {
100
79
  content: text,
101
- format: "text",
102
80
  originalTokens,
103
81
  compressedTokens,
104
82
  tokensSaved: Math.max(0, originalTokens - compressedTokens),
@@ -31,18 +31,31 @@ export function discoverProjectHints(cwd) {
31
31
  hints.push(`Project type: ${lang} (${file} found)`);
32
32
  }
33
33
  }
34
- // Extract rich metadata from package.json
34
+ // Extract metadata from package.json — trimmed to save tokens
35
35
  const pkgPath = join(cwd, "package.json");
36
36
  if (existsSync(pkgPath)) {
37
37
  try {
38
38
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
39
39
  if (pkg.name)
40
- hints.push(`Package name: ${pkg.name}@${pkg.version ?? "unknown"}`);
40
+ hints.push(`Package: ${pkg.name}@${pkg.version ?? "?"}`);
41
41
  if (pkg.scripts) {
42
- hints.push(`Available scripts: ${Object.entries(pkg.scripts).map(([k, v]) => `${k}: ${v}`).slice(0, 10).join(", ")}`);
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(", ")}`);
43
58
  }
44
- if (pkg.dependencies)
45
- hints.push(`Dependencies: ${Object.keys(pkg.dependencies).join(", ")}`);
46
59
  }
47
60
  catch { }
48
61
  }
@@ -101,21 +114,18 @@ export function discoverProjectHints(cwd) {
101
114
  }
102
115
  catch { }
103
116
  }
104
- // Source directory structure
117
+ // Source directory structure — max 20 files to save tokens
105
118
  try {
106
119
  const { execSync } = require("child_process");
107
120
  const srcDirs = ["src", "lib", "app", "packages"];
108
121
  for (const dir of srcDirs) {
109
122
  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();
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();
111
124
  if (tree)
112
125
  hints.push(`Files in ${dir}/:\n${tree}`);
113
126
  break;
114
127
  }
115
128
  }
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
129
  }
120
130
  catch { }
121
131
  return hints;
@@ -1,5 +1,5 @@
1
1
  // Diff-aware output caching — when same command runs again, return only what changed
2
- import { estimateTokens } from "./parsers/index.js";
2
+ import { estimateTokens } from "./tokens.js";
3
3
  const cache = new Map();
4
4
  function cacheKey(command, cwd) {
5
5
  return `${cwd}:${command}`;
package/dist/discover.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // estimates how much terminal would have saved.
4
4
  import { readdirSync, readFileSync, statSync, existsSync } from "fs";
5
5
  import { join } from "path";
6
- import { estimateTokens } from "./parsers/index.js";
6
+ import { estimateTokens } from "./tokens.js";
7
7
  /** Find all Claude session JSONL files */
8
8
  function findSessionFiles(claudeDir, maxAge) {
9
9
  const files = [];
package/dist/economy.js CHANGED
@@ -44,12 +44,29 @@ function loadStats() {
44
44
  };
45
45
  return stats;
46
46
  }
47
+ let _saveTimer = null;
47
48
  function saveStats() {
48
- ensureDir();
49
- if (stats) {
50
- writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
51
- }
49
+ // Debounce: coalesce multiple writes within 1 second
50
+ if (_saveTimer)
51
+ return;
52
+ _saveTimer = setTimeout(() => {
53
+ _saveTimer = null;
54
+ ensureDir();
55
+ if (stats) {
56
+ writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
57
+ }
58
+ }, 1000);
52
59
  }
60
+ // Flush on exit
61
+ process.on("exit", () => {
62
+ if (_saveTimer) {
63
+ clearTimeout(_saveTimer);
64
+ _saveTimer = null;
65
+ ensureDir();
66
+ if (stats)
67
+ writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
68
+ }
69
+ });
53
70
  /** Record token savings from a feature */
54
71
  export function recordSaving(feature, tokensSaved) {
55
72
  const s = loadStats();
@@ -89,8 +106,23 @@ const PROVIDER_PRICING = {
89
106
  "anthropic-sonnet": { input: 3.00, output: 15.00 },
90
107
  "anthropic-opus": { input: 5.00, output: 25.00 },
91
108
  };
109
+ /** Load configurable turns-before-compaction from ~/.terminal/config.json */
110
+ function loadTurnsMultiplier() {
111
+ try {
112
+ const configPath = join(DIR, "config.json");
113
+ if (existsSync(configPath)) {
114
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
115
+ return config.economy?.turnsBeforeCompaction ?? 5;
116
+ }
117
+ }
118
+ catch { }
119
+ return 5; // Default: tokens saved are repeated ~5 turns before agent compacts context
120
+ }
92
121
  /** Estimate USD savings from compressed tokens */
93
- export function estimateSavingsUsd(tokensSaved, consumerModel = "anthropic-opus", avgTurnsBeforeCompaction = 5) {
122
+ export function estimateSavingsUsd(tokensSaved, consumerModel = "anthropic-opus", avgTurnsBeforeCompaction) {
123
+ if (avgTurnsBeforeCompaction === undefined) {
124
+ avgTurnsBeforeCompaction = loadTurnsMultiplier();
125
+ }
94
126
  const pricing = PROVIDER_PRICING[consumerModel] ?? PROVIDER_PRICING["anthropic-opus"];
95
127
  const multipliedTokens = tokensSaved * avgTurnsBeforeCompaction;
96
128
  const savingsUsd = (multipliedTokens * pricing.input) / 1_000_000;
@@ -15,6 +15,10 @@ export function storeOutput(command, output) {
15
15
  store.set(key, { command, output, timestamp: Date.now() });
16
16
  return key;
17
17
  }
18
+ /** Escape regex special characters for safe use in new RegExp() */
19
+ function escapeRegex(str) {
20
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ }
18
22
  /** Retrieve full output by key, optionally filtered */
19
23
  export function expandOutput(key, grep) {
20
24
  const entry = store.get(key);
@@ -22,7 +26,9 @@ export function expandOutput(key, grep) {
22
26
  return { found: false };
23
27
  let output = entry.output;
24
28
  if (grep) {
25
- const pattern = new RegExp(grep, "i");
29
+ // Escape metacharacters so user input like "[error" or "func()" doesn't crash
30
+ const safe = escapeRegex(grep);
31
+ const pattern = new RegExp(safe, "i");
26
32
  output = output.split("\n").filter(l => pattern.test(l)).join("\n");
27
33
  }
28
34
  return { found: true, output, lines: output.split("\n").length };