@hasna/terminal 4.3.1 → 4.3.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.
Files changed (81) 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 +316 -0
  8. package/dist/cache.js +42 -0
  9. package/dist/cli.js +778 -0
  10. package/dist/command-rewriter.js +64 -0
  11. package/dist/command-validator.js +86 -0
  12. package/dist/compression.js +91 -0
  13. package/dist/context-hints.js +285 -0
  14. package/dist/db/pg-migrations.js +70 -0
  15. package/dist/diff-cache.js +107 -0
  16. package/dist/discover.js +212 -0
  17. package/dist/economy.js +155 -0
  18. package/dist/expand-store.js +44 -0
  19. package/dist/file-cache.js +72 -0
  20. package/dist/file-index.js +62 -0
  21. package/dist/history.js +62 -0
  22. package/dist/lazy-executor.js +54 -0
  23. package/dist/line-dedup.js +59 -0
  24. package/dist/loop-detector.js +75 -0
  25. package/dist/mcp/install.js +189 -0
  26. package/dist/mcp/server.js +90 -0
  27. package/dist/mcp/tools/batch.js +111 -0
  28. package/dist/mcp/tools/execute.js +194 -0
  29. package/dist/mcp/tools/files.js +290 -0
  30. package/dist/mcp/tools/git.js +233 -0
  31. package/dist/mcp/tools/helpers.js +63 -0
  32. package/dist/mcp/tools/memory.js +151 -0
  33. package/dist/mcp/tools/meta.js +138 -0
  34. package/dist/mcp/tools/process.js +50 -0
  35. package/dist/mcp/tools/project.js +251 -0
  36. package/dist/mcp/tools/search.js +86 -0
  37. package/dist/noise-filter.js +94 -0
  38. package/dist/output-processor.js +233 -0
  39. package/dist/output-store.js +112 -0
  40. package/dist/paths.js +28 -0
  41. package/dist/providers/anthropic.js +43 -0
  42. package/dist/providers/base.js +4 -0
  43. package/dist/providers/cerebras.js +8 -0
  44. package/dist/providers/groq.js +8 -0
  45. package/dist/providers/index.js +142 -0
  46. package/dist/providers/openai-compat.js +93 -0
  47. package/dist/providers/xai.js +8 -0
  48. package/dist/recipes/model.js +20 -0
  49. package/dist/recipes/storage.js +153 -0
  50. package/dist/search/content-search.js +70 -0
  51. package/dist/search/file-search.js +61 -0
  52. package/dist/search/filters.js +34 -0
  53. package/dist/search/index.js +5 -0
  54. package/dist/search/semantic.js +346 -0
  55. package/dist/session-boot.js +59 -0
  56. package/dist/session-context.js +55 -0
  57. package/dist/sessions-db.js +240 -0
  58. package/dist/smart-display.js +286 -0
  59. package/dist/snapshots.js +51 -0
  60. package/dist/supervisor.js +112 -0
  61. package/dist/test-watchlist.js +131 -0
  62. package/dist/tokens.js +17 -0
  63. package/dist/tool-profiles.js +130 -0
  64. package/dist/tree.js +94 -0
  65. package/dist/usage-cache.js +65 -0
  66. package/package.json +2 -1
  67. package/src/Onboarding.tsx +1 -1
  68. package/src/ai.ts +5 -4
  69. package/src/cache.ts +2 -2
  70. package/src/db/pg-migrations.ts +77 -0
  71. package/src/economy.ts +3 -3
  72. package/src/history.ts +2 -2
  73. package/src/mcp/server.ts +55 -0
  74. package/src/mcp/tools/memory.ts +4 -2
  75. package/src/output-store.ts +2 -1
  76. package/src/paths.ts +32 -0
  77. package/src/recipes/storage.ts +3 -3
  78. package/src/session-context.ts +2 -2
  79. package/src/sessions-db.ts +15 -4
  80. package/src/tool-profiles.ts +4 -3
  81. package/src/usage-cache.ts +2 -2
@@ -0,0 +1,240 @@
1
+ // SQLite session database — tracks every terminal interaction
2
+ import { SqliteAdapter } from "@hasna/cloud";
3
+ import { existsSync, mkdirSync } from "fs";
4
+ import { join } from "path";
5
+ import { randomUUID } from "crypto";
6
+ import { getTerminalDir } from "./paths.js";
7
+ const DIR = getTerminalDir();
8
+ const DB_PATH = process.env.HASNA_TERMINAL_DB_PATH ?? process.env.TERMINAL_DB_PATH ?? join(DIR, "sessions.db");
9
+ let db = null;
10
+ function getDb() {
11
+ if (db)
12
+ return db;
13
+ if (!existsSync(DIR))
14
+ mkdirSync(DIR, { recursive: true });
15
+ db = new SqliteAdapter(DB_PATH);
16
+ db.exec("PRAGMA journal_mode = WAL");
17
+ db.exec(`
18
+ CREATE TABLE IF NOT EXISTS sessions (
19
+ id TEXT PRIMARY KEY,
20
+ started_at INTEGER NOT NULL,
21
+ ended_at INTEGER,
22
+ cwd TEXT NOT NULL,
23
+ provider TEXT,
24
+ model TEXT
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS interactions (
28
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29
+ session_id TEXT NOT NULL REFERENCES sessions(id),
30
+ nl TEXT NOT NULL,
31
+ command TEXT,
32
+ output TEXT,
33
+ exit_code INTEGER,
34
+ tokens_used INTEGER DEFAULT 0,
35
+ tokens_saved INTEGER DEFAULT 0,
36
+ duration_ms INTEGER,
37
+ model TEXT,
38
+ cached INTEGER DEFAULT 0,
39
+ created_at INTEGER NOT NULL
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id);
43
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
44
+
45
+ CREATE TABLE IF NOT EXISTS corrections (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ prompt TEXT NOT NULL,
48
+ failed_command TEXT NOT NULL,
49
+ error_output TEXT,
50
+ corrected_command TEXT NOT NULL,
51
+ worked INTEGER DEFAULT 1,
52
+ error_type TEXT,
53
+ created_at INTEGER NOT NULL
54
+ );
55
+
56
+ CREATE TABLE IF NOT EXISTS outputs (
57
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ session_id TEXT,
59
+ command TEXT NOT NULL,
60
+ raw_output_path TEXT,
61
+ compressed_summary TEXT,
62
+ tokens_raw INTEGER DEFAULT 0,
63
+ tokens_compressed INTEGER DEFAULT 0,
64
+ provider TEXT,
65
+ model TEXT,
66
+ created_at INTEGER NOT NULL
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_corrections_prompt ON corrections(prompt);
70
+
71
+ CREATE TABLE IF NOT EXISTS feedback (
72
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
73
+ message TEXT NOT NULL,
74
+ email TEXT,
75
+ category TEXT DEFAULT 'general',
76
+ version TEXT,
77
+ machine_id TEXT,
78
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
79
+ );
80
+ `);
81
+ return db;
82
+ }
83
+ // ── Sessions ─────────────────────────────────────────────────────────────────
84
+ export function createSession(cwd, provider, model) {
85
+ const id = randomUUID();
86
+ getDb().prepare("INSERT INTO sessions (id, started_at, cwd, provider, model) VALUES (?, ?, ?, ?, ?)").run(id, Date.now(), cwd, provider ?? null, model ?? null);
87
+ return id;
88
+ }
89
+ export function endSession(sessionId) {
90
+ getDb().prepare("UPDATE sessions SET ended_at = ? WHERE id = ?").run(Date.now(), sessionId);
91
+ }
92
+ export function listSessions(limit = 20) {
93
+ return getDb().prepare("SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?").all(limit);
94
+ }
95
+ export function getSession(id) {
96
+ return getDb().prepare("SELECT * FROM sessions WHERE id = ?").get(id);
97
+ }
98
+ export function logInteraction(sessionId, data) {
99
+ const result = getDb().prepare(`INSERT INTO interactions (session_id, nl, command, output, exit_code, tokens_used, tokens_saved, duration_ms, model, cached, created_at)
100
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(sessionId, data.nl, data.command ?? null, data.output ? data.output.slice(0, 500) : null, data.exitCode ?? null, data.tokensUsed ?? 0, data.tokensSaved ?? 0, data.durationMs ?? null, data.model ?? null, data.cached ? 1 : 0, Date.now());
101
+ // bun:sqlite — lastInsertRowid is a property on the statement after run()
102
+ const lastId = getDb().prepare("SELECT last_insert_rowid() as id").get();
103
+ return lastId?.id ?? 0;
104
+ }
105
+ export function updateInteraction(id, data) {
106
+ const sets = [];
107
+ const vals = [];
108
+ if (data.command !== undefined) {
109
+ sets.push("command = ?");
110
+ vals.push(data.command);
111
+ }
112
+ if (data.output !== undefined) {
113
+ sets.push("output = ?");
114
+ vals.push(data.output.slice(0, 500));
115
+ }
116
+ if (data.exitCode !== undefined) {
117
+ sets.push("exit_code = ?");
118
+ vals.push(data.exitCode);
119
+ }
120
+ if (data.tokensSaved !== undefined) {
121
+ sets.push("tokens_saved = ?");
122
+ vals.push(data.tokensSaved);
123
+ }
124
+ if (sets.length === 0)
125
+ return;
126
+ vals.push(id);
127
+ getDb().prepare(`UPDATE interactions SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
128
+ }
129
+ export function getSessionInteractions(sessionId) {
130
+ return getDb().prepare("SELECT * FROM interactions WHERE session_id = ? ORDER BY created_at ASC").all(sessionId);
131
+ }
132
+ export function getSessionStats() {
133
+ const d = getDb();
134
+ const sessions = d.prepare("SELECT COUNT(*) as c FROM sessions").get();
135
+ const interactions = d.prepare("SELECT COUNT(*) as c, SUM(tokens_saved) as saved, SUM(tokens_used) as used FROM interactions").get();
136
+ const cached = d.prepare("SELECT COUNT(*) as c FROM interactions WHERE cached = 1").get();
137
+ const errors = d.prepare("SELECT COUNT(*) as c FROM interactions WHERE exit_code IS NOT NULL AND exit_code != 0").get();
138
+ const totalInteractions = interactions.c ?? 0;
139
+ return {
140
+ totalSessions: sessions.c ?? 0,
141
+ totalInteractions,
142
+ totalTokensSaved: interactions.saved ?? 0,
143
+ totalTokensUsed: interactions.used ?? 0,
144
+ cacheHitRate: totalInteractions > 0 ? (cached.c ?? 0) / totalInteractions : 0,
145
+ avgInteractionsPerSession: sessions.c > 0 ? totalInteractions / sessions.c : 0,
146
+ errorRate: totalInteractions > 0 ? (errors.c ?? 0) / totalInteractions : 0,
147
+ };
148
+ }
149
+ /** Get economy stats for a specific session */
150
+ export function getSessionEconomy(sessionId) {
151
+ const d = getDb();
152
+ const rows = d.prepare("SELECT nl, tokens_saved, tokens_used, duration_ms FROM interactions WHERE session_id = ?").all(sessionId);
153
+ const tools = {};
154
+ let totalSaved = 0, totalUsed = 0, aiCalls = 0, totalLatency = 0, latencyCount = 0;
155
+ for (const r of rows) {
156
+ totalSaved += r.tokens_saved ?? 0;
157
+ totalUsed += r.tokens_used ?? 0;
158
+ if (r.tokens_used > 0)
159
+ aiCalls++;
160
+ if (r.duration_ms) {
161
+ totalLatency += r.duration_ms;
162
+ latencyCount++;
163
+ }
164
+ // Extract tool name from nl field: [mcp:toolname] command
165
+ const toolMatch = r.nl.match(/^\[mcp:(\w+)\]/);
166
+ const tool = toolMatch?.[1] ?? "cli";
167
+ if (!tools[tool])
168
+ tools[tool] = { calls: 0, tokensSaved: 0 };
169
+ tools[tool].calls++;
170
+ tools[tool].tokensSaved += r.tokens_saved ?? 0;
171
+ }
172
+ // Savings at consumer model rates (×5 turns before compaction)
173
+ const multiplied = totalSaved * 5;
174
+ return {
175
+ totalCalls: rows.length,
176
+ tokensSaved: totalSaved,
177
+ tokensUsed: totalUsed,
178
+ aiCalls,
179
+ avgLatencyMs: latencyCount > 0 ? Math.round(totalLatency / latencyCount) : 0,
180
+ savingsUsd: {
181
+ opus: (multiplied * 15) / 1_000_000,
182
+ sonnet: (multiplied * 3) / 1_000_000,
183
+ haiku: (multiplied * 0.8) / 1_000_000,
184
+ },
185
+ tools,
186
+ };
187
+ }
188
+ // ── Corrections ─────────────────────────────────────────────────────────────
189
+ /** Record a correction: command failed, then AI retried with a better one */
190
+ export function recordCorrection(prompt, failedCommand, errorOutput, correctedCommand, worked, errorType) {
191
+ getDb().prepare("INSERT INTO corrections (prompt, failed_command, error_output, corrected_command, worked, error_type, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(prompt, failedCommand, errorOutput?.slice(0, 2000) ?? "", correctedCommand, worked ? 1 : 0, errorType ?? null, Date.now());
192
+ }
193
+ /** Find similar corrections for a prompt — used to inject as negative examples */
194
+ export function findSimilarCorrections(prompt, limit = 5) {
195
+ // Simple keyword matching — extract significant words from prompt
196
+ const words = prompt.toLowerCase().split(/\s+/).filter(w => w.length > 3);
197
+ if (words.length === 0)
198
+ return [];
199
+ // Search corrections where the prompt shares keywords
200
+ const all = getDb().prepare("SELECT prompt, failed_command, corrected_command, error_type FROM corrections WHERE worked = 1 ORDER BY created_at DESC LIMIT 100").all();
201
+ return all
202
+ .filter(c => {
203
+ const cWords = c.prompt.toLowerCase().split(/\s+/);
204
+ const overlap = words.filter((w) => cWords.some((cw) => cw.includes(w) || w.includes(cw)));
205
+ return overlap.length >= Math.min(2, words.length);
206
+ })
207
+ .slice(0, limit)
208
+ .map(c => ({ failed_command: c.failed_command, corrected_command: c.corrected_command, error_type: c.error_type ?? "unknown" }));
209
+ }
210
+ // ── Output tracking ─────────────────────────────────────────────────────────
211
+ /** Record a compressed output for audit trail */
212
+ export function recordOutput(command, rawOutputPath, compressedSummary, tokensRaw, tokensCompressed, provider, model, sessionId) {
213
+ getDb().prepare("INSERT INTO outputs (session_id, command, raw_output_path, compressed_summary, tokens_raw, tokens_compressed, provider, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)").run(sessionId ?? null, command, rawOutputPath ?? null, compressedSummary?.slice(0, 5000) ?? "", tokensRaw, tokensCompressed, provider ?? null, model ?? null, Date.now());
214
+ }
215
+ /** Prune sessions and interactions older than N days */
216
+ export function pruneSessions(olderThanDays = 90) {
217
+ const d = getDb();
218
+ const cutoff = Date.now() - (olderThanDays * 24 * 60 * 60 * 1000);
219
+ const oldSessions = d.prepare("SELECT id FROM sessions WHERE started_at < ?").all(cutoff);
220
+ if (oldSessions.length === 0)
221
+ return { sessionsDeleted: 0, interactionsDeleted: 0 };
222
+ const ids = oldSessions.map(s => s.id);
223
+ const placeholders = ids.map(() => "?").join(",");
224
+ const intResult = d.prepare(`DELETE FROM interactions WHERE session_id IN (${placeholders})`).run(...ids);
225
+ const sesResult = d.prepare(`DELETE FROM sessions WHERE id IN (${placeholders})`).run(...ids);
226
+ // Also prune old corrections and outputs
227
+ d.prepare("DELETE FROM corrections WHERE created_at < ?").run(cutoff);
228
+ d.prepare("DELETE FROM outputs WHERE created_at < ?").run(cutoff);
229
+ return {
230
+ sessionsDeleted: oldSessions.length,
231
+ interactionsDeleted: intResult.changes ?? 0,
232
+ };
233
+ }
234
+ /** Close the database connection */
235
+ export function closeDb() {
236
+ if (db) {
237
+ db.close();
238
+ db = null;
239
+ }
240
+ }
@@ -0,0 +1,286 @@
1
+ // Smart output display — compress repetitive output into grouped patterns
2
+ import { dirname, basename } from "path";
3
+ /** Detect if lines look like file paths */
4
+ function looksLikePaths(lines) {
5
+ if (lines.length < 3)
6
+ return false;
7
+ const pathLike = lines.filter(l => l.trim().match(/^\.?\//) || l.trim().includes("/"));
8
+ return pathLike.length > lines.length * 0.6;
9
+ }
10
+ /** Find the varying part between similar strings and create a glob pattern */
11
+ function findPattern(items) {
12
+ if (items.length < 2)
13
+ return null;
14
+ const first = items[0];
15
+ const last = items[items.length - 1];
16
+ // Find common prefix
17
+ let prefixLen = 0;
18
+ while (prefixLen < first.length && prefixLen < last.length && first[prefixLen] === last[prefixLen]) {
19
+ prefixLen++;
20
+ }
21
+ // Find common suffix
22
+ let suffixLen = 0;
23
+ while (suffixLen < first.length - prefixLen &&
24
+ suffixLen < last.length - prefixLen &&
25
+ first[first.length - 1 - suffixLen] === last[last.length - 1 - suffixLen]) {
26
+ suffixLen++;
27
+ }
28
+ const prefix = first.slice(0, prefixLen);
29
+ const suffix = suffixLen > 0 ? first.slice(-suffixLen) : "";
30
+ if (prefix.length + suffix.length < first.length * 0.3)
31
+ return null; // too different
32
+ return `${prefix}*${suffix}`;
33
+ }
34
+ /** Group file paths by directory */
35
+ function groupByDir(paths) {
36
+ const groups = new Map();
37
+ for (const p of paths) {
38
+ const dir = dirname(p.trim());
39
+ const file = basename(p.trim());
40
+ if (!groups.has(dir))
41
+ groups.set(dir, []);
42
+ groups.get(dir).push(file);
43
+ }
44
+ return groups;
45
+ }
46
+ /** Detect duplicate filenames across directories */
47
+ function findDuplicates(paths) {
48
+ const byName = new Map();
49
+ for (const p of paths) {
50
+ const file = basename(p.trim());
51
+ if (!byName.has(file))
52
+ byName.set(file, []);
53
+ byName.get(file).push(dirname(p.trim()));
54
+ }
55
+ // Only return files that appear in 2+ dirs
56
+ const dupes = new Map();
57
+ for (const [file, dirs] of byName) {
58
+ if (dirs.length >= 2)
59
+ dupes.set(file, dirs);
60
+ }
61
+ return dupes;
62
+ }
63
+ /** Collapse node_modules paths */
64
+ function collapseNodeModules(paths) {
65
+ const nodeModulesPaths = [];
66
+ const otherPaths = [];
67
+ for (const p of paths) {
68
+ if (p.includes("node_modules")) {
69
+ nodeModulesPaths.push(p);
70
+ }
71
+ else {
72
+ otherPaths.push(p);
73
+ }
74
+ }
75
+ return { nodeModulesPaths, otherPaths };
76
+ }
77
+ /** Smart display: compress file path output into grouped patterns */
78
+ export function smartDisplay(lines) {
79
+ if (lines.length <= 5)
80
+ return lines;
81
+ // Try ls -la table compression first
82
+ const lsCompressed = compressLsTable(lines);
83
+ if (lsCompressed)
84
+ return lsCompressed;
85
+ if (!looksLikePaths(lines))
86
+ return compressGeneric(lines);
87
+ const paths = lines.map(l => l.trim()).filter(l => l);
88
+ const result = [];
89
+ // Step 1: Separate node_modules
90
+ const { nodeModulesPaths, otherPaths } = collapseNodeModules(paths);
91
+ // Step 2: Find duplicates in non-node_modules paths
92
+ const dupes = findDuplicates(otherPaths);
93
+ const handledPaths = new Set();
94
+ // Show duplicates first
95
+ for (const [file, dirs] of dupes) {
96
+ if (dirs.length >= 3) {
97
+ result.push(` **/${file} ×${dirs.length}`);
98
+ result.push(` ${dirs.slice(0, 5).join(", ")}${dirs.length > 5 ? ` +${dirs.length - 5} more` : ""}`);
99
+ for (const d of dirs) {
100
+ handledPaths.add(`${d}/${file}`);
101
+ }
102
+ }
103
+ }
104
+ // Step 3: Group remaining by directory
105
+ const remaining = otherPaths.filter(p => !handledPaths.has(p.trim()));
106
+ const dirGroups = groupByDir(remaining);
107
+ for (const [dir, files] of dirGroups) {
108
+ if (files.length === 1) {
109
+ result.push(` ${dir}/${files[0]}`);
110
+ }
111
+ else if (files.length <= 3) {
112
+ result.push(` ${dir}/`);
113
+ for (const f of files)
114
+ result.push(` ${f}`);
115
+ }
116
+ else {
117
+ // Try to find a pattern
118
+ const sorted = files.sort();
119
+ const pattern = findPattern(sorted);
120
+ if (pattern) {
121
+ const dateRange = collapseDateRange(sorted);
122
+ const rangeStr = dateRange ? ` (${dateRange})` : "";
123
+ result.push(` ${dir}/${pattern} ×${files.length}${rangeStr}`);
124
+ }
125
+ else {
126
+ result.push(` ${dir}/ (${files.length} files)`);
127
+ // Show first 2 + count
128
+ result.push(` ${sorted[0]}, ${sorted[1]}${files.length > 2 ? `, +${files.length - 2} more` : ""}`);
129
+ }
130
+ }
131
+ }
132
+ // Step 4: Collapsed node_modules summary
133
+ if (nodeModulesPaths.length > 0) {
134
+ if (nodeModulesPaths.length <= 2) {
135
+ for (const p of nodeModulesPaths)
136
+ result.push(` ${p}`);
137
+ }
138
+ else {
139
+ // Group node_modules by package name
140
+ const nmGroups = new Map();
141
+ for (const p of nodeModulesPaths) {
142
+ // Extract package name from path: ./X/node_modules/PKG/...
143
+ const match = p.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/);
144
+ const pkg = match ? match[1] : "other";
145
+ nmGroups.set(pkg, (nmGroups.get(pkg) ?? 0) + 1);
146
+ }
147
+ result.push(` node_modules/ (${nodeModulesPaths.length} matches)`);
148
+ const topPkgs = [...nmGroups.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3);
149
+ for (const [pkg, count] of topPkgs) {
150
+ result.push(` ${pkg} ×${count}`);
151
+ }
152
+ if (nmGroups.size > 3) {
153
+ result.push(` +${nmGroups.size - 3} more packages`);
154
+ }
155
+ }
156
+ }
157
+ return result;
158
+ }
159
+ /** Detect date range in timestamps and collapse */
160
+ function collapseDateRange(files) {
161
+ const timestamps = [];
162
+ for (const f of files) {
163
+ const match = f.match(/(\d{4})-(\d{2})-(\d{2})T?(\d{2})?/);
164
+ if (match) {
165
+ const [, y, m, d, h] = match;
166
+ timestamps.push(new Date(`${y}-${m}-${d}T${h ?? "00"}:00:00`));
167
+ }
168
+ }
169
+ if (timestamps.length < 2)
170
+ return null;
171
+ timestamps.sort((a, b) => a.getTime() - b.getTime());
172
+ const first = timestamps[0];
173
+ const last = timestamps[timestamps.length - 1];
174
+ const fmt = (d) => `${d.getMonth() + 1}/${d.getDate()}`;
175
+ if (first.toDateString() === last.toDateString()) {
176
+ return `${fmt(first)}`;
177
+ }
178
+ return `${fmt(first)}–${fmt(last)}`;
179
+ }
180
+ /** Detect and compress ls -la style table output */
181
+ function compressLsTable(lines) {
182
+ // Detect ls -la format: permissions size date name
183
+ const lsPattern = /^[dlcbps-][rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+\S+\s+\w+\s+\d+\s+[\d:]+\s+.+$/;
184
+ const isLsOutput = lines.filter(l => lsPattern.test(l.trim())).length > lines.length * 0.5;
185
+ if (!isLsOutput)
186
+ return null;
187
+ const result = [];
188
+ const dirs = [];
189
+ const files = [];
190
+ let totalSize = 0;
191
+ for (const line of lines) {
192
+ const match = line.trim().match(/^([dlcbps-])[rwxsStT-]{9}\s+\d+\s+\S+\s+\S+\s+(\S+)\s+\w+\s+\d+\s+[\d:]+\s+(.+)$/);
193
+ if (!match) {
194
+ if (line.trim().startsWith("total "))
195
+ continue;
196
+ result.push(line);
197
+ continue;
198
+ }
199
+ const [, type, sizeStr, name] = match;
200
+ const size = parseInt(sizeStr) || 0;
201
+ totalSize += size;
202
+ if (type === "d") {
203
+ dirs.push(name);
204
+ }
205
+ else {
206
+ files.push({ name, size: formatSize(size) });
207
+ }
208
+ }
209
+ // Compact display
210
+ if (dirs.length > 0) {
211
+ result.push(` 📁 ${dirs.join(" ")}${dirs.length > 5 ? ` (+${dirs.length - 5} more)` : ""}`);
212
+ }
213
+ if (files.length <= 8) {
214
+ for (const f of files) {
215
+ result.push(` ${f.size.padStart(6)} ${f.name}`);
216
+ }
217
+ }
218
+ else {
219
+ // Show top 5 by size + count
220
+ const sorted = files.sort((a, b) => parseSize(b.size) - parseSize(a.size));
221
+ for (const f of sorted.slice(0, 5)) {
222
+ result.push(` ${f.size.padStart(6)} ${f.name}`);
223
+ }
224
+ result.push(` ... +${files.length - 5} more files (${formatSize(totalSize)} total)`);
225
+ }
226
+ return result;
227
+ }
228
+ function formatSize(bytes) {
229
+ if (bytes >= 1_000_000)
230
+ return `${(bytes / 1_000_000).toFixed(1)}M`;
231
+ if (bytes >= 1_000)
232
+ return `${(bytes / 1_000).toFixed(1)}K`;
233
+ return `${bytes}B`;
234
+ }
235
+ function parseSize(s) {
236
+ const match = s.match(/([\d.]+)([BKMG])?/);
237
+ if (!match)
238
+ return 0;
239
+ const n = parseFloat(match[1]);
240
+ const unit = match[2];
241
+ if (unit === "K")
242
+ return n * 1000;
243
+ if (unit === "M")
244
+ return n * 1000000;
245
+ if (unit === "G")
246
+ return n * 1000000000;
247
+ return n;
248
+ }
249
+ /** Compress non-path generic output by deduplicating similar lines */
250
+ function compressGeneric(lines) {
251
+ if (lines.length <= 10)
252
+ return lines;
253
+ const result = [];
254
+ let repeatCount = 0;
255
+ let lastPattern = "";
256
+ for (let i = 0; i < lines.length; i++) {
257
+ const line = lines[i];
258
+ // Normalize: remove numbers, timestamps, hashes for pattern matching
259
+ const pattern = line
260
+ .replace(/\d{4}-\d{2}-\d{2}T[\d:.-]+Z?/g, "TIMESTAMP")
261
+ .replace(/\b[0-9a-f]{7,40}\b/g, "HASH")
262
+ .replace(/\b\d+\b/g, "N")
263
+ .trim();
264
+ if (pattern === lastPattern && i > 0) {
265
+ repeatCount++;
266
+ }
267
+ else {
268
+ if (repeatCount > 1) {
269
+ result.push(` ... ×${repeatCount} similar`);
270
+ }
271
+ else if (repeatCount === 1) {
272
+ result.push(lines[i - 1]);
273
+ }
274
+ result.push(line);
275
+ lastPattern = pattern;
276
+ repeatCount = 0;
277
+ }
278
+ }
279
+ if (repeatCount > 1) {
280
+ result.push(` ... ×${repeatCount} similar`);
281
+ }
282
+ else if (repeatCount === 1) {
283
+ result.push(lines[lines.length - 1]);
284
+ }
285
+ return result;
286
+ }
@@ -0,0 +1,51 @@
1
+ // Session snapshots — capture terminal state for agent context handoff
2
+ import { loadHistory } from "./history.js";
3
+ import { bgStatus } from "./supervisor.js";
4
+ import { getEconomyStats, formatTokens } from "./economy.js";
5
+ import { listRecipes } from "./recipes/storage.js";
6
+ /** Capture a compact snapshot of the current terminal state */
7
+ export function captureSnapshot() {
8
+ // Filtered env — only relevant vars, no secrets
9
+ const safeEnvKeys = [
10
+ "PATH", "HOME", "USER", "SHELL", "NODE_ENV", "PWD", "LANG",
11
+ "TERM", "EDITOR", "VISUAL",
12
+ ];
13
+ const env = {};
14
+ for (const key of safeEnvKeys) {
15
+ if (process.env[key])
16
+ env[key] = process.env[key];
17
+ }
18
+ // Running processes
19
+ const processes = bgStatus().map(p => ({
20
+ pid: p.pid,
21
+ command: p.command,
22
+ port: p.port,
23
+ uptime: Date.now() - p.startedAt,
24
+ }));
25
+ // Recent commands (last 10)
26
+ const history = loadHistory().slice(-10);
27
+ const recentCommands = history.map(h => ({
28
+ cmd: h.cmd,
29
+ exitCode: h.error,
30
+ intent: h.nl !== h.cmd ? h.nl : undefined, // user's original NL intent, not AI-generated
31
+ }));
32
+ // Project recipes
33
+ const recipes = listRecipes(process.cwd()).slice(0, 10).map(r => ({
34
+ name: r.name,
35
+ command: r.command,
36
+ }));
37
+ // Economy
38
+ const econ = getEconomyStats();
39
+ return {
40
+ cwd: process.cwd(),
41
+ env,
42
+ runningProcesses: processes,
43
+ recentCommands,
44
+ recipes,
45
+ economy: {
46
+ tokensSaved: formatTokens(econ.totalTokensSaved),
47
+ tokensUsed: formatTokens(econ.totalTokensUsed),
48
+ },
49
+ timestamp: Date.now(),
50
+ };
51
+ }
@@ -0,0 +1,112 @@
1
+ // Process supervisor — manages background processes for agents and humans
2
+ import { spawn } from "child_process";
3
+ import { createConnection } from "net";
4
+ const processes = new Map();
5
+ /** Auto-detect port from common commands */
6
+ function detectPort(command) {
7
+ // "next dev -p 3001", "vite --port 4000", etc.
8
+ const portMatch = command.match(/-p\s+(\d+)|--port\s+(\d+)|PORT=(\d+)/);
9
+ if (portMatch)
10
+ return parseInt(portMatch[1] ?? portMatch[2] ?? portMatch[3]);
11
+ // Common defaults
12
+ if (/\bnext\s+dev\b/.test(command))
13
+ return 3000;
14
+ if (/\bvite\b/.test(command))
15
+ return 5173;
16
+ if (/\bnuxt\s+dev\b/.test(command))
17
+ return 3000;
18
+ if (/\bremix\s+dev\b/.test(command))
19
+ return 5173;
20
+ return undefined;
21
+ }
22
+ /** Start a background process */
23
+ export function bgStart(command, cwd) {
24
+ const workDir = cwd ?? process.cwd();
25
+ const proc = spawn("/bin/zsh", ["-c", command], {
26
+ cwd: workDir,
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ detached: false,
29
+ });
30
+ const meta = {
31
+ pid: proc.pid,
32
+ command,
33
+ cwd: workDir,
34
+ port: detectPort(command),
35
+ startedAt: Date.now(),
36
+ lastOutput: [],
37
+ };
38
+ const pushOutput = (d) => {
39
+ const lines = d.toString().split("\n").filter(l => l.trim());
40
+ meta.lastOutput.push(...lines);
41
+ // Keep last 50 lines
42
+ if (meta.lastOutput.length > 50) {
43
+ meta.lastOutput = meta.lastOutput.slice(-50);
44
+ }
45
+ };
46
+ proc.stdout?.on("data", pushOutput);
47
+ proc.stderr?.on("data", pushOutput);
48
+ proc.on("close", (code) => {
49
+ meta.exitCode = code ?? 0;
50
+ });
51
+ processes.set(proc.pid, { proc, meta });
52
+ return meta;
53
+ }
54
+ /** List all managed processes */
55
+ export function bgStatus() {
56
+ const result = [];
57
+ for (const [pid, { proc, meta }] of processes) {
58
+ // Check if still alive
59
+ try {
60
+ process.kill(pid, 0);
61
+ result.push({ ...meta, lastOutput: meta.lastOutput.slice(-5) });
62
+ }
63
+ catch {
64
+ // Process is dead
65
+ result.push({ ...meta, exitCode: meta.exitCode ?? -1, lastOutput: meta.lastOutput.slice(-5) });
66
+ }
67
+ }
68
+ return result;
69
+ }
70
+ /** Stop a background process */
71
+ export function bgStop(pid) {
72
+ const entry = processes.get(pid);
73
+ if (!entry)
74
+ return false;
75
+ try {
76
+ entry.proc.kill("SIGTERM");
77
+ processes.delete(pid);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** Get logs for a background process */
85
+ export function bgLogs(pid, tail = 20) {
86
+ const entry = processes.get(pid);
87
+ if (!entry)
88
+ return [];
89
+ return entry.meta.lastOutput.slice(-tail);
90
+ }
91
+ /** Wait for a port to be ready */
92
+ export function bgWaitPort(port, timeoutMs = 30000) {
93
+ return new Promise((resolve) => {
94
+ const start = Date.now();
95
+ const check = () => {
96
+ if (Date.now() - start > timeoutMs) {
97
+ resolve(false);
98
+ return;
99
+ }
100
+ const sock = createConnection({ port, host: "127.0.0.1" });
101
+ sock.on("connect", () => {
102
+ sock.destroy();
103
+ resolve(true);
104
+ });
105
+ sock.on("error", () => {
106
+ sock.destroy();
107
+ setTimeout(check, 500);
108
+ });
109
+ };
110
+ check();
111
+ });
112
+ }