@hasna/terminal 0.1.5 → 0.2.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 (74) hide show
  1. package/.claude/scheduled_tasks.lock +1 -1
  2. package/README.md +186 -0
  3. package/dist/ai.js +45 -50
  4. package/dist/cli.js +138 -6
  5. package/dist/compression.js +107 -0
  6. package/dist/compression.test.js +42 -0
  7. package/dist/diff-cache.js +87 -0
  8. package/dist/diff-cache.test.js +27 -0
  9. package/dist/economy.js +79 -0
  10. package/dist/economy.test.js +13 -0
  11. package/dist/mcp/install.js +98 -0
  12. package/dist/mcp/server.js +333 -0
  13. package/dist/output-router.js +41 -0
  14. package/dist/parsers/base.js +2 -0
  15. package/dist/parsers/build.js +64 -0
  16. package/dist/parsers/errors.js +101 -0
  17. package/dist/parsers/files.js +78 -0
  18. package/dist/parsers/git.js +86 -0
  19. package/dist/parsers/index.js +48 -0
  20. package/dist/parsers/parsers.test.js +136 -0
  21. package/dist/parsers/tests.js +89 -0
  22. package/dist/providers/anthropic.js +39 -0
  23. package/dist/providers/base.js +4 -0
  24. package/dist/providers/cerebras.js +95 -0
  25. package/dist/providers/index.js +49 -0
  26. package/dist/providers/providers.test.js +14 -0
  27. package/dist/recipes/model.js +20 -0
  28. package/dist/recipes/recipes.test.js +36 -0
  29. package/dist/recipes/storage.js +118 -0
  30. package/dist/search/content-search.js +61 -0
  31. package/dist/search/file-search.js +61 -0
  32. package/dist/search/filters.js +34 -0
  33. package/dist/search/index.js +4 -0
  34. package/dist/search/search.test.js +22 -0
  35. package/dist/snapshots.js +51 -0
  36. package/dist/supervisor.js +112 -0
  37. package/dist/tree.js +94 -0
  38. package/package.json +7 -4
  39. package/src/ai.ts +63 -51
  40. package/src/cli.tsx +132 -6
  41. package/src/compression.test.ts +50 -0
  42. package/src/compression.ts +140 -0
  43. package/src/diff-cache.test.ts +30 -0
  44. package/src/diff-cache.ts +125 -0
  45. package/src/economy.test.ts +16 -0
  46. package/src/economy.ts +99 -0
  47. package/src/mcp/install.ts +94 -0
  48. package/src/mcp/server.ts +476 -0
  49. package/src/output-router.ts +56 -0
  50. package/src/parsers/base.ts +72 -0
  51. package/src/parsers/build.ts +73 -0
  52. package/src/parsers/errors.ts +107 -0
  53. package/src/parsers/files.ts +91 -0
  54. package/src/parsers/git.ts +86 -0
  55. package/src/parsers/index.ts +66 -0
  56. package/src/parsers/parsers.test.ts +153 -0
  57. package/src/parsers/tests.ts +98 -0
  58. package/src/providers/anthropic.ts +44 -0
  59. package/src/providers/base.ts +34 -0
  60. package/src/providers/cerebras.ts +108 -0
  61. package/src/providers/index.ts +60 -0
  62. package/src/providers/providers.test.ts +16 -0
  63. package/src/recipes/model.ts +55 -0
  64. package/src/recipes/recipes.test.ts +44 -0
  65. package/src/recipes/storage.ts +142 -0
  66. package/src/search/content-search.ts +97 -0
  67. package/src/search/file-search.ts +86 -0
  68. package/src/search/filters.ts +36 -0
  69. package/src/search/index.ts +7 -0
  70. package/src/search/search.test.ts +25 -0
  71. package/src/snapshots.ts +67 -0
  72. package/src/supervisor.ts +129 -0
  73. package/src/tree.ts +101 -0
  74. package/tsconfig.json +2 -1
@@ -1 +1 @@
1
- {"sessionId":"501c27b4-5243-40d6-8fb5-04ebdb3c8df8","pid":90841,"acquiredAt":1773556984409}
1
+ {"sessionId":"c1e414c7-f1a5-4b9e-bcc4-64c451584cb8","pid":54679,"acquiredAt":1773566918526}
package/README.md ADDED
@@ -0,0 +1,186 @@
1
+ # open-terminal
2
+
3
+ Smart terminal wrapper for AI agents and humans. Speak plain English or let agents execute commands with structured output, token compression, and massive context savings.
4
+
5
+ ## Why?
6
+
7
+ AI agents waste tokens on terminal interaction. Every `npm test` dumps hundreds of lines into context. Every `find` returns noise. `open-terminal` sits between callers and the shell, making every interaction dramatically more efficient.
8
+
9
+ **For agents:** MCP server with structured output, token compression, diff-aware caching, smart search, process supervision. Cut token usage 50-90% on verbose commands.
10
+
11
+ **For humans:** Natural language terminal powered by Cerebras (free, open-source) or Anthropic. Type "count typescript files" instead of `find . -name '*.ts' | wc -l`.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install -g @hasna/terminal
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ### For Humans (TUI Mode)
22
+
23
+ ```bash
24
+ # Set your API key (pick one)
25
+ export CEREBRAS_API_KEY=your_key # free, open-source (default)
26
+ export ANTHROPIC_API_KEY=your_key # Claude
27
+
28
+ # Launch
29
+ t
30
+ ```
31
+
32
+ Type in plain English. The terminal translates, shows you the command, and runs it.
33
+
34
+ ### For AI Agents (MCP Server)
35
+
36
+ ```bash
37
+ # Install for your agent
38
+ t mcp install --claude # Claude Code
39
+ t mcp install --codex # OpenAI Codex
40
+ t mcp install --gemini # Gemini CLI
41
+ t mcp install --all # All agents
42
+
43
+ # Or start manually
44
+ t mcp serve
45
+ ```
46
+
47
+ ## MCP Tools
48
+
49
+ | Tool | Description | Token Savings |
50
+ |------|-------------|---------------|
51
+ | `execute` | Run command with structured output, compression, or AI summary | 50-90% |
52
+ | `execute_diff` | Run command, return only what changed since last run | 80-95% |
53
+ | `browse` | List files as structured JSON, auto-filter node_modules | 60-80% |
54
+ | `search_files` | Find files by pattern, categorized (source/config/other) | 70-90% |
55
+ | `search_content` | Grep with grouping by file and relevance ranking | 60-80% |
56
+ | `explain_error` | Structured error diagnosis with fix suggestions | N/A |
57
+ | `bg_start` | Start background process with port auto-detection | N/A |
58
+ | `bg_status` | List managed processes with health info | N/A |
59
+ | `bg_wait_port` | Wait for a port to be ready | N/A |
60
+ | `bg_stop` / `bg_logs` | Stop process / get recent output | N/A |
61
+ | `list_recipes` / `run_recipe` / `save_recipe` | Reusable command templates | N/A |
62
+ | `snapshot` | Capture terminal state for agent handoff | N/A |
63
+ | `token_stats` | Token economy dashboard | N/A |
64
+
65
+ ### Example: Structured Output
66
+
67
+ ```
68
+ Agent: execute("npm test", {format: "json"})
69
+
70
+ → {"passed": 142, "failed": 2, "failures": [{"test": "auth.test.ts:45", "error": "expected 200 got 401"}]}
71
+ (saved 847 tokens vs raw output)
72
+ ```
73
+
74
+ ### Example: Diff Mode
75
+
76
+ ```
77
+ Agent: execute_diff("npm test") # first run → full output
78
+ Agent: execute_diff("npm test") # second run → only changes
79
+
80
+ → {"diffSummary": "+1 new line, -1 removed", "added": ["PASS auth.test.ts:45"], "removed": ["FAIL auth.test.ts:45"], "tokensSaved": 892}
81
+ ```
82
+
83
+ ### Example: Smart Search
84
+
85
+ ```
86
+ Agent: search_files("*hooks*")
87
+
88
+ → {"source": ["src/lib/webhooks.ts", "src/hooks/useAuth.ts"], "filtered": [{"count": 47, "reason": "node_modules"}], "tokensSaved": 312}
89
+ ```
90
+
91
+ ## Recipes
92
+
93
+ Reusable command templates with variable substitution:
94
+
95
+ ```bash
96
+ # Save a recipe
97
+ t recipe add kill-port "lsof -i :{port} -t | xargs kill"
98
+
99
+ # Run it
100
+ t recipe run kill-port --port=3000
101
+
102
+ # List recipes
103
+ t recipe list
104
+
105
+ # Project-scoped recipes
106
+ t project init
107
+ t recipe add dev-start "npm run dev" --project
108
+
109
+ # Collections
110
+ t collection create docker "Docker commands"
111
+ t recipe add docker-build "docker build -t {tag} ." --collection=docker
112
+ ```
113
+
114
+ ## Token Economy
115
+
116
+ Track how many tokens you've saved:
117
+
118
+ ```bash
119
+ t stats
120
+ ```
121
+
122
+ ```
123
+ Token Economy:
124
+ Total saved: 124.5K
125
+ By feature:
126
+ Structured: 45.2K
127
+ Compressed: 32.1K
128
+ Diff cache: 28.7K
129
+ Search: 18.5K
130
+ ```
131
+
132
+ ## TUI Keyboard Shortcuts
133
+
134
+ | Key | Action |
135
+ |-----|--------|
136
+ | `ctrl+t` | New tab |
137
+ | `tab` | Switch tabs |
138
+ | `ctrl+w` | Close tab |
139
+ | `ctrl+b` | Browse mode (file navigator) |
140
+ | `ctrl+r` | Fuzzy history search |
141
+ | `ctrl+l` | Clear scrollback |
142
+ | `ctrl+c` | Cancel / exit |
143
+ | `?` | Explain command before running |
144
+ | `e` | Edit translated command |
145
+ | `→` | Accept ghost text suggestion |
146
+
147
+ ## Configuration
148
+
149
+ Config stored at `~/.terminal/config.json`:
150
+
151
+ ```json
152
+ {
153
+ "provider": "cerebras",
154
+ "permissions": {
155
+ "destructive": true,
156
+ "network": true,
157
+ "sudo": false,
158
+ "install": true,
159
+ "write_outside_cwd": false
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Architecture
165
+
166
+ ```
167
+ ┌──────────────────────────────────────────┐
168
+ │ open-terminal │
169
+ │ ┌──────────┐ ┌──────────┐ ┌────────┐ │
170
+ │ │ Human │ │ MCP │ │ CLI │ │
171
+ │ │ TUI │ │ Server │ │ Tools │ │
172
+ │ └────┬─────┘ └────┬─────┘ └───┬────┘ │
173
+ │ └──────────┬───┘────────────┘ │
174
+ │ ┌──────────────────────────────────┐ │
175
+ │ │ Output Intelligence Router │ │
176
+ │ │ Parsers → Compression → Diff │ │
177
+ │ └──────────────┬───────────────────┘ │
178
+ │ ┌──────────────────────────────────┐ │
179
+ │ │ Shell (zsh/bash) │ │
180
+ │ └──────────────────────────────────┘ │
181
+ └──────────────────────────────────────────┘
182
+ ```
183
+
184
+ ## License
185
+
186
+ MIT
package/dist/ai.js CHANGED
@@ -1,8 +1,7 @@
1
- import Anthropic from "@anthropic-ai/sdk";
2
1
  import { cacheGet, cacheSet } from "./cache.js";
3
- const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
2
+ import { getProvider } from "./providers/index.js";
4
3
  // ── model routing ─────────────────────────────────────────────────────────────
5
- // Simple queries → haiku (fast). Complex/ambiguous → sonnet.
4
+ // Simple queries → fast model. Complex/ambiguous → smart model.
6
5
  const COMPLEX_SIGNALS = [
7
6
  /\b(undo|revert|rollback|previous|last)\b/i,
8
7
  /\b(all files?|recursively|bulk|batch)\b/i,
@@ -10,9 +9,23 @@ const COMPLEX_SIGNALS = [
10
9
  /\b(if|when|unless|only if)\b/i,
11
10
  /[|&;]{2}/, // pipes / && in NL (unusual = complex intent)
12
11
  ];
12
+ /** Model routing per provider */
13
13
  function pickModel(nl) {
14
14
  const isComplex = COMPLEX_SIGNALS.some((r) => r.test(nl)) || nl.split(" ").length > 10;
15
- return isComplex ? "claude-sonnet-4-6" : "claude-haiku-4-5-20251001";
15
+ const provider = getProvider();
16
+ if (provider.name === "anthropic") {
17
+ return {
18
+ fast: "claude-haiku-4-5-20251001",
19
+ smart: "claude-sonnet-4-6",
20
+ pick: isComplex ? "smart" : "fast",
21
+ };
22
+ }
23
+ // Cerebras — single fast model (Llama is already fast)
24
+ return {
25
+ fast: "llama-4-scout-17b-16e",
26
+ smart: "llama-4-scout-17b-16e",
27
+ pick: isComplex ? "smart" : "fast",
28
+ };
16
29
  }
17
30
  // ── irreversibility ───────────────────────────────────────────────────────────
18
31
  const IRREVERSIBLE_PATTERNS = [
@@ -73,78 +86,60 @@ export async function translateToCommand(nl, perms, sessionCmds, onToken) {
73
86
  onToken?.(cached);
74
87
  return cached;
75
88
  }
76
- const model = pickModel(nl);
77
- let result = "";
89
+ const provider = getProvider();
90
+ const routing = pickModel(nl);
91
+ const model = routing.pick === "smart" ? routing.smart : routing.fast;
92
+ const system = buildSystemPrompt(perms, sessionCmds);
93
+ let text;
78
94
  if (onToken) {
79
- // streaming path
80
- const stream = await client.messages.stream({
81
- model,
82
- max_tokens: 256,
83
- system: buildSystemPrompt(perms, sessionCmds),
84
- messages: [{ role: "user", content: nl }],
95
+ text = await provider.stream(nl, { model, maxTokens: 256, system }, {
96
+ onToken: (partial) => onToken(partial),
85
97
  });
86
- for await (const chunk of stream) {
87
- if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
88
- result += chunk.delta.text;
89
- onToken(result.trim());
90
- }
91
- }
92
98
  }
93
99
  else {
94
- const message = await client.messages.create({
95
- model,
96
- max_tokens: 256,
97
- system: buildSystemPrompt(perms, sessionCmds),
98
- messages: [{ role: "user", content: nl }],
99
- });
100
- const block = message.content[0];
101
- if (block.type !== "text")
102
- throw new Error("Unexpected response type");
103
- result = block.text;
100
+ text = await provider.complete(nl, { model, maxTokens: 256, system });
104
101
  }
105
- const text = result.trim();
106
102
  if (text.startsWith("BLOCKED:"))
107
103
  throw new Error(text);
108
104
  cacheSet(nl, text);
109
105
  return text;
110
106
  }
111
107
  // ── prefetch ──────────────────────────────────────────────────────────────────
112
- // Silently warm the cache after a command runs — no await, fire and forget
113
108
  export function prefetchNext(lastNl, perms, sessionCmds) {
114
- // Only prefetch if we don't have it cached already
115
109
  if (cacheGet(lastNl))
116
110
  return;
117
111
  translateToCommand(lastNl, perms, sessionCmds).catch(() => { });
118
112
  }
119
113
  // ── explain ───────────────────────────────────────────────────────────────────
120
114
  export async function explainCommand(command) {
121
- const message = await client.messages.create({
122
- model: "claude-haiku-4-5-20251001",
123
- max_tokens: 128,
115
+ const provider = getProvider();
116
+ const routing = pickModel("explain"); // simple = fast model
117
+ return provider.complete(command, {
118
+ model: routing.fast,
119
+ maxTokens: 128,
124
120
  system: "Explain what this shell command does in one plain English sentence. No markdown, no code blocks.",
125
- messages: [{ role: "user", content: command }],
126
121
  });
127
- const block = message.content[0];
128
- if (block.type !== "text")
129
- return "";
130
- return block.text.trim();
131
122
  }
132
123
  // ── auto-fix ──────────────────────────────────────────────────────────────────
133
124
  export async function fixCommand(originalNl, failedCommand, errorOutput, perms, sessionCmds) {
134
- const message = await client.messages.create({
135
- model: "claude-sonnet-4-6",
136
- max_tokens: 256,
125
+ const provider = getProvider();
126
+ const routing = pickModel(originalNl);
127
+ const text = await provider.complete(`I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`, {
128
+ model: routing.smart, // always use smart model for fixes
129
+ maxTokens: 256,
137
130
  system: buildSystemPrompt(perms, sessionCmds),
138
- messages: [{
139
- role: "user",
140
- content: `I wanted to: ${originalNl}\nI ran: ${failedCommand}\nError:\n${errorOutput}\n\nGive me the corrected command only.`,
141
- }],
142
131
  });
143
- const block = message.content[0];
144
- if (block.type !== "text")
145
- throw new Error("Unexpected response type");
146
- const text = block.text.trim();
147
132
  if (text.startsWith("BLOCKED:"))
148
133
  throw new Error(text);
149
134
  return text;
150
135
  }
136
+ // ── summarize output (for MCP/agent use) ──────────────────────────────────────
137
+ export async function summarizeOutput(command, output, maxTokens = 200) {
138
+ const provider = getProvider();
139
+ const routing = pickModel("summarize");
140
+ 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.`, {
141
+ model: routing.fast,
142
+ maxTokens,
143
+ system: "You summarize command output for AI agents. Be extremely concise. Return structured info. No prose.",
144
+ });
145
+ }
package/dist/cli.js CHANGED
@@ -1,10 +1,142 @@
1
1
  #!/usr/bin/env node
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { render } from "ink";
4
- import App from "./App.js";
5
- if (!process.env.ANTHROPIC_API_KEY) {
6
- console.error("terminal: ANTHROPIC_API_KEY is not set.");
7
- console.error("Add it to your shell: export ANTHROPIC_API_KEY=your_key");
8
- process.exit(1);
4
+ const args = process.argv.slice(2);
5
+ // ── MCP commands ─────────────────────────────────────────────────────────────
6
+ if (args[0] === "mcp") {
7
+ if (args[1] === "serve" || args.length === 1) {
8
+ const { startMcpServer } = await import("./mcp/server.js");
9
+ startMcpServer().catch((err) => {
10
+ console.error("MCP server error:", err);
11
+ process.exit(1);
12
+ });
13
+ }
14
+ else if (args[1] === "install") {
15
+ const { handleMcpInstall } = await import("./mcp/install.js");
16
+ handleMcpInstall(args.slice(2));
17
+ }
18
+ else {
19
+ console.log("Usage: t mcp [serve|install]");
20
+ }
21
+ }
22
+ // ── Recipe commands ──────────────────────────────────────────────────────────
23
+ else if (args[0] === "recipe") {
24
+ const { listRecipes, getRecipe, createRecipe, deleteRecipe, listCollections, createCollection } = await import("./recipes/storage.js");
25
+ const { substituteVariables } = await import("./recipes/model.js");
26
+ const sub = args[1];
27
+ if (sub === "list") {
28
+ const collection = args.find(a => a.startsWith("--collection="))?.split("=")[1];
29
+ let recipes = listRecipes(process.cwd());
30
+ if (collection)
31
+ recipes = recipes.filter(r => r.collection === collection);
32
+ if (recipes.length === 0) {
33
+ console.log("No recipes found.");
34
+ }
35
+ else {
36
+ for (const r of recipes) {
37
+ const scope = r.project ? "(project)" : "(global)";
38
+ const col = r.collection ? ` [${r.collection}]` : "";
39
+ console.log(` ${r.name}${col} ${scope} → ${r.command}`);
40
+ }
41
+ }
42
+ }
43
+ else if (sub === "add" && args[2] && args[3]) {
44
+ const name = args[2];
45
+ const command = args[3];
46
+ const collection = args.find(a => a.startsWith("--collection="))?.split("=")[1];
47
+ const project = args.includes("--project") ? process.cwd() : undefined;
48
+ const recipe = createRecipe({ name, command, collection, project });
49
+ console.log(`✓ Saved recipe '${recipe.name}' → ${recipe.command}`);
50
+ }
51
+ else if (sub === "run" && args[2]) {
52
+ const recipe = getRecipe(args[2], process.cwd());
53
+ if (!recipe) {
54
+ console.error(`Recipe '${args[2]}' not found.`);
55
+ process.exit(1);
56
+ }
57
+ // Parse --var=value arguments
58
+ const vars = {};
59
+ for (const arg of args.slice(3)) {
60
+ const match = arg.match(/^--(\w+)=(.+)$/);
61
+ if (match)
62
+ vars[match[1]] = match[2];
63
+ }
64
+ const cmd = substituteVariables(recipe.command, vars);
65
+ console.log(`$ ${cmd}`);
66
+ const { execSync } = await import("child_process");
67
+ try {
68
+ execSync(cmd, { stdio: "inherit", cwd: process.cwd() });
69
+ }
70
+ catch (e) {
71
+ process.exit(e.status ?? 1);
72
+ }
73
+ }
74
+ else if (sub === "delete" && args[2]) {
75
+ const ok = deleteRecipe(args[2], process.cwd());
76
+ console.log(ok ? `✓ Deleted recipe '${args[2]}'` : `Recipe '${args[2]}' not found.`);
77
+ }
78
+ else {
79
+ console.log("Usage: t recipe [add|list|run|delete]");
80
+ console.log(" t recipe add <name> <command> [--collection=X] [--project]");
81
+ console.log(" t recipe list [--collection=X]");
82
+ console.log(" t recipe run <name> [--var=value]");
83
+ console.log(" t recipe delete <name>");
84
+ }
85
+ }
86
+ // ── Collection commands ──────────────────────────────────────────────────────
87
+ else if (args[0] === "collection") {
88
+ const { listCollections, createCollection } = await import("./recipes/storage.js");
89
+ const sub = args[1];
90
+ if (sub === "create" && args[2]) {
91
+ const col = createCollection({ name: args[2], description: args[3], project: args.includes("--project") ? process.cwd() : undefined });
92
+ console.log(`✓ Created collection '${col.name}'`);
93
+ }
94
+ else if (sub === "list") {
95
+ const cols = listCollections(process.cwd());
96
+ if (cols.length === 0)
97
+ console.log("No collections.");
98
+ else
99
+ for (const c of cols)
100
+ console.log(` ${c.name}${c.description ? ` — ${c.description}` : ""}`);
101
+ }
102
+ else {
103
+ console.log("Usage: t collection [create|list]");
104
+ }
105
+ }
106
+ // ── Stats command ────────────────────────────────────────────────────────────
107
+ else if (args[0] === "stats") {
108
+ const { getEconomyStats, formatTokens } = await import("./economy.js");
109
+ const s = getEconomyStats();
110
+ console.log("Token Economy:");
111
+ console.log(` Total saved: ${formatTokens(s.totalTokensSaved)}`);
112
+ console.log(` Total used: ${formatTokens(s.totalTokensUsed)}`);
113
+ console.log(` By feature:`);
114
+ console.log(` Structured: ${formatTokens(s.savingsByFeature.structured)}`);
115
+ console.log(` Compressed: ${formatTokens(s.savingsByFeature.compressed)}`);
116
+ console.log(` Diff cache: ${formatTokens(s.savingsByFeature.diff)}`);
117
+ console.log(` NL cache: ${formatTokens(s.savingsByFeature.cache)}`);
118
+ console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
119
+ }
120
+ // ── Snapshot command ─────────────────────────────────────────────────────────
121
+ else if (args[0] === "snapshot") {
122
+ const { captureSnapshot } = await import("./snapshots.js");
123
+ console.log(JSON.stringify(captureSnapshot(), null, 2));
124
+ }
125
+ // ── Project init ─────────────────────────────────────────────────────────────
126
+ else if (args[0] === "project" && args[1] === "init") {
127
+ const { initProject } = await import("./recipes/storage.js");
128
+ initProject(process.cwd());
129
+ console.log("✓ Initialized .terminal/recipes.json");
130
+ }
131
+ // ── TUI mode (default) ──────────────────────────────────────────────────────
132
+ else {
133
+ if (!process.env.ANTHROPIC_API_KEY && !process.env.CEREBRAS_API_KEY) {
134
+ console.error("terminal: No API key found.");
135
+ console.error("Set one of:");
136
+ console.error(" export CEREBRAS_API_KEY=your_key (free, open-source)");
137
+ console.error(" export ANTHROPIC_API_KEY=your_key (Claude)");
138
+ process.exit(1);
139
+ }
140
+ const App = (await import("./App.js")).default;
141
+ render(_jsx(App, {}));
9
142
  }
10
- render(_jsx(App, {}));
@@ -0,0 +1,107 @@
1
+ // Token compression engine — reduces CLI output to fit within token budgets
2
+ import { parseOutput, estimateTokens, tokenSavings } from "./parsers/index.js";
3
+ /** Strip ANSI escape codes from text */
4
+ export function stripAnsi(text) {
5
+ // eslint-disable-next-line no-control-regex
6
+ return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
7
+ }
8
+ /** Deduplicate consecutive similar lines (e.g., "Compiling X... Compiling Y...") */
9
+ function deduplicateLines(lines) {
10
+ if (lines.length <= 3)
11
+ return lines;
12
+ const result = [];
13
+ let repeatCount = 0;
14
+ let repeatPattern = "";
15
+ for (let i = 0; i < lines.length; i++) {
16
+ const line = lines[i];
17
+ // Extract a "pattern" — the line without numbers, paths, specific identifiers
18
+ const pattern = line.replace(/[0-9]+/g, "N").replace(/\/\S+/g, "/PATH").replace(/\s+/g, " ").trim();
19
+ if (pattern === repeatPattern) {
20
+ repeatCount++;
21
+ }
22
+ else {
23
+ if (repeatCount > 2) {
24
+ result.push(` ... (${repeatCount} similar lines)`);
25
+ }
26
+ else if (repeatCount > 0) {
27
+ // Push the skipped lines back
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 N + last M 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
+ // Keep proportional first/last, with first getting more
54
+ const targetLines = Math.floor((maxTokens * lines.length) / currentTokens);
55
+ const firstCount = Math.ceil(targetLines * 0.6);
56
+ const lastCount = Math.floor(targetLines * 0.4);
57
+ if (firstCount + lastCount >= lines.length)
58
+ return text;
59
+ const first = lines.slice(0, firstCount);
60
+ const last = lines.slice(-lastCount);
61
+ const hiddenCount = lines.length - firstCount - lastCount;
62
+ return [...first, `\n--- ${hiddenCount} lines hidden ---\n`, ...last].join("\n");
63
+ }
64
+ /** Compress command output to fit within a token budget */
65
+ export function compress(command, output, options = {}) {
66
+ const { maxTokens, format = "text", stripAnsi: doStrip = true } = options;
67
+ const originalTokens = estimateTokens(output);
68
+ // Step 1: Strip ANSI codes
69
+ 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
+ // If within budget or no budget, return structured
78
+ if (!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
91
+ const lines = text.split("\n");
92
+ const deduped = deduplicateLines(lines);
93
+ text = deduped.join("\n");
94
+ // Step 4: Smart truncation if over budget
95
+ if (maxTokens) {
96
+ text = smartTruncate(text, maxTokens);
97
+ }
98
+ const compressedTokens = estimateTokens(text);
99
+ return {
100
+ content: text,
101
+ format: "text",
102
+ originalTokens,
103
+ compressedTokens,
104
+ tokensSaved: Math.max(0, originalTokens - compressedTokens),
105
+ savingsPercent: originalTokens > 0 ? Math.round(((originalTokens - compressedTokens) / originalTokens) * 100) : 0,
106
+ };
107
+ }
@@ -0,0 +1,42 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { compress, stripAnsi } from "./compression.js";
3
+ describe("stripAnsi", () => {
4
+ it("removes ANSI escape codes", () => {
5
+ expect(stripAnsi("\x1b[31mred\x1b[0m")).toBe("red");
6
+ expect(stripAnsi("\x1b[1;32mbold green\x1b[0m")).toBe("bold green");
7
+ });
8
+ it("leaves clean text unchanged", () => {
9
+ expect(stripAnsi("hello world")).toBe("hello world");
10
+ });
11
+ });
12
+ describe("compress", () => {
13
+ it("strips ANSI by default", () => {
14
+ const result = compress("ls", "\x1b[32mfile.ts\x1b[0m");
15
+ expect(result.content).not.toContain("\x1b");
16
+ });
17
+ it("uses structured parser when format=json", () => {
18
+ const output = `total 16
19
+ -rw-r--r-- 1 user staff 450 Mar 10 09:00 package.json
20
+ drwxr-xr-x 5 user staff 160 Mar 10 09:00 src`;
21
+ const result = compress("ls -la", output, { format: "json" });
22
+ // Parser may or may not save tokens on small input, just check it parsed
23
+ expect(result.content).toBeTruthy();
24
+ const parsed = JSON.parse(result.content);
25
+ expect(Array.isArray(parsed)).toBe(true);
26
+ });
27
+ it("respects maxTokens budget", () => {
28
+ const longOutput = Array.from({ length: 100 }, (_, i) => `Line ${i}: some output text here`).join("\n");
29
+ const result = compress("some-command", longOutput, { maxTokens: 50 });
30
+ expect(result.compressedTokens).toBeLessThanOrEqual(60); // allow some slack
31
+ });
32
+ it("deduplicates similar lines", () => {
33
+ const output = Array.from({ length: 20 }, (_, i) => `Compiling module ${i}...`).join("\n");
34
+ const result = compress("build", output);
35
+ expect(result.compressedTokens).toBeLessThan(result.originalTokens);
36
+ });
37
+ it("tracks savings on large output", () => {
38
+ const output = Array.from({ length: 100 }, (_, i) => `Line ${i}: some long output text here that takes tokens`).join("\n");
39
+ const result = compress("cmd", output, { maxTokens: 50 });
40
+ expect(result.compressedTokens).toBeLessThan(result.originalTokens);
41
+ });
42
+ });