@hasna/terminal 0.2.4 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/App.js +12 -0
- package/dist/cli.js +50 -0
- package/dist/mcp/server.js +17 -0
- package/dist/sessions-db.js +117 -0
- package/package.json +3 -1
- package/src/App.tsx +14 -0
- package/src/cli.tsx +45 -0
- package/src/mcp/server.ts +24 -0
- package/src/sessions-db.ts +189 -0
package/dist/App.js
CHANGED
|
@@ -10,6 +10,7 @@ import StatusBar from "./StatusBar.js";
|
|
|
10
10
|
import Spinner from "./Spinner.js";
|
|
11
11
|
import Browse from "./Browse.js";
|
|
12
12
|
import FuzzyPicker from "./FuzzyPicker.js";
|
|
13
|
+
import { createSession, logInteraction, updateInteraction } from "./sessions-db.js";
|
|
13
14
|
loadCache();
|
|
14
15
|
const MAX_LINES = 20;
|
|
15
16
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -66,6 +67,8 @@ export default function App() {
|
|
|
66
67
|
const [activeTab, setActiveTab] = useState(0);
|
|
67
68
|
const abortRef = useRef(null);
|
|
68
69
|
let nextTabId = useRef(2);
|
|
70
|
+
const sessionIdRef = useRef(createSession(process.cwd()));
|
|
71
|
+
const interactionIdRef = useRef(0);
|
|
69
72
|
const tab = tabs[activeTab];
|
|
70
73
|
const allNl = [...nlHistory, ...tab.sessionNl];
|
|
71
74
|
// ── tab helpers ─────────────────────────────────────────────────────────────
|
|
@@ -99,6 +102,10 @@ export default function App() {
|
|
|
99
102
|
}],
|
|
100
103
|
}));
|
|
101
104
|
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
105
|
+
// Log to SQLite session
|
|
106
|
+
if (interactionIdRef.current) {
|
|
107
|
+
updateInteraction(interactionIdRef.current, { output: shortOutput, exitCode: error ? 1 : 0 });
|
|
108
|
+
}
|
|
102
109
|
};
|
|
103
110
|
// ── run command ─────────────────────────────────────────────────────────────
|
|
104
111
|
const runPhase = async (nl, command, raw) => {
|
|
@@ -136,6 +143,9 @@ export default function App() {
|
|
|
136
143
|
// ── translate + run ─────────────────────────────────────────────────────────
|
|
137
144
|
const translateAndRun = async (nl, raw) => {
|
|
138
145
|
updateTab(t => ({ ...t, sessionNl: [...t.sessionNl, nl] }));
|
|
146
|
+
// Log interaction start
|
|
147
|
+
const startTime = Date.now();
|
|
148
|
+
interactionIdRef.current = logInteraction(sessionIdRef.current, { nl });
|
|
139
149
|
if (raw) {
|
|
140
150
|
await runPhase(nl, nl, true);
|
|
141
151
|
return;
|
|
@@ -144,6 +154,8 @@ export default function App() {
|
|
|
144
154
|
setPhase({ type: "thinking", nl, partial: "" });
|
|
145
155
|
try {
|
|
146
156
|
const command = await translateToCommand(nl, config.permissions, sessionEntries, partial => setPhase({ type: "thinking", nl, partial }));
|
|
157
|
+
// Update interaction with generated command
|
|
158
|
+
updateInteraction(interactionIdRef.current, { command });
|
|
147
159
|
const blocked = checkPermissions(command, config.permissions);
|
|
148
160
|
if (blocked) {
|
|
149
161
|
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
package/dist/cli.js
CHANGED
|
@@ -117,6 +117,56 @@ else if (args[0] === "stats") {
|
|
|
117
117
|
console.log(` NL cache: ${formatTokens(s.savingsByFeature.cache)}`);
|
|
118
118
|
console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
|
|
119
119
|
}
|
|
120
|
+
// ── Sessions command ─────────────────────────────────────────────────────────
|
|
121
|
+
else if (args[0] === "sessions") {
|
|
122
|
+
const { listSessions, getSession, getSessionInteractions, getSessionStats } = await import("./sessions-db.js");
|
|
123
|
+
if (args[1] === "stats") {
|
|
124
|
+
const stats = getSessionStats();
|
|
125
|
+
console.log("Session Stats:");
|
|
126
|
+
console.log(` Total sessions: ${stats.totalSessions}`);
|
|
127
|
+
console.log(` Total interactions: ${stats.totalInteractions}`);
|
|
128
|
+
console.log(` Tokens saved: ${stats.totalTokensSaved}`);
|
|
129
|
+
console.log(` Tokens used: ${stats.totalTokensUsed}`);
|
|
130
|
+
console.log(` Cache hit rate: ${(stats.cacheHitRate * 100).toFixed(1)}%`);
|
|
131
|
+
console.log(` Avg per session: ${stats.avgInteractionsPerSession.toFixed(1)}`);
|
|
132
|
+
console.log(` Error rate: ${(stats.errorRate * 100).toFixed(1)}%`);
|
|
133
|
+
}
|
|
134
|
+
else if (args[1]) {
|
|
135
|
+
// Show specific session
|
|
136
|
+
const session = getSession(args[1]);
|
|
137
|
+
if (!session) {
|
|
138
|
+
console.error(`Session '${args[1]}' not found.`);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
console.log(`Session: ${session.id}`);
|
|
142
|
+
console.log(` Started: ${new Date(session.started_at).toLocaleString()}`);
|
|
143
|
+
console.log(` CWD: ${session.cwd}`);
|
|
144
|
+
console.log(` Provider: ${session.provider ?? "auto"}`);
|
|
145
|
+
console.log("");
|
|
146
|
+
const interactions = getSessionInteractions(session.id);
|
|
147
|
+
for (const i of interactions) {
|
|
148
|
+
const status = i.exit_code === 0 ? "✓" : i.exit_code ? "✗" : "·";
|
|
149
|
+
console.log(` ${status} ${i.nl}`);
|
|
150
|
+
if (i.command)
|
|
151
|
+
console.log(` $ ${i.command}`);
|
|
152
|
+
}
|
|
153
|
+
console.log(`\n ${interactions.length} interactions`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
// List recent sessions
|
|
157
|
+
const sessions = listSessions(20);
|
|
158
|
+
if (sessions.length === 0) {
|
|
159
|
+
console.log("No sessions yet.");
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
for (const s of sessions) {
|
|
163
|
+
const date = new Date(s.started_at).toLocaleString();
|
|
164
|
+
const dir = s.cwd.split("/").pop() || s.cwd;
|
|
165
|
+
console.log(` ${s.id.slice(0, 8)} ${date} ${dir} ${s.provider ?? "auto"}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
120
170
|
// ── Snapshot command ─────────────────────────────────────────────────────────
|
|
121
171
|
else if (args[0] === "snapshot") {
|
|
122
172
|
const { captureSnapshot } = await import("./snapshots.js");
|
package/dist/mcp/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipe
|
|
|
11
11
|
import { substituteVariables } from "../recipes/model.js";
|
|
12
12
|
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
13
13
|
import { diffOutput } from "../diff-cache.js";
|
|
14
|
+
import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
|
|
14
15
|
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
15
16
|
import { captureSnapshot } from "../snapshots.js";
|
|
16
17
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -322,6 +323,22 @@ export function createServer() {
|
|
|
322
323
|
const snap = captureSnapshot();
|
|
323
324
|
return { content: [{ type: "text", text: JSON.stringify(snap) }] };
|
|
324
325
|
});
|
|
326
|
+
// ── session_history: query session data ────────────────────────────────────
|
|
327
|
+
server.tool("session_history", "Query terminal session history — recent sessions, specific session details, or aggregate stats.", {
|
|
328
|
+
action: z.enum(["list", "detail", "stats"]).describe("list=recent sessions, detail=specific session, stats=aggregates"),
|
|
329
|
+
sessionId: z.string().optional().describe("Session ID (for detail action)"),
|
|
330
|
+
limit: z.number().optional().describe("Max sessions to return (for list, default: 20)"),
|
|
331
|
+
}, async ({ action, sessionId, limit }) => {
|
|
332
|
+
if (action === "stats") {
|
|
333
|
+
return { content: [{ type: "text", text: JSON.stringify(getSessionStats()) }] };
|
|
334
|
+
}
|
|
335
|
+
if (action === "detail" && sessionId) {
|
|
336
|
+
const interactions = getSessionInteractions(sessionId);
|
|
337
|
+
return { content: [{ type: "text", text: JSON.stringify(interactions) }] };
|
|
338
|
+
}
|
|
339
|
+
const sessions = listSessions(limit ?? 20);
|
|
340
|
+
return { content: [{ type: "text", text: JSON.stringify(sessions) }] };
|
|
341
|
+
});
|
|
325
342
|
return server;
|
|
326
343
|
}
|
|
327
344
|
// ── main: start MCP server via stdio ─────────────────────────────────────────
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SQLite session database — tracks every terminal interaction
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { existsSync, mkdirSync } from "fs";
|
|
4
|
+
import { homedir } from "os";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { randomUUID } from "crypto";
|
|
7
|
+
const DIR = join(homedir(), ".terminal");
|
|
8
|
+
const 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 Database(DB_PATH);
|
|
16
|
+
db.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
|
+
return db;
|
|
46
|
+
}
|
|
47
|
+
// ── Sessions ─────────────────────────────────────────────────────────────────
|
|
48
|
+
export function createSession(cwd, provider, model) {
|
|
49
|
+
const id = randomUUID();
|
|
50
|
+
getDb().prepare("INSERT INTO sessions (id, started_at, cwd, provider, model) VALUES (?, ?, ?, ?, ?)").run(id, Date.now(), cwd, provider ?? null, model ?? null);
|
|
51
|
+
return id;
|
|
52
|
+
}
|
|
53
|
+
export function endSession(sessionId) {
|
|
54
|
+
getDb().prepare("UPDATE sessions SET ended_at = ? WHERE id = ?").run(Date.now(), sessionId);
|
|
55
|
+
}
|
|
56
|
+
export function listSessions(limit = 20) {
|
|
57
|
+
return getDb().prepare("SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?").all(limit);
|
|
58
|
+
}
|
|
59
|
+
export function getSession(id) {
|
|
60
|
+
return getDb().prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
61
|
+
}
|
|
62
|
+
export function logInteraction(sessionId, data) {
|
|
63
|
+
const result = getDb().prepare(`INSERT INTO interactions (session_id, nl, command, output, exit_code, tokens_used, tokens_saved, duration_ms, model, cached, created_at)
|
|
64
|
+
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());
|
|
65
|
+
return result.lastInsertRowid;
|
|
66
|
+
}
|
|
67
|
+
export function updateInteraction(id, data) {
|
|
68
|
+
const sets = [];
|
|
69
|
+
const vals = [];
|
|
70
|
+
if (data.command !== undefined) {
|
|
71
|
+
sets.push("command = ?");
|
|
72
|
+
vals.push(data.command);
|
|
73
|
+
}
|
|
74
|
+
if (data.output !== undefined) {
|
|
75
|
+
sets.push("output = ?");
|
|
76
|
+
vals.push(data.output.slice(0, 500));
|
|
77
|
+
}
|
|
78
|
+
if (data.exitCode !== undefined) {
|
|
79
|
+
sets.push("exit_code = ?");
|
|
80
|
+
vals.push(data.exitCode);
|
|
81
|
+
}
|
|
82
|
+
if (data.tokensSaved !== undefined) {
|
|
83
|
+
sets.push("tokens_saved = ?");
|
|
84
|
+
vals.push(data.tokensSaved);
|
|
85
|
+
}
|
|
86
|
+
if (sets.length === 0)
|
|
87
|
+
return;
|
|
88
|
+
vals.push(id);
|
|
89
|
+
getDb().prepare(`UPDATE interactions SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
90
|
+
}
|
|
91
|
+
export function getSessionInteractions(sessionId) {
|
|
92
|
+
return getDb().prepare("SELECT * FROM interactions WHERE session_id = ? ORDER BY created_at ASC").all(sessionId);
|
|
93
|
+
}
|
|
94
|
+
export function getSessionStats() {
|
|
95
|
+
const d = getDb();
|
|
96
|
+
const sessions = d.prepare("SELECT COUNT(*) as c FROM sessions").get();
|
|
97
|
+
const interactions = d.prepare("SELECT COUNT(*) as c, SUM(tokens_saved) as saved, SUM(tokens_used) as used FROM interactions").get();
|
|
98
|
+
const cached = d.prepare("SELECT COUNT(*) as c FROM interactions WHERE cached = 1").get();
|
|
99
|
+
const errors = d.prepare("SELECT COUNT(*) as c FROM interactions WHERE exit_code IS NOT NULL AND exit_code != 0").get();
|
|
100
|
+
const totalInteractions = interactions.c ?? 0;
|
|
101
|
+
return {
|
|
102
|
+
totalSessions: sessions.c ?? 0,
|
|
103
|
+
totalInteractions,
|
|
104
|
+
totalTokensSaved: interactions.saved ?? 0,
|
|
105
|
+
totalTokensUsed: interactions.used ?? 0,
|
|
106
|
+
cacheHitRate: totalInteractions > 0 ? (cached.c ?? 0) / totalInteractions : 0,
|
|
107
|
+
avgInteractionsPerSession: sessions.c > 0 ? totalInteractions / sessions.c : 0,
|
|
108
|
+
errorRate: totalInteractions > 0 ? (errors.c ?? 0) / totalInteractions : 0,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Close the database connection */
|
|
112
|
+
export function closeDb() {
|
|
113
|
+
if (db) {
|
|
114
|
+
db.close();
|
|
115
|
+
db = null;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/terminal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"dependencies": {
|
|
17
17
|
"@anthropic-ai/sdk": "^0.39.0",
|
|
18
18
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
19
|
+
"better-sqlite3": "^12.8.0",
|
|
19
20
|
"ink": "^5.0.1",
|
|
20
21
|
"react": "^18.2.0",
|
|
21
22
|
"zod": "^4.3.6"
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"url": "git+https://github.com/hasna/terminal.git"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
33
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
32
34
|
"@types/node": "^20.0.0",
|
|
33
35
|
"@types/react": "^18.2.0",
|
|
34
36
|
"tsx": "^4.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import StatusBar from "./StatusBar.js";
|
|
|
9
9
|
import Spinner from "./Spinner.js";
|
|
10
10
|
import Browse from "./Browse.js";
|
|
11
11
|
import FuzzyPicker from "./FuzzyPicker.js";
|
|
12
|
+
import { createSession, endSession, logInteraction, updateInteraction } from "./sessions-db.js";
|
|
12
13
|
|
|
13
14
|
loadCache();
|
|
14
15
|
|
|
@@ -107,6 +108,8 @@ export default function App() {
|
|
|
107
108
|
const [activeTab, setActiveTab] = useState(0);
|
|
108
109
|
const abortRef = useRef<AbortController | null>(null);
|
|
109
110
|
let nextTabId = useRef(2);
|
|
111
|
+
const sessionIdRef = useRef<string>(createSession(process.cwd()));
|
|
112
|
+
const interactionIdRef = useRef<number>(0);
|
|
110
113
|
|
|
111
114
|
const tab = tabs[activeTab];
|
|
112
115
|
const allNl = [...nlHistory, ...tab.sessionNl];
|
|
@@ -149,6 +152,10 @@ export default function App() {
|
|
|
149
152
|
}],
|
|
150
153
|
}));
|
|
151
154
|
appendHistory({ nl, cmd, output: lines.join("\n"), ts: Date.now(), error });
|
|
155
|
+
// Log to SQLite session
|
|
156
|
+
if (interactionIdRef.current) {
|
|
157
|
+
updateInteraction(interactionIdRef.current, { output: shortOutput, exitCode: error ? 1 : 0 });
|
|
158
|
+
}
|
|
152
159
|
};
|
|
153
160
|
|
|
154
161
|
// ── run command ─────────────────────────────────────────────────────────────
|
|
@@ -193,6 +200,11 @@ export default function App() {
|
|
|
193
200
|
|
|
194
201
|
const translateAndRun = async (nl: string, raw: boolean) => {
|
|
195
202
|
updateTab(t => ({ ...t, sessionNl: [...t.sessionNl, nl] }));
|
|
203
|
+
|
|
204
|
+
// Log interaction start
|
|
205
|
+
const startTime = Date.now();
|
|
206
|
+
interactionIdRef.current = logInteraction(sessionIdRef.current, { nl });
|
|
207
|
+
|
|
196
208
|
if (raw) { await runPhase(nl, nl, true); return; }
|
|
197
209
|
|
|
198
210
|
const sessionEntries = tabs[activeTab].sessionEntries;
|
|
@@ -201,6 +213,8 @@ export default function App() {
|
|
|
201
213
|
const command = await translateToCommand(nl, config.permissions, sessionEntries, partial =>
|
|
202
214
|
setPhase({ type: "thinking", nl, partial })
|
|
203
215
|
);
|
|
216
|
+
// Update interaction with generated command
|
|
217
|
+
updateInteraction(interactionIdRef.current, { command });
|
|
204
218
|
const blocked = checkPermissions(command, config.permissions);
|
|
205
219
|
if (blocked) {
|
|
206
220
|
pushScroll({ nl, cmd: command, lines: [`blocked: ${blocked}`], truncated: false, error: true });
|
package/src/cli.tsx
CHANGED
|
@@ -107,6 +107,51 @@ else if (args[0] === "stats") {
|
|
|
107
107
|
console.log(` Search: ${formatTokens(s.savingsByFeature.search)}`);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// ── Sessions command ─────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
else if (args[0] === "sessions") {
|
|
113
|
+
const { listSessions, getSession, getSessionInteractions, getSessionStats } = await import("./sessions-db.js");
|
|
114
|
+
|
|
115
|
+
if (args[1] === "stats") {
|
|
116
|
+
const stats = getSessionStats();
|
|
117
|
+
console.log("Session Stats:");
|
|
118
|
+
console.log(` Total sessions: ${stats.totalSessions}`);
|
|
119
|
+
console.log(` Total interactions: ${stats.totalInteractions}`);
|
|
120
|
+
console.log(` Tokens saved: ${stats.totalTokensSaved}`);
|
|
121
|
+
console.log(` Tokens used: ${stats.totalTokensUsed}`);
|
|
122
|
+
console.log(` Cache hit rate: ${(stats.cacheHitRate * 100).toFixed(1)}%`);
|
|
123
|
+
console.log(` Avg per session: ${stats.avgInteractionsPerSession.toFixed(1)}`);
|
|
124
|
+
console.log(` Error rate: ${(stats.errorRate * 100).toFixed(1)}%`);
|
|
125
|
+
} else if (args[1]) {
|
|
126
|
+
// Show specific session
|
|
127
|
+
const session = getSession(args[1]);
|
|
128
|
+
if (!session) { console.error(`Session '${args[1]}' not found.`); process.exit(1); }
|
|
129
|
+
console.log(`Session: ${session.id}`);
|
|
130
|
+
console.log(` Started: ${new Date(session.started_at).toLocaleString()}`);
|
|
131
|
+
console.log(` CWD: ${session.cwd}`);
|
|
132
|
+
console.log(` Provider: ${session.provider ?? "auto"}`);
|
|
133
|
+
console.log("");
|
|
134
|
+
const interactions = getSessionInteractions(session.id);
|
|
135
|
+
for (const i of interactions) {
|
|
136
|
+
const status = i.exit_code === 0 ? "✓" : i.exit_code ? "✗" : "·";
|
|
137
|
+
console.log(` ${status} ${i.nl}`);
|
|
138
|
+
if (i.command) console.log(` $ ${i.command}`);
|
|
139
|
+
}
|
|
140
|
+
console.log(`\n ${interactions.length} interactions`);
|
|
141
|
+
} else {
|
|
142
|
+
// List recent sessions
|
|
143
|
+
const sessions = listSessions(20);
|
|
144
|
+
if (sessions.length === 0) { console.log("No sessions yet."); }
|
|
145
|
+
else {
|
|
146
|
+
for (const s of sessions) {
|
|
147
|
+
const date = new Date(s.started_at).toLocaleString();
|
|
148
|
+
const dir = s.cwd.split("/").pop() || s.cwd;
|
|
149
|
+
console.log(` ${s.id.slice(0, 8)} ${date} ${dir} ${s.provider ?? "auto"}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
110
155
|
// ── Snapshot command ─────────────────────────────────────────────────────────
|
|
111
156
|
|
|
112
157
|
else if (args[0] === "snapshot") {
|
package/src/mcp/server.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { listRecipes, listCollections, getRecipe, createRecipe } from "../recipe
|
|
|
12
12
|
import { substituteVariables } from "../recipes/model.js";
|
|
13
13
|
import { bgStart, bgStatus, bgStop, bgLogs, bgWaitPort } from "../supervisor.js";
|
|
14
14
|
import { diffOutput } from "../diff-cache.js";
|
|
15
|
+
import { listSessions, getSessionInteractions, getSessionStats } from "../sessions-db.js";
|
|
15
16
|
import { getEconomyStats, recordSaving } from "../economy.js";
|
|
16
17
|
import { captureSnapshot } from "../snapshots.js";
|
|
17
18
|
|
|
@@ -463,6 +464,29 @@ export function createServer(): McpServer {
|
|
|
463
464
|
}
|
|
464
465
|
);
|
|
465
466
|
|
|
467
|
+
// ── session_history: query session data ────────────────────────────────────
|
|
468
|
+
|
|
469
|
+
server.tool(
|
|
470
|
+
"session_history",
|
|
471
|
+
"Query terminal session history — recent sessions, specific session details, or aggregate stats.",
|
|
472
|
+
{
|
|
473
|
+
action: z.enum(["list", "detail", "stats"]).describe("list=recent sessions, detail=specific session, stats=aggregates"),
|
|
474
|
+
sessionId: z.string().optional().describe("Session ID (for detail action)"),
|
|
475
|
+
limit: z.number().optional().describe("Max sessions to return (for list, default: 20)"),
|
|
476
|
+
},
|
|
477
|
+
async ({ action, sessionId, limit }) => {
|
|
478
|
+
if (action === "stats") {
|
|
479
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(getSessionStats()) }] };
|
|
480
|
+
}
|
|
481
|
+
if (action === "detail" && sessionId) {
|
|
482
|
+
const interactions = getSessionInteractions(sessionId);
|
|
483
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(interactions) }] };
|
|
484
|
+
}
|
|
485
|
+
const sessions = listSessions(limit ?? 20);
|
|
486
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(sessions) }] };
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
|
|
466
490
|
return server;
|
|
467
491
|
}
|
|
468
492
|
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// SQLite session database — tracks every terminal interaction
|
|
2
|
+
|
|
3
|
+
import Database from "better-sqlite3";
|
|
4
|
+
import { existsSync, mkdirSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { randomUUID } from "crypto";
|
|
8
|
+
|
|
9
|
+
const DIR = join(homedir(), ".terminal");
|
|
10
|
+
const DB_PATH = join(DIR, "sessions.db");
|
|
11
|
+
|
|
12
|
+
let db: Database.Database | null = null;
|
|
13
|
+
|
|
14
|
+
function getDb(): Database.Database {
|
|
15
|
+
if (db) return db;
|
|
16
|
+
if (!existsSync(DIR)) mkdirSync(DIR, { recursive: true });
|
|
17
|
+
db = new Database(DB_PATH);
|
|
18
|
+
db.pragma("journal_mode = WAL");
|
|
19
|
+
|
|
20
|
+
db.exec(`
|
|
21
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
started_at INTEGER NOT NULL,
|
|
24
|
+
ended_at INTEGER,
|
|
25
|
+
cwd TEXT NOT NULL,
|
|
26
|
+
provider TEXT,
|
|
27
|
+
model TEXT
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS interactions (
|
|
31
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
+
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
33
|
+
nl TEXT NOT NULL,
|
|
34
|
+
command TEXT,
|
|
35
|
+
output TEXT,
|
|
36
|
+
exit_code INTEGER,
|
|
37
|
+
tokens_used INTEGER DEFAULT 0,
|
|
38
|
+
tokens_saved INTEGER DEFAULT 0,
|
|
39
|
+
duration_ms INTEGER,
|
|
40
|
+
model TEXT,
|
|
41
|
+
cached INTEGER DEFAULT 0,
|
|
42
|
+
created_at INTEGER NOT NULL
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_interactions_session ON interactions(session_id);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);
|
|
47
|
+
`);
|
|
48
|
+
|
|
49
|
+
return db;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Sessions ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export function createSession(cwd: string, provider?: string, model?: string): string {
|
|
55
|
+
const id = randomUUID();
|
|
56
|
+
getDb().prepare(
|
|
57
|
+
"INSERT INTO sessions (id, started_at, cwd, provider, model) VALUES (?, ?, ?, ?, ?)"
|
|
58
|
+
).run(id, Date.now(), cwd, provider ?? null, model ?? null);
|
|
59
|
+
return id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function endSession(sessionId: string): void {
|
|
63
|
+
getDb().prepare("UPDATE sessions SET ended_at = ? WHERE id = ?").run(Date.now(), sessionId);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SessionRow {
|
|
67
|
+
id: string;
|
|
68
|
+
started_at: number;
|
|
69
|
+
ended_at: number | null;
|
|
70
|
+
cwd: string;
|
|
71
|
+
provider: string | null;
|
|
72
|
+
model: string | null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function listSessions(limit: number = 20): SessionRow[] {
|
|
76
|
+
return getDb().prepare(
|
|
77
|
+
"SELECT * FROM sessions ORDER BY started_at DESC LIMIT ?"
|
|
78
|
+
).all(limit) as SessionRow[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function getSession(id: string): SessionRow | null {
|
|
82
|
+
return getDb().prepare("SELECT * FROM sessions WHERE id = ?").get(id) as SessionRow | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Interactions ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export interface InteractionRow {
|
|
88
|
+
id: number;
|
|
89
|
+
session_id: string;
|
|
90
|
+
nl: string;
|
|
91
|
+
command: string | null;
|
|
92
|
+
output: string | null;
|
|
93
|
+
exit_code: number | null;
|
|
94
|
+
tokens_used: number;
|
|
95
|
+
tokens_saved: number;
|
|
96
|
+
duration_ms: number | null;
|
|
97
|
+
model: string | null;
|
|
98
|
+
cached: number;
|
|
99
|
+
created_at: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function logInteraction(sessionId: string, data: {
|
|
103
|
+
nl: string;
|
|
104
|
+
command?: string;
|
|
105
|
+
output?: string;
|
|
106
|
+
exitCode?: number;
|
|
107
|
+
tokensUsed?: number;
|
|
108
|
+
tokensSaved?: number;
|
|
109
|
+
durationMs?: number;
|
|
110
|
+
model?: string;
|
|
111
|
+
cached?: boolean;
|
|
112
|
+
}): number {
|
|
113
|
+
const result = getDb().prepare(
|
|
114
|
+
`INSERT INTO interactions (session_id, nl, command, output, exit_code, tokens_used, tokens_saved, duration_ms, model, cached, created_at)
|
|
115
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
116
|
+
).run(
|
|
117
|
+
sessionId,
|
|
118
|
+
data.nl,
|
|
119
|
+
data.command ?? null,
|
|
120
|
+
data.output ? data.output.slice(0, 500) : null,
|
|
121
|
+
data.exitCode ?? null,
|
|
122
|
+
data.tokensUsed ?? 0,
|
|
123
|
+
data.tokensSaved ?? 0,
|
|
124
|
+
data.durationMs ?? null,
|
|
125
|
+
data.model ?? null,
|
|
126
|
+
data.cached ? 1 : 0,
|
|
127
|
+
Date.now()
|
|
128
|
+
);
|
|
129
|
+
return result.lastInsertRowid as number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function updateInteraction(id: number, data: {
|
|
133
|
+
command?: string;
|
|
134
|
+
output?: string;
|
|
135
|
+
exitCode?: number;
|
|
136
|
+
tokensSaved?: number;
|
|
137
|
+
}): void {
|
|
138
|
+
const sets: string[] = [];
|
|
139
|
+
const vals: unknown[] = [];
|
|
140
|
+
if (data.command !== undefined) { sets.push("command = ?"); vals.push(data.command); }
|
|
141
|
+
if (data.output !== undefined) { sets.push("output = ?"); vals.push(data.output.slice(0, 500)); }
|
|
142
|
+
if (data.exitCode !== undefined) { sets.push("exit_code = ?"); vals.push(data.exitCode); }
|
|
143
|
+
if (data.tokensSaved !== undefined) { sets.push("tokens_saved = ?"); vals.push(data.tokensSaved); }
|
|
144
|
+
if (sets.length === 0) return;
|
|
145
|
+
vals.push(id);
|
|
146
|
+
getDb().prepare(`UPDATE interactions SET ${sets.join(", ")} WHERE id = ?`).run(...vals);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function getSessionInteractions(sessionId: string): InteractionRow[] {
|
|
150
|
+
return getDb().prepare(
|
|
151
|
+
"SELECT * FROM interactions WHERE session_id = ? ORDER BY created_at ASC"
|
|
152
|
+
).all(sessionId) as InteractionRow[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Analytics ────────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export interface SessionStats {
|
|
158
|
+
totalSessions: number;
|
|
159
|
+
totalInteractions: number;
|
|
160
|
+
totalTokensSaved: number;
|
|
161
|
+
totalTokensUsed: number;
|
|
162
|
+
cacheHitRate: number;
|
|
163
|
+
avgInteractionsPerSession: number;
|
|
164
|
+
errorRate: number;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getSessionStats(): SessionStats {
|
|
168
|
+
const d = getDb();
|
|
169
|
+
const sessions = d.prepare("SELECT COUNT(*) as c FROM sessions").get() as any;
|
|
170
|
+
const interactions = d.prepare("SELECT COUNT(*) as c, SUM(tokens_saved) as saved, SUM(tokens_used) as used FROM interactions").get() as any;
|
|
171
|
+
const cached = d.prepare("SELECT COUNT(*) as c FROM interactions WHERE cached = 1").get() as any;
|
|
172
|
+
const errors = d.prepare("SELECT COUNT(*) as c FROM interactions WHERE exit_code IS NOT NULL AND exit_code != 0").get() as any;
|
|
173
|
+
|
|
174
|
+
const totalInteractions = interactions.c ?? 0;
|
|
175
|
+
return {
|
|
176
|
+
totalSessions: sessions.c ?? 0,
|
|
177
|
+
totalInteractions,
|
|
178
|
+
totalTokensSaved: interactions.saved ?? 0,
|
|
179
|
+
totalTokensUsed: interactions.used ?? 0,
|
|
180
|
+
cacheHitRate: totalInteractions > 0 ? (cached.c ?? 0) / totalInteractions : 0,
|
|
181
|
+
avgInteractionsPerSession: sessions.c > 0 ? totalInteractions / sessions.c : 0,
|
|
182
|
+
errorRate: totalInteractions > 0 ? (errors.c ?? 0) / totalInteractions : 0,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Close the database connection */
|
|
187
|
+
export function closeDb(): void {
|
|
188
|
+
if (db) { db.close(); db = null; }
|
|
189
|
+
}
|