@hasna/terminal 0.5.1 → 0.5.3

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.
package/dist/App.js CHANGED
@@ -69,7 +69,7 @@ export default function App() {
69
69
  const [activeTab, setActiveTab] = useState(0);
70
70
  const abortRef = useRef(null);
71
71
  let nextTabId = useRef(2);
72
- const sessionIdRef = useRef(createSession(process.cwd()));
72
+ const sessionIdRef = useRef("");
73
73
  const interactionIdRef = useRef(0);
74
74
  const tab = tabs[activeTab];
75
75
  const allNl = [...nlHistory, ...tab.sessionNl];
@@ -158,6 +158,10 @@ export default function App() {
158
158
  // ── translate + run ─────────────────────────────────────────────────────────
159
159
  const translateAndRun = async (nl, raw) => {
160
160
  updateTab(t => ({ ...t, sessionNl: [...t.sessionNl, nl] }));
161
+ // Lazy session creation — only when user actually types something
162
+ if (!sessionIdRef.current) {
163
+ sessionIdRef.current = createSession(process.cwd());
164
+ }
161
165
  // Log interaction start
162
166
  const startTime = Date.now();
163
167
  interactionIdRef.current = logInteraction(sessionIdRef.current, { nl });
@@ -74,8 +74,8 @@ export function compress(command, output, options = {}) {
74
74
  const json = JSON.stringify(parsed.data, null, format === "summary" ? 0 : 2);
75
75
  const savings = tokenSavings(output, parsed.data);
76
76
  const compressedTokens = estimateTokens(json);
77
- // If within budget or no budget, return structured
78
- if (!maxTokens || compressedTokens <= maxTokens) {
77
+ // ONLY use JSON if it actually saves tokens (never return larger output)
78
+ if (savings.saved > 0 && (!maxTokens || compressedTokens <= maxTokens)) {
79
79
  return {
80
80
  content: json,
81
81
  format: "json",
@@ -64,17 +64,20 @@ export function createServer() {
64
64
  }) }],
65
65
  };
66
66
  }
67
- // JSON mode — structured parsing
67
+ // JSON mode — structured parsing (only if it actually saves tokens)
68
68
  if (format === "json") {
69
69
  const parsed = parseOutput(command, output);
70
70
  if (parsed) {
71
71
  const savings = tokenSavings(output, parsed.data);
72
- return {
73
- content: [{ type: "text", text: JSON.stringify({
74
- exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
75
- duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
76
- }) }],
77
- };
72
+ if (savings.saved > 0) {
73
+ return {
74
+ content: [{ type: "text", text: JSON.stringify({
75
+ exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
76
+ duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
77
+ }) }],
78
+ };
79
+ }
80
+ // JSON was larger — fall through to compression
78
81
  }
79
82
  }
80
83
  // Compressed mode (also fallback for json when no parser matches)
@@ -32,7 +32,11 @@ export async function processOutput(command, output) {
32
32
  summary: output,
33
33
  full: output,
34
34
  tokensSaved: 0,
35
+ aiTokensUsed: 0,
35
36
  aiProcessed: false,
37
+ aiCostUsd: 0,
38
+ savingsValueUsd: 0,
39
+ netSavingsUsd: 0,
36
40
  };
37
41
  }
38
42
  // Truncate very long output before sending to AI
@@ -65,12 +69,30 @@ export async function processOutput(command, output) {
65
69
  }
66
70
  }
67
71
  catch { /* not JSON, that's fine */ }
72
+ // Cost calculation
73
+ // AI input: system prompt (~200 tokens) + command + output sent to AI
74
+ const aiInputTokens = estimateTokens(SUMMARIZE_PROMPT) + estimateTokens(toSummarize) + 20;
75
+ const aiOutputTokens = summaryTokens;
76
+ const aiTokensUsed = aiInputTokens + aiOutputTokens;
77
+ // Cerebras qwen-3-235b pricing: $0.60/M input, $1.20/M output
78
+ const aiCostUsd = (aiInputTokens * 0.60 + aiOutputTokens * 1.20) / 1_000_000;
79
+ // Value of tokens saved (at Claude Sonnet $3/M input — what the agent would pay)
80
+ const savingsValueUsd = (saved * 3.0) / 1_000_000;
81
+ const netSavingsUsd = savingsValueUsd - aiCostUsd;
82
+ // Only record savings if net positive (AI cost < token savings value)
83
+ if (netSavingsUsd > 0 && saved > 0) {
84
+ recordSaving("compressed", saved);
85
+ }
68
86
  return {
69
87
  summary,
70
88
  full: output,
71
89
  structured,
72
90
  tokensSaved: saved,
91
+ aiTokensUsed,
73
92
  aiProcessed: true,
93
+ aiCostUsd,
94
+ savingsValueUsd,
95
+ netSavingsUsd,
74
96
  };
75
97
  }
76
98
  catch {
@@ -82,7 +104,11 @@ export async function processOutput(command, output) {
82
104
  summary: fallback,
83
105
  full: output,
84
106
  tokensSaved: Math.max(0, estimateTokens(output) - estimateTokens(fallback)),
107
+ aiTokensUsed: 0,
85
108
  aiProcessed: false,
109
+ aiCostUsd: 0,
110
+ savingsValueUsd: 0,
111
+ netSavingsUsd: 0,
86
112
  };
87
113
  }
88
114
  }
@@ -45,6 +45,20 @@ export function getRecipe(name, projectPath) {
45
45
  export function createRecipe(opts) {
46
46
  const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
47
47
  const store = loadStore(filePath);
48
+ // Prevent duplicates — update existing if same name
49
+ const existingIdx = store.recipes.findIndex(r => r.name === opts.name);
50
+ if (existingIdx >= 0) {
51
+ store.recipes[existingIdx].command = opts.command;
52
+ store.recipes[existingIdx].updatedAt = Date.now();
53
+ if (opts.description)
54
+ store.recipes[existingIdx].description = opts.description;
55
+ if (opts.tags)
56
+ store.recipes[existingIdx].tags = opts.tags;
57
+ if (opts.collection)
58
+ store.recipes[existingIdx].collection = opts.collection;
59
+ saveStore(filePath, store);
60
+ return store.recipes[existingIdx];
61
+ }
48
62
  // Auto-detect variables from command if not explicitly provided
49
63
  const detectedVars = extractVariables(opts.command);
50
64
  const variables = opts.variables ?? detectedVars.map(name => ({ name, required: true }));
@@ -98,6 +112,10 @@ export function listCollections(projectPath) {
98
112
  export function createCollection(opts) {
99
113
  const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
100
114
  const store = loadStore(filePath);
115
+ // Prevent duplicates — return existing if same name
116
+ const existing = store.collections.find(c => c.name === opts.name);
117
+ if (existing)
118
+ return existing;
101
119
  const collection = {
102
120
  id: genId(),
103
121
  name: opts.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/terminal",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "Smart terminal wrapper for AI agents and humans — structured output, token compression, MCP server, natural language",
5
5
  "type": "module",
6
6
  "bin": {
package/src/App.tsx CHANGED
@@ -110,7 +110,7 @@ export default function App() {
110
110
  const [activeTab, setActiveTab] = useState(0);
111
111
  const abortRef = useRef<AbortController | null>(null);
112
112
  let nextTabId = useRef(2);
113
- const sessionIdRef = useRef<string>(createSession(process.cwd()));
113
+ const sessionIdRef = useRef<string>("");
114
114
  const interactionIdRef = useRef<number>(0);
115
115
 
116
116
  const tab = tabs[activeTab];
@@ -217,6 +217,10 @@ export default function App() {
217
217
  const translateAndRun = async (nl: string, raw: boolean) => {
218
218
  updateTab(t => ({ ...t, sessionNl: [...t.sessionNl, nl] }));
219
219
 
220
+ // Lazy session creation — only when user actually types something
221
+ if (!sessionIdRef.current) {
222
+ sessionIdRef.current = createSession(process.cwd());
223
+ }
220
224
  // Log interaction start
221
225
  const startTime = Date.now();
222
226
  interactionIdRef.current = logInteraction(sessionIdRef.current, { nl });
@@ -104,8 +104,8 @@ export function compress(command: string, output: string, options: CompressOptio
104
104
  const savings = tokenSavings(output, parsed.data);
105
105
  const compressedTokens = estimateTokens(json);
106
106
 
107
- // If within budget or no budget, return structured
108
- if (!maxTokens || compressedTokens <= maxTokens) {
107
+ // ONLY use JSON if it actually saves tokens (never return larger output)
108
+ if (savings.saved > 0 && (!maxTokens || compressedTokens <= maxTokens)) {
109
109
  return {
110
110
  content: json,
111
111
  format: "json",
package/src/mcp/server.ts CHANGED
@@ -77,17 +77,20 @@ export function createServer(): McpServer {
77
77
  };
78
78
  }
79
79
 
80
- // JSON mode — structured parsing
80
+ // JSON mode — structured parsing (only if it actually saves tokens)
81
81
  if (format === "json") {
82
82
  const parsed = parseOutput(command, output);
83
83
  if (parsed) {
84
84
  const savings = tokenSavings(output, parsed.data);
85
- return {
86
- content: [{ type: "text" as const, text: JSON.stringify({
87
- exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
88
- duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
89
- }) }],
90
- };
85
+ if (savings.saved > 0) {
86
+ return {
87
+ content: [{ type: "text" as const, text: JSON.stringify({
88
+ exitCode: result.exitCode, parsed: parsed.data, parser: parsed.parser,
89
+ duration: result.duration, tokensSaved: savings.saved, savingsPercent: savings.percent,
90
+ }) }],
91
+ };
92
+ }
93
+ // JSON was larger — fall through to compression
91
94
  }
92
95
  }
93
96
 
@@ -12,10 +12,18 @@ export interface ProcessedOutput {
12
12
  full: string;
13
13
  /** Structured JSON if the AI could extract it */
14
14
  structured?: Record<string, unknown>;
15
- /** How many tokens were saved */
15
+ /** How many tokens were saved (net, after subtracting AI cost) */
16
16
  tokensSaved: number;
17
+ /** Tokens used by the AI summarization call */
18
+ aiTokensUsed: number;
17
19
  /** Whether AI processing was used (vs passthrough) */
18
20
  aiProcessed: boolean;
21
+ /** Cost of the AI call in USD (Cerebras pricing) */
22
+ aiCostUsd: number;
23
+ /** Value of tokens saved in USD (at Claude Sonnet rates) */
24
+ savingsValueUsd: number;
25
+ /** Net ROI: savings minus AI cost */
26
+ netSavingsUsd: number;
19
27
  }
20
28
 
21
29
  const MIN_LINES_TO_PROCESS = 15;
@@ -53,7 +61,11 @@ export async function processOutput(
53
61
  summary: output,
54
62
  full: output,
55
63
  tokensSaved: 0,
64
+ aiTokensUsed: 0,
56
65
  aiProcessed: false,
66
+ aiCostUsd: 0,
67
+ savingsValueUsd: 0,
68
+ netSavingsUsd: 0,
57
69
  };
58
70
  }
59
71
 
@@ -94,12 +106,34 @@ export async function processOutput(
94
106
  }
95
107
  } catch { /* not JSON, that's fine */ }
96
108
 
109
+ // Cost calculation
110
+ // AI input: system prompt (~200 tokens) + command + output sent to AI
111
+ const aiInputTokens = estimateTokens(SUMMARIZE_PROMPT) + estimateTokens(toSummarize) + 20;
112
+ const aiOutputTokens = summaryTokens;
113
+ const aiTokensUsed = aiInputTokens + aiOutputTokens;
114
+
115
+ // Cerebras qwen-3-235b pricing: $0.60/M input, $1.20/M output
116
+ const aiCostUsd = (aiInputTokens * 0.60 + aiOutputTokens * 1.20) / 1_000_000;
117
+
118
+ // Value of tokens saved (at Claude Sonnet $3/M input — what the agent would pay)
119
+ const savingsValueUsd = (saved * 3.0) / 1_000_000;
120
+ const netSavingsUsd = savingsValueUsd - aiCostUsd;
121
+
122
+ // Only record savings if net positive (AI cost < token savings value)
123
+ if (netSavingsUsd > 0 && saved > 0) {
124
+ recordSaving("compressed", saved);
125
+ }
126
+
97
127
  return {
98
128
  summary,
99
129
  full: output,
100
130
  structured,
101
131
  tokensSaved: saved,
132
+ aiTokensUsed,
102
133
  aiProcessed: true,
134
+ aiCostUsd,
135
+ savingsValueUsd,
136
+ netSavingsUsd,
103
137
  };
104
138
  } catch {
105
139
  // AI unavailable — fall back to simple truncation
@@ -111,7 +145,11 @@ export async function processOutput(
111
145
  summary: fallback,
112
146
  full: output,
113
147
  tokensSaved: Math.max(0, estimateTokens(output) - estimateTokens(fallback)),
148
+ aiTokensUsed: 0,
114
149
  aiProcessed: false,
150
+ aiCostUsd: 0,
151
+ savingsValueUsd: 0,
152
+ netSavingsUsd: 0,
115
153
  };
116
154
  }
117
155
  }
@@ -61,6 +61,18 @@ export function createRecipe(opts: {
61
61
  const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
62
62
  const store = loadStore(filePath);
63
63
 
64
+ // Prevent duplicates — update existing if same name
65
+ const existingIdx = store.recipes.findIndex(r => r.name === opts.name);
66
+ if (existingIdx >= 0) {
67
+ store.recipes[existingIdx].command = opts.command;
68
+ store.recipes[existingIdx].updatedAt = Date.now();
69
+ if (opts.description) store.recipes[existingIdx].description = opts.description;
70
+ if (opts.tags) store.recipes[existingIdx].tags = opts.tags;
71
+ if (opts.collection) store.recipes[existingIdx].collection = opts.collection;
72
+ saveStore(filePath, store);
73
+ return store.recipes[existingIdx];
74
+ }
75
+
64
76
  // Auto-detect variables from command if not explicitly provided
65
77
  const detectedVars = extractVariables(opts.command);
66
78
  const variables = opts.variables ?? detectedVars.map(name => ({ name, required: true }));
@@ -120,6 +132,10 @@ export function createCollection(opts: { name: string; description?: string; pro
120
132
  const filePath = opts.project ? projectFile(opts.project) : GLOBAL_FILE;
121
133
  const store = loadStore(filePath);
122
134
 
135
+ // Prevent duplicates — return existing if same name
136
+ const existing = store.collections.find(c => c.name === opts.name);
137
+ if (existing) return existing;
138
+
123
139
  const collection: Collection = {
124
140
  id: genId(),
125
141
  name: opts.name,