@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
@@ -43,16 +43,29 @@ export function discoverProjectHints(cwd: string): string[] {
43
43
  }
44
44
  }
45
45
 
46
- // Extract rich metadata from package.json
46
+ // Extract metadata from package.json — trimmed to save tokens
47
47
  const pkgPath = join(cwd, "package.json");
48
48
  if (existsSync(pkgPath)) {
49
49
  try {
50
50
  const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
51
- if (pkg.name) hints.push(`Package name: ${pkg.name}@${pkg.version ?? "unknown"}`);
51
+ if (pkg.name) hints.push(`Package: ${pkg.name}@${pkg.version ?? "?"}`);
52
52
  if (pkg.scripts) {
53
- hints.push(`Available scripts: ${Object.entries(pkg.scripts).map(([k, v]) => `${k}: ${v}`).slice(0, 10).join(", ")}`);
53
+ // Only top-5 most useful scripts
54
+ const priority = ["dev", "build", "test", "lint", "start", "typecheck", "check"];
55
+ const scripts = Object.keys(pkg.scripts);
56
+ const top = priority.filter(s => scripts.includes(s));
57
+ const rest = scripts.filter(s => !priority.includes(s)).slice(0, Math.max(0, 5 - top.length));
58
+ hints.push(`Scripts: ${[...top, ...rest].join(", ")}`);
59
+ }
60
+ if (pkg.dependencies) {
61
+ // Only framework/major deps — skip utility libs
62
+ const major = ["react", "next", "express", "fastify", "hono", "vue", "angular", "svelte",
63
+ "prisma", "drizzle", "mongoose", "typeorm", "zod", "trpc", "graphql", "tailwindcss",
64
+ "electron", "bun", "elysia", "nest", "nuxt", "remix", "astro", "vite"];
65
+ const deps = Object.keys(pkg.dependencies);
66
+ const found = deps.filter(d => major.some(m => d.includes(m)));
67
+ if (found.length > 0) hints.push(`Key deps: ${found.slice(0, 10).join(", ")}`);
54
68
  }
55
- if (pkg.dependencies) hints.push(`Dependencies: ${Object.keys(pkg.dependencies).join(", ")}`);
56
69
  } catch {}
57
70
  }
58
71
 
@@ -107,23 +120,20 @@ export function discoverProjectHints(cwd: string): string[] {
107
120
  } catch {}
108
121
  }
109
122
 
110
- // Source directory structure
123
+ // Source directory structure — max 20 files to save tokens
111
124
  try {
112
125
  const { execSync } = require("child_process");
113
126
  const srcDirs = ["src", "lib", "app", "packages"];
114
127
  for (const dir of srcDirs) {
115
128
  if (existsSync(join(cwd, dir))) {
116
129
  const tree = execSync(
117
- `find ${dir} -maxdepth 3 -not -path '*/node_modules/*' -not -path '*/dist/*' -not -name '*.test.*' 2>/dev/null | sort | head -60`,
118
- { cwd, encoding: "utf8", timeout: 3000 }
130
+ `find ${dir} -maxdepth 2 -not -path '*/node_modules/*' -not -path '*/dist/*' -not -name '*.test.*' -not -name '*.spec.*' 2>/dev/null | sort | head -20`,
131
+ { cwd, encoding: "utf8", timeout: 2000 }
119
132
  ).trim();
120
133
  if (tree) hints.push(`Files in ${dir}/:\n${tree}`);
121
134
  break;
122
135
  }
123
136
  }
124
- // Top-level files
125
- const topLevel = execSync("ls -1", { cwd, encoding: "utf8", timeout: 1000 }).trim();
126
- hints.push(`Top-level: ${topLevel.split("\n").join(", ")}`);
127
137
  } catch {}
128
138
 
129
139
  return hints;
package/src/diff-cache.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // Diff-aware output caching — when same command runs again, return only what changed
2
2
 
3
- import { estimateTokens } from "./parsers/index.js";
3
+ import { estimateTokens } from "./tokens.js";
4
4
 
5
5
  interface CachedOutput {
6
6
  command: string;
package/src/discover.ts CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import { readdirSync, readFileSync, statSync, existsSync } from "fs";
6
6
  import { join } from "path";
7
- import { estimateTokens } from "./parsers/index.js";
7
+ import { estimateTokens } from "./tokens.js";
8
8
 
9
9
  export interface DiscoveredCommand {
10
10
  command: string;
package/src/economy.ts CHANGED
@@ -62,13 +62,30 @@ function loadStats(): EconomyStats {
62
62
  return stats;
63
63
  }
64
64
 
65
+ let _saveTimer: ReturnType<typeof setTimeout> | null = null;
66
+
65
67
  function saveStats() {
66
- ensureDir();
67
- if (stats) {
68
- writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
69
- }
68
+ // Debounce: coalesce multiple writes within 1 second
69
+ if (_saveTimer) return;
70
+ _saveTimer = setTimeout(() => {
71
+ _saveTimer = null;
72
+ ensureDir();
73
+ if (stats) {
74
+ writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
75
+ }
76
+ }, 1000);
70
77
  }
71
78
 
79
+ // Flush on exit
80
+ process.on("exit", () => {
81
+ if (_saveTimer) {
82
+ clearTimeout(_saveTimer);
83
+ _saveTimer = null;
84
+ ensureDir();
85
+ if (stats) writeFileSync(ECONOMY_FILE, JSON.stringify(stats, null, 2));
86
+ }
87
+ });
88
+
72
89
  /** Record token savings from a feature */
73
90
  export function recordSaving(feature: keyof EconomyStats["savingsByFeature"], tokensSaved: number) {
74
91
  const s = loadStats();
@@ -112,12 +129,27 @@ const PROVIDER_PRICING: Record<string, { input: number; output: number }> = {
112
129
  "anthropic-opus": { input: 5.00, output: 25.00 },
113
130
  };
114
131
 
132
+ /** Load configurable turns-before-compaction from ~/.terminal/config.json */
133
+ function loadTurnsMultiplier(): number {
134
+ try {
135
+ const configPath = join(DIR, "config.json");
136
+ if (existsSync(configPath)) {
137
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
138
+ return config.economy?.turnsBeforeCompaction ?? 5;
139
+ }
140
+ } catch {}
141
+ return 5; // Default: tokens saved are repeated ~5 turns before agent compacts context
142
+ }
143
+
115
144
  /** Estimate USD savings from compressed tokens */
116
145
  export function estimateSavingsUsd(
117
146
  tokensSaved: number,
118
147
  consumerModel: string = "anthropic-opus",
119
- avgTurnsBeforeCompaction: number = 5,
148
+ avgTurnsBeforeCompaction?: number,
120
149
  ): { savingsUsd: number; multipliedTokens: number; ratePerMillion: number } {
150
+ if (avgTurnsBeforeCompaction === undefined) {
151
+ avgTurnsBeforeCompaction = loadTurnsMultiplier();
152
+ }
121
153
  const pricing = PROVIDER_PRICING[consumerModel] ?? PROVIDER_PRICING["anthropic-opus"];
122
154
  const multipliedTokens = tokensSaved * avgTurnsBeforeCompaction;
123
155
  const savingsUsd = (multipliedTokens * pricing.input) / 1_000_000;
@@ -26,6 +26,11 @@ export function storeOutput(command: string, output: string): string {
26
26
  return key;
27
27
  }
28
28
 
29
+ /** Escape regex special characters for safe use in new RegExp() */
30
+ function escapeRegex(str: string): string {
31
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
32
+ }
33
+
29
34
  /** Retrieve full output by key, optionally filtered */
30
35
  export function expandOutput(key: string, grep?: string): { found: boolean; output?: string; lines?: number } {
31
36
  const entry = store.get(key);
@@ -33,7 +38,9 @@ export function expandOutput(key: string, grep?: string): { found: boolean; outp
33
38
 
34
39
  let output = entry.output;
35
40
  if (grep) {
36
- const pattern = new RegExp(grep, "i");
41
+ // Escape metacharacters so user input like "[error" or "func()" doesn't crash
42
+ const safe = escapeRegex(grep);
43
+ const pattern = new RegExp(safe, "i");
37
44
  output = output.split("\n").filter(l => pattern.test(l)).join("\n");
38
45
  }
39
46
 
package/src/mcp/server.ts CHANGED
@@ -6,17 +6,16 @@ import { z } from "zod";
6
6
  import { spawn } from "child_process";
7
7
  import { compress, stripAnsi } from "../compression.js";
8
8
  import { stripNoise } from "../noise-filter.js";
9
- import { parseOutput, tokenSavings, estimateTokens } from "../parsers/index.js";
10
- import { summarizeOutput } from "../ai.js";
9
+ import { estimateTokens } from "../tokens.js";
10
+ import { processOutput } from "../output-processor.js";
11
11
  import { searchFiles, searchContent, semanticSearch } from "../search/index.js";
12
12
  import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipes/storage.js";
13
13
  import { substituteVariables } from "../recipes/model.js";
14
14
  import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
15
15
  import { diffOutput } from "../diff-cache.js";
16
- import { processOutput } from "../output-processor.js";
17
16
  import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
18
17
  import { cachedRead, cacheStats } from "../file-cache.js";
19
- import { getBootContext } from "../session-boot.js";
18
+ import { getBootContext, invalidateBootCache } from "../session-boot.js";
20
19
  import { storeOutput, expandOutput } from "../expand-store.js";
21
20
  import { rewriteCommand } from "../command-rewriter.js";
22
21
  import { shouldBeLazy, toLazy } from "../lazy-executor.js";
@@ -49,6 +48,10 @@ function exec(command: string, cwd?: string, timeout?: number): Promise<{ exitCo
49
48
  // Strip noise before returning (npm fund, progress bars, etc.)
50
49
  const cleanStdout = stripNoise(stdout).cleaned;
51
50
  const cleanStderr = stripNoise(stderr).cleaned;
51
+ // Invalidate boot cache after state-changing git commands
52
+ if (/\bgit\s+(commit|checkout|branch|merge|reset|push|pull|rebase|stash)\b/.test(actualCommand)) {
53
+ invalidateBootCache();
54
+ }
52
55
  resolve({ exitCode: code ?? 0, stdout: cleanStdout, stderr: cleanStderr, duration: Date.now() - start, rewritten: rw.changed ? rw.rewritten : undefined });
53
56
  });
54
57
  });
@@ -100,44 +103,20 @@ export function createServer(): McpServer {
100
103
  };
101
104
  }
102
105
 
103
- // JSON mode structured parsing (only if it actually saves tokens)
104
- if (format === "json") {
105
- const parsed = parseOutput(command, output);
106
- if (parsed) {
107
- const savings = tokenSavings(output, parsed.data);
108
- if (savings.saved > 0) {
109
- return {
110
- content: [{ type: "text" as const, text: JSON.stringify({
111
- exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
112
- duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
113
- }) }],
114
- };
115
- }
116
- // JSON was larger — fall through to compression
117
- }
118
- }
119
-
120
- // Compressed mode (also fallback for json when no parser matches)
121
- if (format === "compressed" || format === "json") {
122
- const compressed = compress(command, output, { maxTokens, format: "json" });
123
- return {
124
- content: [{ type: "text" as const, text: JSON.stringify({
125
- exitCode: result.exitCode, output: compressed.content, format: compressed.format,
126
- duration: result.duration, tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent,
127
- }) }],
128
- };
129
- }
130
-
131
- // Summary mode — AI-powered
132
- if (format === "summary") {
106
+ // JSON and Summary modes both go through AI processing
107
+ if (format === "json" || format === "summary") {
133
108
  try {
134
- const summary = await summarizeOutput(command, output, maxTokens ?? 200);
135
- const rawTokens = estimateTokens(output);
136
- const summaryTokens = estimateTokens(summary);
109
+ const processed = await processOutput(command, output);
110
+ const detailKey = output.split("\n").length > 15 ? storeOutput(command, output) : undefined;
137
111
  return {
138
112
  content: [{ type: "text" as const, text: JSON.stringify({
139
- exitCode: result.exitCode, summary, duration: result.duration,
140
- tokensSaved: rawTokens - summaryTokens,
113
+ exitCode: result.exitCode,
114
+ summary: processed.summary,
115
+ structured: processed.structured,
116
+ duration: result.duration,
117
+ tokensSaved: processed.tokensSaved,
118
+ aiProcessed: processed.aiProcessed,
119
+ ...(detailKey ? { detailKey, expandable: true } : {}),
141
120
  }) }],
142
121
  };
143
122
  } catch {
@@ -151,6 +130,17 @@ export function createServer(): McpServer {
151
130
  }
152
131
  }
153
132
 
133
+ // Compressed mode — fast non-AI: strip + dedup + truncate
134
+ if (format === "compressed") {
135
+ const compressed = compress(command, output, { maxTokens });
136
+ return {
137
+ content: [{ type: "text" as const, text: JSON.stringify({
138
+ exitCode: result.exitCode, output: compressed.content, duration: result.duration,
139
+ tokensSaved: compressed.tokensSaved, savingsPercent: compressed.savingsPercent,
140
+ }) }],
141
+ };
142
+ }
143
+
154
144
  return { content: [{ type: "text" as const, text: output }] };
155
145
  }
156
146
  );
@@ -230,16 +220,8 @@ export function createServer(): McpServer {
230
220
  }
231
221
 
232
222
  const result = await exec(command);
233
- const parsed = parseOutput(command, result.stdout);
234
-
235
- if (parsed) {
236
- return {
237
- content: [{ type: "text" as const, text: JSON.stringify({ cwd: target, ...parsed.data as object, parser: parsed.parser }) }],
238
- };
239
- }
240
-
241
223
  const files = result.stdout.split("\n").filter(l => l.trim());
242
- return { content: [{ type: "text" as const, text: JSON.stringify({ cwd: target, files }) }] };
224
+ return { content: [{ type: "text" as const, text: JSON.stringify({ cwd: target, files, count: files.length }) }] };
243
225
  }
244
226
  );
245
227
 
@@ -253,14 +235,13 @@ export function createServer(): McpServer {
253
235
  command: z.string().optional().describe("The command that produced the error"),
254
236
  },
255
237
  async ({ error, command }) => {
256
- const { errorParser } = await import("../parsers/errors.js");
257
- if (errorParser.detect(command ?? "", error)) {
258
- const info = errorParser.parse(command ?? "", error);
259
- return { content: [{ type: "text" as const, text: JSON.stringify(info) }] };
260
- }
238
+ // AI processes the error no regex guessing
239
+ const processed = await processOutput(command ?? "unknown", error);
261
240
  return {
262
241
  content: [{ type: "text" as const, text: JSON.stringify({
263
- type: "unknown", message: error.split("\n")[0]?.trim() ?? "Unknown error",
242
+ summary: processed.summary,
243
+ structured: processed.structured,
244
+ aiProcessed: processed.aiProcessed,
264
245
  }) }],
265
246
  };
266
247
  }
@@ -274,9 +255,8 @@ export function createServer(): McpServer {
274
255
  async () => {
275
256
  return {
276
257
  content: [{ type: "text" as const, text: JSON.stringify({
277
- name: "open-terminal", version: "0.2.0", cwd: process.cwd(),
278
- parsers: ["ls", "find", "test", "git-log", "git-status", "build", "npm-install", "error"],
279
- features: ["structured-output", "token-compression", "ai-summary", "error-diagnosis"],
258
+ name: "open-terminal", version: "0.3.0", cwd: process.cwd(),
259
+ features: ["ai-output-processing", "token-compression", "noise-filtering", "diff-caching", "lazy-execution", "progressive-disclosure"],
280
260
  }) }],
281
261
  };
282
262
  }
@@ -376,20 +356,12 @@ export function createServer(): McpServer {
376
356
  const result = await exec(command, cwd, 30000);
377
357
  const output = (result.stdout + result.stderr).trim();
378
358
 
379
- if (format === "json") {
380
- const parsed = parseOutput(command, output);
381
- if (parsed) {
382
- return { content: [{ type: "text" as const, text: JSON.stringify({
383
- recipe: name, exitCode: result.exitCode, parsed: parsed.data, duration: result.duration,
384
- }) }] };
385
- }
386
- }
387
-
388
- if (format === "compressed") {
389
- const compressed = compress(command, output, { format: "json" });
359
+ if (format === "json" || format === "compressed") {
360
+ const processed = await processOutput(command, output);
390
361
  return { content: [{ type: "text" as const, text: JSON.stringify({
391
- recipe: name, exitCode: result.exitCode, output: compressed.content, duration: result.duration,
392
- tokensSaved: compressed.tokensSaved,
362
+ recipe: name, exitCode: result.exitCode, summary: processed.summary,
363
+ structured: processed.structured, duration: result.duration,
364
+ tokensSaved: processed.tokensSaved, aiProcessed: processed.aiProcessed,
393
365
  }) }] };
394
366
  }
395
367
 
@@ -534,10 +506,10 @@ export function createServer(): McpServer {
534
506
  }) }] };
535
507
  }
536
508
 
537
- // First run — return full output
538
- const compressed = compress(command, output, { format: "json" });
509
+ // First run — return full output (ANSI stripped)
510
+ const clean = stripAnsi(output);
539
511
  return { content: [{ type: "text" as const, text: JSON.stringify({
540
- exitCode: result.exitCode, output: compressed.content,
512
+ exitCode: result.exitCode, output: clean,
541
513
  diffSummary: "first run", duration: result.duration,
542
514
  }) }] };
543
515
  }
@@ -2,10 +2,12 @@
2
2
  // NOTHING is hardcoded. The AI decides what's important, what's noise, what to keep.
3
3
 
4
4
  import { getProvider } from "./providers/index.js";
5
- import { estimateTokens } from "./parsers/index.js";
5
+ import { estimateTokens } from "./tokens.js";
6
6
  import { recordSaving } from "./economy.js";
7
7
  import { discoverOutputHints } from "./context-hints.js";
8
8
  import { formatProfileHints } from "./tool-profiles.js";
9
+ import { stripAnsi } from "./compression.js";
10
+ import { stripNoise } from "./noise-filter.js";
9
11
 
10
12
  export interface ProcessedOutput {
11
13
  /** AI-generated summary (concise, structured) */
@@ -29,7 +31,9 @@ export interface ProcessedOutput {
29
31
  }
30
32
 
31
33
  const MIN_LINES_TO_PROCESS = 15;
32
- const MAX_OUTPUT_FOR_AI = 8000; // chars to send to AI (truncate if longer)
34
+ // Reserve ~2000 chars for system prompt + hints + profile + overhead
35
+ const PROMPT_OVERHEAD_CHARS = 2000;
36
+ const MAX_OUTPUT_FOR_AI = 6000; // chars of output to send to AI (leaves room for prompt overhead)
33
37
 
34
38
  const SUMMARIZE_PROMPT = `You are an intelligent terminal assistant. Given a user's original question and the command output, ANSWER THE QUESTION directly.
35
39
 
@@ -70,8 +74,10 @@ export async function processOutput(
70
74
  };
71
75
  }
72
76
 
73
- // Truncate very long output before sending to AI
74
- let toSummarize = output;
77
+ // Clean output before AI processing — strip ANSI codes and noise
78
+ let toSummarize = stripAnsi(output);
79
+ toSummarize = stripNoise(toSummarize).cleaned;
80
+
75
81
  if (toSummarize.length > MAX_OUTPUT_FOR_AI) {
76
82
  const headChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.6);
77
83
  const tailChars = Math.floor(MAX_OUTPUT_FOR_AI * 0.3);
@@ -97,6 +103,7 @@ export async function processOutput(
97
103
  {
98
104
  system: SUMMARIZE_PROMPT,
99
105
  maxTokens: 300,
106
+ temperature: 0.2,
100
107
  }
101
108
  );
102
109
 
@@ -104,10 +111,6 @@ export async function processOutput(
104
111
  const summaryTokens = estimateTokens(summary);
105
112
  const saved = Math.max(0, originalTokens - summaryTokens);
106
113
 
107
- if (saved > 0) {
108
- recordSaving("compressed", saved);
109
- }
110
-
111
114
  // Try to extract structured JSON if the AI returned it
112
115
  let structured: Record<string, unknown> | undefined;
113
116
  try {
@@ -17,7 +17,9 @@ export class AnthropicProvider implements LLMProvider {
17
17
  const message = await this.client.messages.create({
18
18
  model: options.model ?? "claude-haiku-4-5-20251001",
19
19
  max_tokens: options.maxTokens ?? 256,
20
- system: options.system,
20
+ temperature: options.temperature ?? 0,
21
+ ...(options.stop ? { stop_sequences: options.stop } : {}),
22
+ system: [{ type: "text", text: options.system, cache_control: { type: "ephemeral" } }],
21
23
  messages: [{ role: "user", content: prompt }],
22
24
  });
23
25
  const block = message.content[0];
@@ -30,7 +32,9 @@ export class AnthropicProvider implements LLMProvider {
30
32
  const stream = await this.client.messages.stream({
31
33
  model: options.model ?? "claude-haiku-4-5-20251001",
32
34
  max_tokens: options.maxTokens ?? 256,
33
- system: options.system,
35
+ temperature: options.temperature ?? 0,
36
+ ...(options.stop ? { stop_sequences: options.stop } : {}),
37
+ system: [{ type: "text", text: options.system, cache_control: { type: "ephemeral" } }],
34
38
  messages: [{ role: "user", content: prompt }],
35
39
  });
36
40
  for await (const chunk of stream) {
@@ -3,6 +3,8 @@
3
3
  export interface ProviderOptions {
4
4
  model?: string;
5
5
  maxTokens?: number;
6
+ temperature?: number;
7
+ stop?: string[];
6
8
  system: string;
7
9
  }
8
10
 
@@ -1,108 +1,9 @@
1
- // Cerebras provider — uses OpenAI-compatible API
2
- // Default for open-source users. Fast inference on Llama models.
1
+ // Cerebras provider — fast inference on Qwen/Llama models
2
+ import { OpenAICompatibleProvider } from "./openai-compat.js";
3
3
 
4
- import type { LLMProvider, ProviderOptions, StreamCallbacks } from "./base.js";
5
-
6
- const CEREBRAS_BASE_URL = "https://api.cerebras.ai/v1";
7
- const DEFAULT_MODEL = "qwen-3-235b-a22b-instruct-2507";
8
-
9
- export class CerebrasProvider implements LLMProvider {
4
+ export class CerebrasProvider extends OpenAICompatibleProvider {
10
5
  readonly name = "cerebras";
11
- private apiKey: string;
12
-
13
- constructor() {
14
- this.apiKey = process.env.CEREBRAS_API_KEY ?? "";
15
- }
16
-
17
- isAvailable(): boolean {
18
- return !!process.env.CEREBRAS_API_KEY;
19
- }
20
-
21
- async complete(prompt: string, options: ProviderOptions): Promise<string> {
22
- const model = options.model ?? DEFAULT_MODEL;
23
- const res = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, {
24
- method: "POST",
25
- headers: {
26
- "Content-Type": "application/json",
27
- Authorization: `Bearer ${this.apiKey}`,
28
- },
29
- body: JSON.stringify({
30
- model,
31
- max_tokens: options.maxTokens ?? 256,
32
- messages: [
33
- { role: "system", content: options.system },
34
- { role: "user", content: prompt },
35
- ],
36
- }),
37
- });
38
-
39
- if (!res.ok) {
40
- const text = await res.text();
41
- throw new Error(`Cerebras API error ${res.status}: ${text}`);
42
- }
43
-
44
- const json = (await res.json()) as any;
45
- return (json.choices?.[0]?.message?.content ?? "").trim();
46
- }
47
-
48
- async stream(prompt: string, options: ProviderOptions, callbacks: StreamCallbacks): Promise<string> {
49
- const model = options.model ?? DEFAULT_MODEL;
50
- const res = await fetch(`${CEREBRAS_BASE_URL}/chat/completions`, {
51
- method: "POST",
52
- headers: {
53
- "Content-Type": "application/json",
54
- Authorization: `Bearer ${this.apiKey}`,
55
- },
56
- body: JSON.stringify({
57
- model,
58
- max_tokens: options.maxTokens ?? 256,
59
- stream: true,
60
- messages: [
61
- { role: "system", content: options.system },
62
- { role: "user", content: prompt },
63
- ],
64
- }),
65
- });
66
-
67
- if (!res.ok) {
68
- const text = await res.text();
69
- throw new Error(`Cerebras API error ${res.status}: ${text}`);
70
- }
71
-
72
- let result = "";
73
- const reader = res.body?.getReader();
74
- if (!reader) throw new Error("No response body");
75
-
76
- const decoder = new TextDecoder();
77
- let buffer = "";
78
-
79
- while (true) {
80
- const { done, value } = await reader.read();
81
- if (done) break;
82
-
83
- buffer += decoder.decode(value, { stream: true });
84
- const lines = buffer.split("\n");
85
- buffer = lines.pop() ?? "";
86
-
87
- for (const line of lines) {
88
- const trimmed = line.trim();
89
- if (!trimmed.startsWith("data: ")) continue;
90
- const data = trimmed.slice(6);
91
- if (data === "[DONE]") break;
92
-
93
- try {
94
- const parsed = JSON.parse(data) as any;
95
- const delta = parsed.choices?.[0]?.delta?.content;
96
- if (delta) {
97
- result += delta;
98
- callbacks.onToken(result.trim());
99
- }
100
- } catch {
101
- // skip malformed chunks
102
- }
103
- }
104
- }
105
-
106
- return result.trim();
107
- }
6
+ protected readonly baseUrl = "https://api.cerebras.ai/v1";
7
+ protected readonly defaultModel = "qwen-3-235b-a22b-instruct-2507";
8
+ protected readonly apiKeyEnvVar = "CEREBRAS_API_KEY";
108
9
  }