@hasna/terminal 4.3.1 → 4.3.2
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 +404 -0
- package/dist/Browse.js +79 -0
- package/dist/FuzzyPicker.js +47 -0
- package/dist/Onboarding.js +51 -0
- package/dist/Spinner.js +12 -0
- package/dist/StatusBar.js +49 -0
- package/dist/ai.js +316 -0
- package/dist/cache.js +42 -0
- package/dist/cli.js +778 -0
- package/dist/command-rewriter.js +64 -0
- package/dist/command-validator.js +86 -0
- package/dist/compression.js +91 -0
- package/dist/context-hints.js +285 -0
- package/dist/diff-cache.js +107 -0
- package/dist/discover.js +212 -0
- package/dist/economy.js +155 -0
- package/dist/expand-store.js +44 -0
- package/dist/file-cache.js +72 -0
- package/dist/file-index.js +62 -0
- package/dist/history.js +62 -0
- package/dist/lazy-executor.js +54 -0
- package/dist/line-dedup.js +59 -0
- package/dist/loop-detector.js +75 -0
- package/dist/mcp/install.js +189 -0
- package/dist/mcp/server.js +56 -0
- package/dist/mcp/tools/batch.js +111 -0
- package/dist/mcp/tools/execute.js +194 -0
- package/dist/mcp/tools/files.js +290 -0
- package/dist/mcp/tools/git.js +233 -0
- package/dist/mcp/tools/helpers.js +63 -0
- package/dist/mcp/tools/memory.js +151 -0
- package/dist/mcp/tools/meta.js +138 -0
- package/dist/mcp/tools/process.js +50 -0
- package/dist/mcp/tools/project.js +251 -0
- package/dist/mcp/tools/search.js +86 -0
- package/dist/noise-filter.js +94 -0
- package/dist/output-processor.js +233 -0
- package/dist/output-store.js +112 -0
- package/dist/paths.js +28 -0
- package/dist/providers/anthropic.js +43 -0
- package/dist/providers/base.js +4 -0
- package/dist/providers/cerebras.js +8 -0
- package/dist/providers/groq.js +8 -0
- package/dist/providers/index.js +142 -0
- package/dist/providers/openai-compat.js +93 -0
- package/dist/providers/xai.js +8 -0
- package/dist/recipes/model.js +20 -0
- package/dist/recipes/storage.js +153 -0
- package/dist/search/content-search.js +70 -0
- package/dist/search/file-search.js +61 -0
- package/dist/search/filters.js +34 -0
- package/dist/search/index.js +5 -0
- package/dist/search/semantic.js +346 -0
- package/dist/session-boot.js +59 -0
- package/dist/session-context.js +55 -0
- package/dist/sessions-db.js +240 -0
- package/dist/smart-display.js +286 -0
- package/dist/snapshots.js +51 -0
- package/dist/supervisor.js +112 -0
- package/dist/test-watchlist.js +131 -0
- package/dist/tokens.js +17 -0
- package/dist/tool-profiles.js +130 -0
- package/dist/tree.js +94 -0
- package/dist/usage-cache.js +65 -0
- package/package.json +2 -1
- package/src/Onboarding.tsx +1 -1
- package/src/ai.ts +5 -4
- package/src/cache.ts +2 -2
- package/src/economy.ts +3 -3
- package/src/history.ts +2 -2
- package/src/mcp/server.ts +2 -0
- package/src/mcp/tools/memory.ts +4 -2
- package/src/output-store.ts +2 -1
- package/src/paths.ts +32 -0
- package/src/recipes/storage.ts +3 -3
- package/src/session-context.ts +2 -2
- package/src/sessions-db.ts +15 -4
- package/src/tool-profiles.ts +4 -3
- 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
|
+
}
|