@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
package/dist/ai.js ADDED
@@ -0,0 +1,296 @@
1
+ import { cacheGet, cacheSet } from "./cache.js";
2
+ import { getProvider } from "./providers/index.js";
3
+ import { existsSync, readFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { discoverProjectHints, discoverSafetyHints, formatHints } from "./context-hints.js";
6
+ // ── model routing ─────────────────────────────────────────────────────────────
7
+ // Config-driven model selection. Defaults per provider, user can override in ~/.terminal/config.json
8
+ const COMPLEX_SIGNALS = [
9
+ /\b(undo|revert|rollback|previous|last)\b/i,
10
+ /\b(all files?|recursively|bulk|batch)\b/i,
11
+ /\b(pipeline|chain|then|and then|after)\b/i,
12
+ /\b(if|when|unless|only if)\b/i,
13
+ /\b(go into|go to|navigate|cd into|enter)\b.*\b(and|then)\b/i,
14
+ /\b(inside|within|under)\b/i,
15
+ /[|&;]{2}/,
16
+ ];
17
+ /** Default models per provider — user can override in ~/.terminal/config.json under "models" */
18
+ const MODEL_DEFAULTS = {
19
+ cerebras: { fast: "qwen-3-235b-a22b-instruct-2507", smart: "qwen-3-235b-a22b-instruct-2507" },
20
+ groq: { fast: "openai/gpt-oss-120b", smart: "moonshotai/kimi-k2-instruct" },
21
+ xai: { fast: "grok-code-fast-1", smart: "grok-4-fast-non-reasoning" },
22
+ anthropic: { fast: "claude-haiku-4-5-20251001", smart: "claude-sonnet-4-6" },
23
+ };
24
+ /** Load user model overrides from ~/.terminal/config.json (cached 30s) */
25
+ let _modelOverrides = null;
26
+ let _modelOverridesAt = 0;
27
+ function loadModelOverrides() {
28
+ const now = Date.now();
29
+ if (_modelOverrides && now - _modelOverridesAt < 30_000)
30
+ return _modelOverrides;
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
+ _modelOverrides = config.models ?? {};
36
+ _modelOverridesAt = now;
37
+ return _modelOverrides;
38
+ }
39
+ }
40
+ catch { }
41
+ _modelOverrides = {};
42
+ _modelOverridesAt = now;
43
+ return _modelOverrides;
44
+ }
45
+ /** Model routing per provider — config-driven with defaults */
46
+ function pickModel(nl) {
47
+ const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
48
+ const provider = getProvider();
49
+ const defaults = MODEL_DEFAULTS[provider.name] ?? MODEL_DEFAULTS.cerebras;
50
+ const overrides = loadModelOverrides()[provider.name] ?? {};
51
+ return {
52
+ fast: overrides.fast ?? defaults.fast,
53
+ smart: overrides.smart ?? defaults.smart,
54
+ pick: isComplex ? "smart" : "fast",
55
+ };
56
+ }
57
+ // ── irreversibility ───────────────────────────────────────────────────────────
58
+ const IRREVERSIBLE_PATTERNS = [
59
+ /\brm\s/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i,
60
+ /\bdelete\s+from\b/i, /\bmv\b.*\/dev\/null/, /\becho\b.*>\s*[^>]/, /\bcat\b.*>\s*[^>]/,
61
+ /\bdd\b/, /\bmkfs\b/, /\bformat\b/, /\bshred\b/,
62
+ // Process/service killing
63
+ /\bkill\b/, /\bkillall\b/, /\bpkill\b/,
64
+ // Git push/force operations
65
+ /\bgit\s+push\b/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+force\b/,
66
+ // Code modification / package installation (security risk)
67
+ /\bnpx\s+\S+/, /\bnpm\s+install\b/, /\bbun\s+add\b/, /\bpip\s+install\b/,
68
+ /\bcodemod\b/, /\bsed\s+-i\b/, /\bawk\s.*>\s*\S+\.\w+/, /\bperl\s+-[pi]\b/,
69
+ // File creation/modification (READ-ONLY terminal)
70
+ /\btouch\b/, /\bmkdir\b/, /\becho\s.*>/, /\btee\b/, /\bcp\b/, /\bmv\b/,
71
+ // Starting servers/processes (dangerous from NL)
72
+ /\b(bun|npm|pnpm|yarn)\s+run\s+dev\b/, /\b(bun|npm)\s+start\b/,
73
+ ];
74
+ // Commands that are ALWAYS safe (read-only git, etc.)
75
+ const SAFE_OVERRIDES = [
76
+ /^\s*git\s+(log|show|diff|branch|status|blame|tag|remote|stash\s+list)\b/,
77
+ /^\s*git\s+log\b/,
78
+ // find -exec with read-only tools is safe
79
+ /\bfind\b.*-exec\s+(wc|cat|head|tail|grep|stat|file|du|ls)\b/,
80
+ // find without -exec is always safe
81
+ /^\s*find\b(?!.*-exec\s+(rm|mv|chmod|chown|sed))/,
82
+ // xargs with read-only tools is safe
83
+ /\bxargs\s+(wc|cat|head|tail|grep|stat|file|du|ls|git\s+log|git\s+show|git\s+blame)\b/,
84
+ /\bxargs\s+-I\s*\S+\s+(wc|cat|head|tail|grep|stat|git)\b/,
85
+ ];
86
+ export function isIrreversible(command) {
87
+ // Safe overrides take priority
88
+ if (SAFE_OVERRIDES.some((r) => r.test(command)))
89
+ return false;
90
+ return IRREVERSIBLE_PATTERNS.some((r) => r.test(command));
91
+ }
92
+ // ── permissions ───────────────────────────────────────────────────────────────
93
+ const DESTRUCTIVE_PATTERNS = [/\brm\b/, /\brmdir\b/, /\btruncate\b/, /\bdrop\s+table\b/i, /\bdelete\s+from\b/i];
94
+ const NETWORK_PATTERNS = [/\bcurl\b/, /\bwget\b/, /\bssh\b/, /\bscp\b/, /\bping\b/, /\bnc\b/, /\bnetcat\b/];
95
+ const SUDO_PATTERNS = [/\bsudo\b/];
96
+ const INSTALL_PATTERNS = [/\bbrew\s+install\b/, /\bnpm\s+install\s+-g\b/, /\bpip\s+install\b/, /\bapt\s+install\b/, /\byum\s+install\b/];
97
+ const WRITE_OUTSIDE_PATTERNS = [/\s(\/etc|\/usr|\/var|\/opt|\/root|~\/[^.])/, />\s*\//];
98
+ export function checkPermissions(command, perms) {
99
+ if (!perms.destructive && DESTRUCTIVE_PATTERNS.some((r) => r.test(command)))
100
+ return "destructive commands are disabled";
101
+ if (!perms.network && NETWORK_PATTERNS.some((r) => r.test(command)))
102
+ return "network commands are disabled";
103
+ if (!perms.sudo && SUDO_PATTERNS.some((r) => r.test(command)))
104
+ return "sudo is disabled";
105
+ if (!perms.install && INSTALL_PATTERNS.some((r) => r.test(command)))
106
+ return "package installation is disabled";
107
+ if (!perms.write_outside_cwd && WRITE_OUTSIDE_PATTERNS.some((r) => r.test(command)))
108
+ return "writing outside cwd is disabled";
109
+ return null;
110
+ }
111
+ // ── correction memory ───────────────────────────────────────────────────────
112
+ /** Load past corrections relevant to a prompt — injected as negative examples */
113
+ function loadCorrectionHints(prompt) {
114
+ try {
115
+ // Dynamic import to avoid circular deps
116
+ const { findSimilarCorrections } = require("./sessions-db.js");
117
+ const corrections = findSimilarCorrections(prompt, 3);
118
+ if (corrections.length === 0)
119
+ return "";
120
+ const lines = corrections.map((c) => `AVOID: "${c.failed_command}" (failed: ${c.error_type}). USE: "${c.corrected_command}" instead.`);
121
+ return `\n\nLEARNED CORRECTIONS (from past failures):\n${lines.join("\n")}`;
122
+ }
123
+ catch {
124
+ return "";
125
+ }
126
+ }
127
+ // ── project context (powered by context-hints) ──────────────────────────────
128
+ function detectProjectContext() {
129
+ const hints = discoverProjectHints(process.cwd());
130
+ return hints.length > 0 ? `\n\n${formatHints(hints)}` : "";
131
+ }
132
+ // ── system prompt ─────────────────────────────────────────────────────────────
133
+ function buildSystemPrompt(perms, sessionEntries, currentPrompt) {
134
+ const nl = currentPrompt?.toLowerCase() ?? "";
135
+ const restrictions = [];
136
+ if (!perms.destructive)
137
+ restrictions.push("- NEVER generate commands that delete, remove, or overwrite files/data");
138
+ if (!perms.network)
139
+ restrictions.push("- NEVER generate commands that make network requests (curl, wget, ssh, etc.)");
140
+ if (!perms.sudo)
141
+ restrictions.push("- NEVER generate commands requiring sudo");
142
+ if (!perms.write_outside_cwd)
143
+ restrictions.push("- NEVER write to paths outside the current working directory");
144
+ if (!perms.install)
145
+ restrictions.push("- NEVER install packages (brew, npm -g, pip, apt, etc.)");
146
+ const restrictionBlock = restrictions.length > 0
147
+ ? `\n\nRESTRICTIONS:\n${restrictions.join("\n")}\nIf restricted, output: BLOCKED: <reason>`
148
+ : "";
149
+ let contextBlock = "";
150
+ if (sessionEntries.length > 0) {
151
+ const lines = [];
152
+ for (const e of sessionEntries.slice(-5)) {
153
+ lines.push(`> ${e.nl}`);
154
+ lines.push(`$ ${e.cmd}`);
155
+ if (e.output)
156
+ lines.push(e.output);
157
+ if (e.error)
158
+ lines.push("(command failed)");
159
+ }
160
+ contextBlock = `\n\nSESSION HISTORY (user intent > command $ output):\n${lines.join("\n")}`;
161
+ }
162
+ const projectContext = detectProjectContext();
163
+ const safetyBlock = sessionEntries.length > 0
164
+ ? (() => {
165
+ const lastCmd = sessionEntries[sessionEntries.length - 1]?.cmd;
166
+ if (lastCmd) {
167
+ const safetyHints = discoverSafetyHints(lastCmd);
168
+ return safetyHints.length > 0 ? `\n\nLAST COMMAND SAFETY:\n${safetyHints.join("\n")}` : "";
169
+ }
170
+ return "";
171
+ })()
172
+ : "";
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 `You are a terminal assistant. Output ONLY the exact shell command — no explanation, no markdown, no backticks.
181
+
182
+ RULES:
183
+ - SIMPLICITY FIRST: Use the simplest command. Prefer grep | sort | head over 10-pipe chains.
184
+ - ALWAYS use grep -rn when searching directories. NEVER grep without -r on a directory.
185
+ - When user refers to items from previous output, use EXACT names shown.
186
+ - For text search use grep -rn, NOT nm or objdump.
187
+ - macOS/BSD tools: du -d 1 (not --max-depth), NEVER grep -P, use grep -E for extended regex.
188
+ - NEVER invent commands. Stick to standard Unix/macOS.
189
+ - NEVER install packages. READ-ONLY terminal.
190
+ - NEVER modify source code. Only observe.
191
+ - Search src/ not dist/ or node_modules/.
192
+ - Use exact file paths from project context. Do NOT guess paths.
193
+ - For DESTRUCTIVE requests: output BLOCKED: <reason>.
194
+ - ACTION vs CONCEPTUAL: "run/test/build/check" → executable command. "explain/what does X mean" → read docs.
195
+ - EXISTENCE CHECKS: "is there/does X exist" → use ls/find/test, NEVER run/launch.${astBlock}${compoundBlock}${blockedAltBlock}
196
+ cwd: ${process.cwd()}
197
+ shell: zsh / macOS${projectContext}${safetyBlock}${restrictionBlock}${contextBlock}${currentPrompt ? loadCorrectionHints(currentPrompt) : ""}`;
198
+ }
199
+ // ── streaming translate ───────────────────────────────────────────────────────
200
+ export async function translateToCommand(nl, perms, sessionEntries, onToken) {
201
+ // Only use cache when there's no session context (context makes same NL produce different commands)
202
+ if (sessionEntries.length === 0) {
203
+ const cached = cacheGet(nl);
204
+ if (cached) {
205
+ onToken?.(cached);
206
+ return cached;
207
+ }
208
+ }
209
+ const provider = getProvider();
210
+ const routing = pickModel(nl);
211
+ const model = routing.pick === "smart" ? routing.smart : routing.fast;
212
+ const system = buildSystemPrompt(perms, sessionEntries, nl);
213
+ let text;
214
+ if (onToken) {
215
+ text = await provider.stream(nl, { model, maxTokens: 256, temperature: 0, stop: ["\n"], system }, {
216
+ onToken: (partial) => onToken(partial),
217
+ });
218
+ }
219
+ else {
220
+ text = await provider.complete(nl, { model, maxTokens: 256, temperature: 0, stop: ["\n"], system });
221
+ }
222
+ if (text.startsWith("BLOCKED:"))
223
+ throw new Error(text);
224
+ // Strip AI reasoning — extract ONLY the shell command (first line)
225
+ let cleaned = text.trim();
226
+ // Remove ALL markdown code blocks and their content markers
227
+ cleaned = cleaned.replace(/```(?:bash|sh|shell)?\n?/g, "").replace(/```/g, "");
228
+ // Split into lines and find the FIRST one that looks like a SHELL COMMAND
229
+ const lines = cleaned.split("\n");
230
+ let command = "";
231
+ for (const line of lines) {
232
+ const t = line.trim();
233
+ if (!t)
234
+ continue;
235
+ // Skip lines that are clearly English prose, not commands
236
+ if (/^(Based on|I |This |The |Let me|Here|Note:|Since|Looking|To |However|BLOCKED:|If |You |We |For |It |A |An |That )/.test(t))
237
+ continue;
238
+ if (/^[A-Z][a-z].*[.;:!?,]/.test(t))
239
+ continue; // English sentence with punctuation anywhere
240
+ if (t.split(" ").length > 15 && !/[|&;><$]/.test(t))
241
+ continue; // Long line without shell operators = prose
242
+ // Must start with a plausible command character (lowercase, /, ., $, or common tool)
243
+ if (/^[a-z./$~(]/.test(t) || /^[A-Z]+[_=]/.test(t)) {
244
+ command = t;
245
+ break;
246
+ }
247
+ }
248
+ cleaned = command || lines[0]?.trim() || cleaned;
249
+ cacheSet(nl, cleaned);
250
+ return cleaned;
251
+ }
252
+ // ── prefetch ──────────────────────────────────────────────────────────────────
253
+ export function prefetchNext(lastNl, perms, sessionEntries) {
254
+ if (sessionEntries.length === 0 && cacheGet(lastNl))
255
+ return;
256
+ translateToCommand(lastNl, perms, sessionEntries).catch(() => { });
257
+ }
258
+ // ── explain ───────────────────────────────────────────────────────────────────
259
+ export async function explainCommand(command) {
260
+ const provider = getProvider();
261
+ const routing = pickModel("explain"); // simple = fast model
262
+ return provider.complete(command, {
263
+ model: routing.fast,
264
+ maxTokens: 128,
265
+ temperature: 0,
266
+ system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
267
+ });
268
+ }
269
+ // ── auto-fix ──────────────────────────────────────────────────────────────────
270
+ export async function fixCommand(originalNl, failedCommand, errorOutput, perms, _sessionEntries) {
271
+ const provider = getProvider();
272
+ const routing = pickModel(originalNl);
273
+ // Lightweight fix prompt — no full project context, just rules + restrictions
274
+ const restrictions = [];
275
+ if (!perms.destructive)
276
+ restrictions.push("- NEVER delete/remove/overwrite files");
277
+ if (!perms.network)
278
+ restrictions.push("- NEVER make network requests");
279
+ if (!perms.install)
280
+ restrictions.push("- NEVER install packages");
281
+ const fixSystem = `You are a terminal assistant. Output ONLY the corrected shell command — no explanation.
282
+ macOS/BSD tools. NEVER use grep -P. Use grep -E for extended regex.
283
+ NEVER install packages. READ-ONLY terminal.
284
+ cwd: ${process.cwd()}${restrictions.length > 0 ? `\nRESTRICTIONS:\n${restrictions.join("\n")}` : ""}`;
285
+ 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.`, {
286
+ model: routing.smart,
287
+ maxTokens: 256,
288
+ temperature: 0,
289
+ stop: ["\n"],
290
+ system: fixSystem,
291
+ });
292
+ if (text.startsWith("BLOCKED:"))
293
+ throw new Error(text);
294
+ return text.trim();
295
+ }
296
+ // summarizeOutput() removed — all output processing goes through processOutput() in output-processor.ts
package/dist/cache.js ADDED
@@ -0,0 +1,42 @@
1
+ // In-memory LRU cache + disk persistence for command translations
2
+ import { existsSync, readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+ const CACHE_FILE = join(homedir(), ".terminal", "cache.json");
6
+ const MAX_ENTRIES = 500;
7
+ let mem = {};
8
+ export function loadCache() {
9
+ if (!existsSync(CACHE_FILE))
10
+ return;
11
+ try {
12
+ mem = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
13
+ }
14
+ catch { }
15
+ }
16
+ function persistCache() {
17
+ try {
18
+ writeFileSync(CACHE_FILE, JSON.stringify(mem));
19
+ }
20
+ catch { }
21
+ }
22
+ /** Normalize a natural language query for cache lookup.
23
+ * Keeps . / - _ which are meaningful in file paths and shell context. */
24
+ export function normalizeNl(nl) {
25
+ return nl
26
+ .toLowerCase()
27
+ .trim()
28
+ .replace(/[^a-z0-9\s.\/_-]/g, "") // keep meaningful shell chars
29
+ .replace(/\s+/g, " ");
30
+ }
31
+ export function cacheGet(nl) {
32
+ return mem[normalizeNl(nl)] ?? null;
33
+ }
34
+ export function cacheSet(nl, command) {
35
+ const key = normalizeNl(nl);
36
+ // evict oldest if full
37
+ const keys = Object.keys(mem);
38
+ if (keys.length >= MAX_ENTRIES)
39
+ delete mem[keys[0]];
40
+ mem[key] = command;
41
+ persistCache();
42
+ }
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");
@@ -0,0 +1,64 @@
1
+ // Command rewriter — auto-optimize commands to produce less output
2
+ // Only rewrites when semantic result is identical
3
+ const rules = [
4
+ // find | grep -v node_modules → find -not -path
5
+ {
6
+ pattern: /find\s+(\S+)\s+(.*?)\|\s*grep\s+-v\s+node_modules/,
7
+ rewrite: (m, cmd) => cmd.replace(m[0], `find ${m[1]} ${m[2]}-not -path '*/node_modules/*'`),
8
+ reason: "avoid pipe, filter in-kernel",
9
+ },
10
+ // cat file | grep X → grep X file
11
+ {
12
+ pattern: /cat\s+(\S+)\s*\|\s*grep\s+(.*)/,
13
+ rewrite: (m) => `grep ${m[2]} ${m[1]}`,
14
+ reason: "useless cat",
15
+ },
16
+ // find without node_modules exclusion → add it
17
+ {
18
+ pattern: /^find\s+\.\s+(.*)(?!.*node_modules)/,
19
+ rewrite: (m, cmd) => {
20
+ if (cmd.includes("node_modules") || cmd.includes("-not -path"))
21
+ return cmd;
22
+ return cmd.replace(/^find\s+\.\s+/, "find . -not -path '*/node_modules/*' -not -path '*/.git/*' ");
23
+ },
24
+ reason: "auto-exclude node_modules and .git",
25
+ },
26
+ // git log without limit → add --oneline -20
27
+ {
28
+ pattern: /^git\s+log\s*$/,
29
+ rewrite: () => "git log --oneline -20",
30
+ reason: "prevent unbounded log output",
31
+ },
32
+ // git diff without stat → add --stat for overview
33
+ {
34
+ pattern: /^git\s+diff\s*$/,
35
+ rewrite: () => "git diff --stat",
36
+ reason: "stat overview is usually sufficient",
37
+ },
38
+ // npm ls without depth → add --depth=0
39
+ {
40
+ pattern: /^npm\s+ls\s*$/,
41
+ rewrite: () => "npm ls --depth=0",
42
+ reason: "full tree is massive, top-level usually enough",
43
+ },
44
+ // ps aux without filter → sort by memory and head (macOS compatible)
45
+ {
46
+ pattern: /^ps\s+aux\s*$/,
47
+ rewrite: () => "ps aux | sort -k4 -rn | head -20",
48
+ reason: "full process list is noise, show top consumers",
49
+ },
50
+ ];
51
+ /** Rewrite a command to produce less output */
52
+ export function rewriteCommand(cmd) {
53
+ const trimmed = cmd.trim();
54
+ for (const rule of rules) {
55
+ const match = trimmed.match(rule.pattern);
56
+ if (match) {
57
+ const rewritten = rule.rewrite(match, trimmed);
58
+ if (rewritten !== trimmed) {
59
+ return { original: trimmed, rewritten, changed: true, reason: rule.reason };
60
+ }
61
+ }
62
+ }
63
+ return { original: trimmed, rewritten: trimmed, changed: false };
64
+ }
@@ -0,0 +1,86 @@
1
+ // Command validator — catch invalid commands BEFORE executing
2
+ // Prevents shell errors from hallucinated flags, wrong paths, bad syntax
3
+ import { existsSync } from "fs";
4
+ import { join } from "path";
5
+ /** Extract file paths referenced in a command */
6
+ function extractPaths(command) {
7
+ const paths = [];
8
+ // Match quoted paths
9
+ const quoted = command.match(/["']([^"']+\.\w+)["']/g);
10
+ if (quoted)
11
+ paths.push(...quoted.map(q => q.replace(/["']/g, "")));
12
+ // Match unquoted paths with extensions or directory separators
13
+ const tokens = command.split(/\s+/);
14
+ for (const t of tokens) {
15
+ if (t.includes("/") && !t.startsWith("-") && !t.startsWith("|") && !t.startsWith("&")) {
16
+ // Clean shell operators from end
17
+ const clean = t.replace(/[;|&>]+$/, "");
18
+ if (clean && !clean.startsWith("-"))
19
+ paths.push(clean);
20
+ }
21
+ }
22
+ return [...new Set(paths)];
23
+ }
24
+ /** Check for obviously broken shell syntax */
25
+ function checkSyntax(command) {
26
+ const issues = [];
27
+ // Unmatched quotes
28
+ const singleQuotes = (command.match(/'/g) || []).length;
29
+ const doubleQuotes = (command.match(/"/g) || []).length;
30
+ if (singleQuotes % 2 !== 0)
31
+ issues.push("unmatched single quote");
32
+ if (doubleQuotes % 2 !== 0)
33
+ issues.push("unmatched double quote");
34
+ // Unmatched parentheses
35
+ const openParens = (command.match(/\(/g) || []).length;
36
+ const closeParens = (command.match(/\)/g) || []).length;
37
+ if (openParens !== closeParens)
38
+ issues.push("unmatched parentheses");
39
+ // Empty pipe targets
40
+ if (/\|\s*$/.test(command))
41
+ issues.push("pipe with no target");
42
+ if (/^\s*\|/.test(command))
43
+ issues.push("pipe with no source");
44
+ return issues;
45
+ }
46
+ /** Validate a command before execution */
47
+ export function validateCommand(command, cwd) {
48
+ const issues = [];
49
+ // Check syntax
50
+ issues.push(...checkSyntax(command));
51
+ // Check file paths exist
52
+ const paths = extractPaths(command);
53
+ for (const p of paths) {
54
+ const fullPath = p.startsWith("/") ? p : join(cwd, p);
55
+ if (p.includes("*") || p.includes("?"))
56
+ continue; // skip globs
57
+ if (p.startsWith("-"))
58
+ continue; // skip flags
59
+ if ([".", "..", "/", "~"].includes(p))
60
+ continue; // skip special
61
+ if (!existsSync(fullPath) && !existsSync(p)) {
62
+ // Only flag source file paths, not output paths
63
+ if (/\.(ts|tsx|js|jsx|json|md|yaml|yml|py|go|rs)$/.test(p)) {
64
+ issues.push(`file not found: ${p}`);
65
+ }
66
+ }
67
+ }
68
+ // Check for common GNU flags on macOS
69
+ const gnuFlags = command.match(/--max-depth|--color=|--sort=|--field-type|--no-deps/g);
70
+ if (gnuFlags) {
71
+ issues.push(`GNU flag on macOS: ${gnuFlags.join(", ")}`);
72
+ }
73
+ // Complexity guard — extreme pipe chains are fragile
74
+ const pipeCount = (command.match(/\|/g) || []).length;
75
+ if (pipeCount > 7) {
76
+ issues.push(`too complex: ${pipeCount} pipes — simplify`);
77
+ }
78
+ // grep -P (PCRE) doesn't exist on macOS
79
+ if (/grep\s+.*-[a-zA-Z]*P/.test(command)) {
80
+ issues.push("grep -P (PCRE) not available on macOS — use grep -E");
81
+ }
82
+ return {
83
+ valid: issues.length === 0,
84
+ issues,
85
+ };
86
+ }
@@ -0,0 +1,85 @@
1
+ // Token compression engine — reduces CLI output to fit within token budgets
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";
5
+ /** Strip ANSI escape codes from text */
6
+ export function stripAnsi(text) {
7
+ // eslint-disable-next-line no-control-regex
8
+ return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
9
+ }
10
+ /** Deduplicate consecutive similar lines (e.g., "Compiling X... Compiling Y...") */
11
+ function deduplicateLines(lines) {
12
+ if (lines.length <= 3)
13
+ return lines;
14
+ const result = [];
15
+ let repeatCount = 0;
16
+ let repeatPattern = "";
17
+ for (let i = 0; i < lines.length; i++) {
18
+ const line = lines[i];
19
+ const pattern = line.replace(/[0-9]+/g, "N").replace(/\/\S+/g, "/PATH").replace(/\s+/g, " ").trim();
20
+ if (pattern === repeatPattern) {
21
+ repeatCount++;
22
+ }
23
+ else {
24
+ if (repeatCount > 2) {
25
+ result.push(` ... (${repeatCount} similar lines)`);
26
+ }
27
+ else if (repeatCount > 0) {
28
+ for (let j = i - repeatCount; j < i; j++) {
29
+ result.push(lines[j]);
30
+ }
31
+ }
32
+ result.push(line);
33
+ repeatPattern = pattern;
34
+ repeatCount = 0;
35
+ }
36
+ }
37
+ if (repeatCount > 2) {
38
+ result.push(` ... (${repeatCount} similar lines)`);
39
+ }
40
+ else {
41
+ for (let j = lines.length - repeatCount; j < lines.length; j++) {
42
+ result.push(lines[j]);
43
+ }
44
+ }
45
+ return result;
46
+ }
47
+ /** Smart truncation: keep first 60% + last 40% of lines */
48
+ function smartTruncate(text, maxTokens) {
49
+ const lines = text.split("\n");
50
+ const currentTokens = estimateTokens(text);
51
+ if (currentTokens <= maxTokens)
52
+ return text;
53
+ const targetLines = Math.floor((maxTokens * lines.length) / currentTokens);
54
+ const firstCount = Math.ceil(targetLines * 0.6);
55
+ const lastCount = Math.floor(targetLines * 0.4);
56
+ if (firstCount + lastCount >= lines.length)
57
+ return text;
58
+ const first = lines.slice(0, firstCount);
59
+ const last = lines.slice(-lastCount);
60
+ const hiddenCount = lines.length - firstCount - lastCount;
61
+ return [...first, `\n--- ${hiddenCount} lines hidden ---\n`, ...last].join("\n");
62
+ }
63
+ /** Compress command output — ANSI strip, dedup, truncate. No parsing. */
64
+ export function compress(command, output, options = {}) {
65
+ const { maxTokens, stripAnsi: doStrip = true } = options;
66
+ const originalTokens = estimateTokens(output);
67
+ // Step 1: Strip ANSI codes
68
+ let text = doStrip ? stripAnsi(output) : output;
69
+ // Step 2: Deduplicate similar lines
70
+ const lines = text.split("\n");
71
+ const deduped = deduplicateLines(lines);
72
+ text = deduped.join("\n");
73
+ // Step 3: Smart truncation if over budget
74
+ if (maxTokens) {
75
+ text = smartTruncate(text, maxTokens);
76
+ }
77
+ const compressedTokens = estimateTokens(text);
78
+ return {
79
+ content: text,
80
+ originalTokens,
81
+ compressedTokens,
82
+ tokensSaved: Math.max(0, originalTokens - compressedTokens),
83
+ savingsPercent: originalTokens > 0 ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 100) : 0,
84
+ };
85
+ }