@blockrun/franklin 3.0.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/LICENSE +190 -0
- package/README.md +256 -0
- package/dist/agent/commands.d.ts +27 -0
- package/dist/agent/commands.js +659 -0
- package/dist/agent/compact.d.ts +31 -0
- package/dist/agent/compact.js +366 -0
- package/dist/agent/context.d.ts +11 -0
- package/dist/agent/context.js +184 -0
- package/dist/agent/error-classifier.d.ts +10 -0
- package/dist/agent/error-classifier.js +61 -0
- package/dist/agent/llm.d.ts +63 -0
- package/dist/agent/llm.js +448 -0
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +346 -0
- package/dist/agent/optimize.d.ts +53 -0
- package/dist/agent/optimize.js +262 -0
- package/dist/agent/permissions.d.ts +39 -0
- package/dist/agent/permissions.js +226 -0
- package/dist/agent/reduce.d.ts +49 -0
- package/dist/agent/reduce.js +317 -0
- package/dist/agent/streaming-executor.d.ts +36 -0
- package/dist/agent/streaming-executor.js +149 -0
- package/dist/agent/tokens.d.ts +53 -0
- package/dist/agent/tokens.js +185 -0
- package/dist/agent/types.d.ts +125 -0
- package/dist/agent/types.js +5 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +27 -0
- package/dist/commands/balance.d.ts +1 -0
- package/dist/commands/balance.js +40 -0
- package/dist/commands/config.d.ts +14 -0
- package/dist/commands/config.js +107 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +117 -0
- package/dist/commands/history.d.ts +5 -0
- package/dist/commands/history.js +31 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +92 -0
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/models.d.ts +1 -0
- package/dist/commands/models.js +56 -0
- package/dist/commands/plugin.d.ts +14 -0
- package/dist/commands/plugin.js +176 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +106 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +292 -0
- package/dist/commands/stats.d.ts +10 -0
- package/dist/commands/stats.js +94 -0
- package/dist/commands/uninit.d.ts +1 -0
- package/dist/commands/uninit.js +63 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +179 -0
- package/dist/mcp/client.d.ts +44 -0
- package/dist/mcp/client.js +147 -0
- package/dist/mcp/config.d.ts +20 -0
- package/dist/mcp/config.js +138 -0
- package/dist/plugin-sdk/channel.d.ts +100 -0
- package/dist/plugin-sdk/channel.js +10 -0
- package/dist/plugin-sdk/index.d.ts +14 -0
- package/dist/plugin-sdk/index.js +9 -0
- package/dist/plugin-sdk/plugin.d.ts +87 -0
- package/dist/plugin-sdk/plugin.js +7 -0
- package/dist/plugin-sdk/search.d.ts +13 -0
- package/dist/plugin-sdk/search.js +4 -0
- package/dist/plugin-sdk/tracker.d.ts +27 -0
- package/dist/plugin-sdk/tracker.js +5 -0
- package/dist/plugin-sdk/workflow.d.ts +126 -0
- package/dist/plugin-sdk/workflow.js +11 -0
- package/dist/plugins/registry.d.ts +33 -0
- package/dist/plugins/registry.js +155 -0
- package/dist/plugins/runner.d.ts +21 -0
- package/dist/plugins/runner.js +453 -0
- package/dist/plugins-bundled/social/index.d.ts +10 -0
- package/dist/plugins-bundled/social/index.js +363 -0
- package/dist/plugins-bundled/social/plugin.json +14 -0
- package/dist/plugins-bundled/social/prompts.d.ts +19 -0
- package/dist/plugins-bundled/social/prompts.js +67 -0
- package/dist/plugins-bundled/social/types.d.ts +58 -0
- package/dist/plugins-bundled/social/types.js +16 -0
- package/dist/pricing.d.ts +21 -0
- package/dist/pricing.js +91 -0
- package/dist/proxy/fallback.d.ts +38 -0
- package/dist/proxy/fallback.js +144 -0
- package/dist/proxy/server.d.ts +18 -0
- package/dist/proxy/server.js +576 -0
- package/dist/proxy/sse-translator.d.ts +29 -0
- package/dist/proxy/sse-translator.js +270 -0
- package/dist/router/index.d.ts +22 -0
- package/dist/router/index.js +269 -0
- package/dist/session/search.d.ts +33 -0
- package/dist/session/search.js +229 -0
- package/dist/session/storage.d.ts +48 -0
- package/dist/session/storage.js +173 -0
- package/dist/stats/insights.d.ts +55 -0
- package/dist/stats/insights.js +195 -0
- package/dist/stats/tracker.d.ts +54 -0
- package/dist/stats/tracker.js +165 -0
- package/dist/tools/askuser.d.ts +6 -0
- package/dist/tools/askuser.js +76 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +336 -0
- package/dist/tools/edit.d.ts +5 -0
- package/dist/tools/edit.js +148 -0
- package/dist/tools/glob.d.ts +5 -0
- package/dist/tools/glob.js +158 -0
- package/dist/tools/grep.d.ts +5 -0
- package/dist/tools/grep.js +194 -0
- package/dist/tools/imagegen.d.ts +6 -0
- package/dist/tools/imagegen.js +172 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.js +90 -0
- package/dist/tools/subagent.d.ts +5 -0
- package/dist/tools/subagent.js +116 -0
- package/dist/tools/task.d.ts +5 -0
- package/dist/tools/task.js +91 -0
- package/dist/tools/webfetch.d.ts +5 -0
- package/dist/tools/webfetch.js +166 -0
- package/dist/tools/websearch.d.ts +5 -0
- package/dist/tools/websearch.js +103 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +114 -0
- package/dist/ui/app.d.ts +26 -0
- package/dist/ui/app.js +545 -0
- package/dist/ui/model-picker.d.ts +14 -0
- package/dist/ui/model-picker.js +161 -0
- package/dist/ui/terminal.d.ts +35 -0
- package/dist/ui/terminal.js +337 -0
- package/dist/wallet/manager.d.ts +10 -0
- package/dist/wallet/manager.js +23 -0
- package/package.json +79 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session search — find past conversations by keyword.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by Hermes Agent's FTS5 search (`hermes_state.py`). For RunCode's
|
|
5
|
+
* scale (last 20 sessions) we use a lightweight in-memory tokenized search
|
|
6
|
+
* instead of SQLite FTS5 — zero install cost, same user experience.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import { listSessions, getSessionFilePath } from './storage.js';
|
|
10
|
+
// ─── Tokenization ─────────────────────────────────────────────────────────
|
|
11
|
+
function tokenize(text) {
|
|
12
|
+
return text
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/[^\w\s]/g, ' ')
|
|
15
|
+
.split(/\s+/)
|
|
16
|
+
.filter(t => t.length > 1);
|
|
17
|
+
}
|
|
18
|
+
function parseQuery(query) {
|
|
19
|
+
const phrases = [];
|
|
20
|
+
// Extract quoted phrases first
|
|
21
|
+
const cleaned = query.replace(/"([^"]+)"/g, (_, phrase) => {
|
|
22
|
+
phrases.push(phrase.toLowerCase());
|
|
23
|
+
return ' ';
|
|
24
|
+
});
|
|
25
|
+
const terms = tokenize(cleaned);
|
|
26
|
+
return { terms, phrases };
|
|
27
|
+
}
|
|
28
|
+
// ─── Snippet Extraction ───────────────────────────────────────────────────
|
|
29
|
+
function extractSnippet(content, query, maxLen = 200) {
|
|
30
|
+
const lower = content.toLowerCase();
|
|
31
|
+
const q = query.toLowerCase();
|
|
32
|
+
const idx = lower.indexOf(q);
|
|
33
|
+
if (idx === -1) {
|
|
34
|
+
// Fall back to first token match
|
|
35
|
+
const firstToken = tokenize(query)[0];
|
|
36
|
+
if (firstToken) {
|
|
37
|
+
const tIdx = lower.indexOf(firstToken);
|
|
38
|
+
if (tIdx !== -1)
|
|
39
|
+
return centerSnippet(content, tIdx, firstToken.length, maxLen);
|
|
40
|
+
}
|
|
41
|
+
return content.slice(0, maxLen);
|
|
42
|
+
}
|
|
43
|
+
return centerSnippet(content, idx, q.length, maxLen);
|
|
44
|
+
}
|
|
45
|
+
function centerSnippet(content, matchStart, matchLen, maxLen) {
|
|
46
|
+
const padding = Math.floor((maxLen - matchLen) / 2);
|
|
47
|
+
const start = Math.max(0, matchStart - padding);
|
|
48
|
+
const end = Math.min(content.length, matchStart + matchLen + padding);
|
|
49
|
+
const prefix = start > 0 ? '...' : '';
|
|
50
|
+
const suffix = end < content.length ? '...' : '';
|
|
51
|
+
return (prefix + content.slice(start, end) + suffix).replace(/\s+/g, ' ').trim();
|
|
52
|
+
}
|
|
53
|
+
function extractMessageText(msg) {
|
|
54
|
+
if (typeof msg.content === 'string')
|
|
55
|
+
return msg.content;
|
|
56
|
+
if (Array.isArray(msg.content)) {
|
|
57
|
+
return msg.content
|
|
58
|
+
.map((part) => {
|
|
59
|
+
if (typeof part === 'string')
|
|
60
|
+
return part;
|
|
61
|
+
if (!part || typeof part !== 'object')
|
|
62
|
+
return '';
|
|
63
|
+
const p = part;
|
|
64
|
+
if (p.type === 'text' && typeof p.text === 'string')
|
|
65
|
+
return p.text;
|
|
66
|
+
if (p.type === 'tool_use' && typeof p.name === 'string')
|
|
67
|
+
return `[tool:${p.name}]`;
|
|
68
|
+
if (p.type === 'tool_result') {
|
|
69
|
+
const c = p.content;
|
|
70
|
+
if (typeof c === 'string')
|
|
71
|
+
return c;
|
|
72
|
+
if (Array.isArray(c)) {
|
|
73
|
+
return c.map((cp) => {
|
|
74
|
+
if (typeof cp === 'string')
|
|
75
|
+
return cp;
|
|
76
|
+
if (cp && typeof cp === 'object' && 'text' in cp)
|
|
77
|
+
return String(cp.text);
|
|
78
|
+
return '';
|
|
79
|
+
}).join(' ');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return '';
|
|
83
|
+
})
|
|
84
|
+
.join(' ');
|
|
85
|
+
}
|
|
86
|
+
return '';
|
|
87
|
+
}
|
|
88
|
+
// ─── Core Search ──────────────────────────────────────────────────────────
|
|
89
|
+
/**
|
|
90
|
+
* Search sessions for a query string.
|
|
91
|
+
* Returns results ranked by relevance (term frequency + recency).
|
|
92
|
+
*/
|
|
93
|
+
export function searchSessions(query, options = {}) {
|
|
94
|
+
const { limit = 10, model, since } = options;
|
|
95
|
+
const { terms, phrases } = parseQuery(query);
|
|
96
|
+
if (terms.length === 0 && phrases.length === 0)
|
|
97
|
+
return [];
|
|
98
|
+
const sessions = listSessions();
|
|
99
|
+
const results = [];
|
|
100
|
+
for (const session of sessions) {
|
|
101
|
+
if (model && !session.model.toLowerCase().includes(model.toLowerCase()))
|
|
102
|
+
continue;
|
|
103
|
+
if (since && session.updatedAt < since)
|
|
104
|
+
continue;
|
|
105
|
+
const match = scoreSession(session, terms, phrases, query);
|
|
106
|
+
if (match)
|
|
107
|
+
results.push(match);
|
|
108
|
+
}
|
|
109
|
+
// Sort by score desc, then recency desc
|
|
110
|
+
results.sort((a, b) => {
|
|
111
|
+
if (b.score !== a.score)
|
|
112
|
+
return b.score - a.score;
|
|
113
|
+
return b.session.updatedAt - a.session.updatedAt;
|
|
114
|
+
});
|
|
115
|
+
return results.slice(0, limit);
|
|
116
|
+
}
|
|
117
|
+
function scoreSession(session, terms, phrases, originalQuery) {
|
|
118
|
+
// Locate the session's JSONL file (search in sessions dir)
|
|
119
|
+
const sessionFile = findSessionFile(session.id);
|
|
120
|
+
if (!sessionFile)
|
|
121
|
+
return null;
|
|
122
|
+
let rawContent;
|
|
123
|
+
try {
|
|
124
|
+
rawContent = fs.readFileSync(sessionFile, 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
// Parse messages
|
|
130
|
+
const messages = [];
|
|
131
|
+
for (const line of rawContent.split('\n')) {
|
|
132
|
+
if (!line.trim())
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
const msg = JSON.parse(line);
|
|
136
|
+
if (msg && typeof msg === 'object' && 'role' in msg) {
|
|
137
|
+
messages.push(msg);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch { /* skip malformed */ }
|
|
141
|
+
}
|
|
142
|
+
if (messages.length === 0)
|
|
143
|
+
return null;
|
|
144
|
+
// Score each message
|
|
145
|
+
let totalScore = 0;
|
|
146
|
+
let hitCount = 0;
|
|
147
|
+
let bestSnippet = '';
|
|
148
|
+
let bestMatchedRole = 'user';
|
|
149
|
+
let bestMessageScore = 0;
|
|
150
|
+
for (const msg of messages) {
|
|
151
|
+
const text = extractMessageText(msg);
|
|
152
|
+
if (!text)
|
|
153
|
+
continue;
|
|
154
|
+
const lowerText = text.toLowerCase();
|
|
155
|
+
// Score: sum of term frequencies + phrase bonuses
|
|
156
|
+
let msgScore = 0;
|
|
157
|
+
for (const term of terms) {
|
|
158
|
+
const count = countOccurrences(lowerText, term);
|
|
159
|
+
if (count > 0) {
|
|
160
|
+
msgScore += count;
|
|
161
|
+
hitCount += count;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
for (const phrase of phrases) {
|
|
165
|
+
const count = countOccurrences(lowerText, phrase);
|
|
166
|
+
if (count > 0) {
|
|
167
|
+
// Phrase matches are worth 3x term matches
|
|
168
|
+
msgScore += count * 3;
|
|
169
|
+
hitCount += count;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Assistant matches slightly preferred (usually more substantive)
|
|
173
|
+
if (msg.role === 'assistant')
|
|
174
|
+
msgScore *= 1.1;
|
|
175
|
+
if (msgScore > bestMessageScore) {
|
|
176
|
+
bestMessageScore = msgScore;
|
|
177
|
+
bestSnippet = extractSnippet(text, originalQuery);
|
|
178
|
+
bestMatchedRole = msg.role === 'assistant' ? 'assistant' : 'user';
|
|
179
|
+
}
|
|
180
|
+
totalScore += msgScore;
|
|
181
|
+
}
|
|
182
|
+
if (totalScore === 0)
|
|
183
|
+
return null;
|
|
184
|
+
// Recency bonus: newer sessions get a small boost
|
|
185
|
+
const ageDays = (Date.now() - session.updatedAt) / (1000 * 60 * 60 * 24);
|
|
186
|
+
const recencyBonus = Math.max(0, 5 - ageDays * 0.1);
|
|
187
|
+
const finalScore = totalScore + recencyBonus;
|
|
188
|
+
return {
|
|
189
|
+
session,
|
|
190
|
+
score: finalScore,
|
|
191
|
+
hitCount,
|
|
192
|
+
snippet: bestSnippet,
|
|
193
|
+
matchedRole: bestMatchedRole,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function countOccurrences(text, needle) {
|
|
197
|
+
if (!needle)
|
|
198
|
+
return 0;
|
|
199
|
+
let count = 0;
|
|
200
|
+
let idx = 0;
|
|
201
|
+
while ((idx = text.indexOf(needle, idx)) !== -1) {
|
|
202
|
+
count++;
|
|
203
|
+
idx += needle.length;
|
|
204
|
+
}
|
|
205
|
+
return count;
|
|
206
|
+
}
|
|
207
|
+
function findSessionFile(sessionId) {
|
|
208
|
+
const p = getSessionFilePath(sessionId);
|
|
209
|
+
return fs.existsSync(p) ? p : null;
|
|
210
|
+
}
|
|
211
|
+
// ─── Display ──────────────────────────────────────────────────────────────
|
|
212
|
+
export function formatSearchResults(matches, query) {
|
|
213
|
+
if (matches.length === 0) {
|
|
214
|
+
return `\nNo sessions found matching "${query}".\n`;
|
|
215
|
+
}
|
|
216
|
+
const lines = [];
|
|
217
|
+
lines.push(`\n Found ${matches.length} session${matches.length === 1 ? '' : 's'} matching "${query}":\n`);
|
|
218
|
+
for (let i = 0; i < matches.length; i++) {
|
|
219
|
+
const m = matches[i];
|
|
220
|
+
const date = new Date(m.session.updatedAt).toISOString().slice(0, 16).replace('T', ' ');
|
|
221
|
+
const hitLabel = m.hitCount === 1 ? 'hit' : 'hits';
|
|
222
|
+
lines.push(` ${i + 1}. ${m.session.id}`);
|
|
223
|
+
lines.push(` ${date} | ${m.session.model} | ${m.hitCount} ${hitLabel} | score ${m.score.toFixed(1)}`);
|
|
224
|
+
lines.push(` [${m.matchedRole}] ${m.snippet}`);
|
|
225
|
+
lines.push('');
|
|
226
|
+
}
|
|
227
|
+
lines.push(` Resume: runcode (then /resume <session-id>)\n`);
|
|
228
|
+
return lines.join('\n');
|
|
229
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence for runcode.
|
|
3
|
+
* Saves conversation history as JSONL for resume capability.
|
|
4
|
+
*/
|
|
5
|
+
import type { Dialogue } from '../agent/types.js';
|
|
6
|
+
export interface SessionMeta {
|
|
7
|
+
id: string;
|
|
8
|
+
model: string;
|
|
9
|
+
workDir: string;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
updatedAt: number;
|
|
12
|
+
turnCount: number;
|
|
13
|
+
messageCount: number;
|
|
14
|
+
}
|
|
15
|
+
/** Get the absolute path to a session's JSONL file (for external readers like search). */
|
|
16
|
+
export declare function getSessionFilePath(id: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Create a new session ID based on timestamp.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createSessionId(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Save a message to the session transcript (append-only JSONL).
|
|
23
|
+
*/
|
|
24
|
+
export declare function appendToSession(sessionId: string, message: Dialogue): void;
|
|
25
|
+
/**
|
|
26
|
+
* Update session metadata.
|
|
27
|
+
*/
|
|
28
|
+
export declare function updateSessionMeta(sessionId: string, meta: Partial<SessionMeta>): void;
|
|
29
|
+
/**
|
|
30
|
+
* Load session metadata.
|
|
31
|
+
*/
|
|
32
|
+
export declare function loadSessionMeta(sessionId: string): SessionMeta | null;
|
|
33
|
+
/**
|
|
34
|
+
* Load full session history from JSONL.
|
|
35
|
+
*/
|
|
36
|
+
export declare function loadSessionHistory(sessionId: string): Dialogue[];
|
|
37
|
+
/**
|
|
38
|
+
* List all saved sessions, newest first.
|
|
39
|
+
*/
|
|
40
|
+
export declare function listSessions(): SessionMeta[];
|
|
41
|
+
/**
|
|
42
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
43
|
+
*/
|
|
44
|
+
/**
|
|
45
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
46
|
+
* Accepts optional activeSessionId to protect from deletion.
|
|
47
|
+
*/
|
|
48
|
+
export declare function pruneOldSessions(activeSessionId?: string): void;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session persistence for runcode.
|
|
3
|
+
* Saves conversation history as JSONL for resume capability.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
9
|
+
const MAX_SESSIONS = 20; // Keep last 20 sessions
|
|
10
|
+
let resolvedSessionsDir = null;
|
|
11
|
+
function getSessionsDir() {
|
|
12
|
+
if (resolvedSessionsDir)
|
|
13
|
+
return resolvedSessionsDir;
|
|
14
|
+
const preferred = path.join(BLOCKRUN_DIR, 'sessions');
|
|
15
|
+
const fallback = path.join(os.tmpdir(), 'runcode', 'sessions');
|
|
16
|
+
for (const dir of [preferred, fallback]) {
|
|
17
|
+
try {
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
resolvedSessionsDir = dir;
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// Try the next candidate.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// If both locations fail, keep the preferred path so the original error
|
|
27
|
+
// surfaces from the caller rather than hiding the failure.
|
|
28
|
+
resolvedSessionsDir = preferred;
|
|
29
|
+
return resolvedSessionsDir;
|
|
30
|
+
}
|
|
31
|
+
function sessionPath(id) {
|
|
32
|
+
return path.join(getSessionsDir(), `${id}.jsonl`);
|
|
33
|
+
}
|
|
34
|
+
/** Get the absolute path to a session's JSONL file (for external readers like search). */
|
|
35
|
+
export function getSessionFilePath(id) {
|
|
36
|
+
return sessionPath(id);
|
|
37
|
+
}
|
|
38
|
+
function metaPath(id) {
|
|
39
|
+
return path.join(getSessionsDir(), `${id}.meta.json`);
|
|
40
|
+
}
|
|
41
|
+
function withWritableSessionDir(action) {
|
|
42
|
+
const preferred = path.join(BLOCKRUN_DIR, 'sessions');
|
|
43
|
+
const fallback = path.join(os.tmpdir(), 'runcode', 'sessions');
|
|
44
|
+
try {
|
|
45
|
+
action();
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
const code = err.code;
|
|
49
|
+
const shouldFallback = (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') &&
|
|
50
|
+
resolvedSessionsDir === preferred;
|
|
51
|
+
if (!shouldFallback)
|
|
52
|
+
throw err;
|
|
53
|
+
fs.mkdirSync(fallback, { recursive: true });
|
|
54
|
+
resolvedSessionsDir = fallback;
|
|
55
|
+
action();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Create a new session ID based on timestamp.
|
|
60
|
+
*/
|
|
61
|
+
export function createSessionId() {
|
|
62
|
+
const now = new Date();
|
|
63
|
+
const ts = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
64
|
+
return `session-${ts}`;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Save a message to the session transcript (append-only JSONL).
|
|
68
|
+
*/
|
|
69
|
+
export function appendToSession(sessionId, message) {
|
|
70
|
+
const line = JSON.stringify(message) + '\n';
|
|
71
|
+
withWritableSessionDir(() => {
|
|
72
|
+
fs.appendFileSync(sessionPath(sessionId), line);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Update session metadata.
|
|
77
|
+
*/
|
|
78
|
+
export function updateSessionMeta(sessionId, meta) {
|
|
79
|
+
withWritableSessionDir(() => {
|
|
80
|
+
const existing = loadSessionMeta(sessionId);
|
|
81
|
+
const updated = {
|
|
82
|
+
id: sessionId,
|
|
83
|
+
model: meta.model || existing?.model || 'unknown',
|
|
84
|
+
workDir: meta.workDir || existing?.workDir || '',
|
|
85
|
+
createdAt: existing?.createdAt || Date.now(),
|
|
86
|
+
updatedAt: Date.now(),
|
|
87
|
+
turnCount: meta.turnCount ?? existing?.turnCount ?? 0,
|
|
88
|
+
messageCount: meta.messageCount ?? existing?.messageCount ?? 0,
|
|
89
|
+
};
|
|
90
|
+
fs.writeFileSync(metaPath(sessionId), JSON.stringify(updated, null, 2));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Load session metadata.
|
|
95
|
+
*/
|
|
96
|
+
export function loadSessionMeta(sessionId) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(fs.readFileSync(metaPath(sessionId), 'utf-8'));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Load full session history from JSONL.
|
|
106
|
+
*/
|
|
107
|
+
export function loadSessionHistory(sessionId) {
|
|
108
|
+
try {
|
|
109
|
+
const content = fs.readFileSync(sessionPath(sessionId), 'utf-8');
|
|
110
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
111
|
+
const results = [];
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
try {
|
|
114
|
+
results.push(JSON.parse(line));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Skip corrupted lines — partial writes from crashes
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* List all saved sessions, newest first.
|
|
129
|
+
*/
|
|
130
|
+
export function listSessions() {
|
|
131
|
+
const sessionsDir = getSessionsDir();
|
|
132
|
+
try {
|
|
133
|
+
const files = fs.readdirSync(sessionsDir)
|
|
134
|
+
.filter(f => f.endsWith('.meta.json'));
|
|
135
|
+
const metas = [];
|
|
136
|
+
for (const file of files) {
|
|
137
|
+
try {
|
|
138
|
+
const meta = JSON.parse(fs.readFileSync(path.join(sessionsDir, file), 'utf-8'));
|
|
139
|
+
metas.push(meta);
|
|
140
|
+
}
|
|
141
|
+
catch { /* skip corrupted */ }
|
|
142
|
+
}
|
|
143
|
+
return metas.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
151
|
+
*/
|
|
152
|
+
/**
|
|
153
|
+
* Prune old sessions beyond MAX_SESSIONS.
|
|
154
|
+
* Accepts optional activeSessionId to protect from deletion.
|
|
155
|
+
*/
|
|
156
|
+
export function pruneOldSessions(activeSessionId) {
|
|
157
|
+
const sessions = listSessions();
|
|
158
|
+
if (sessions.length <= MAX_SESSIONS)
|
|
159
|
+
return;
|
|
160
|
+
const toDelete = sessions
|
|
161
|
+
.slice(MAX_SESSIONS)
|
|
162
|
+
.filter(s => s.id !== activeSessionId); // Never delete active session
|
|
163
|
+
for (const s of toDelete) {
|
|
164
|
+
try {
|
|
165
|
+
fs.unlinkSync(sessionPath(s.id));
|
|
166
|
+
}
|
|
167
|
+
catch { /* ok */ }
|
|
168
|
+
try {
|
|
169
|
+
fs.unlinkSync(metaPath(s.id));
|
|
170
|
+
}
|
|
171
|
+
catch { /* ok */ }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session insights engine.
|
|
3
|
+
*
|
|
4
|
+
* Rich usage analytics from the stats tracker history.
|
|
5
|
+
* Inspired by hermes-agent's `agent/insights.py` and Claude Code's /insights.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - Per-model cost and request breakdown
|
|
9
|
+
* - Daily activity trend (sparkline)
|
|
10
|
+
* - Top sessions by cost
|
|
11
|
+
* - Tool usage patterns
|
|
12
|
+
* - Cost projections and efficiency metrics
|
|
13
|
+
*/
|
|
14
|
+
export interface InsightsReport {
|
|
15
|
+
/** Window size in days */
|
|
16
|
+
days: number;
|
|
17
|
+
/** Records within the window */
|
|
18
|
+
windowRecords: number;
|
|
19
|
+
/** Total cost in window */
|
|
20
|
+
totalCostUsd: number;
|
|
21
|
+
/** Total input tokens in window */
|
|
22
|
+
totalInputTokens: number;
|
|
23
|
+
/** Total output tokens in window */
|
|
24
|
+
totalOutputTokens: number;
|
|
25
|
+
/** Savings vs always using Claude Opus */
|
|
26
|
+
savedVsOpusUsd: number;
|
|
27
|
+
/** Per-model breakdown, sorted by cost desc */
|
|
28
|
+
byModel: Array<{
|
|
29
|
+
model: string;
|
|
30
|
+
requests: number;
|
|
31
|
+
costUsd: number;
|
|
32
|
+
inputTokens: number;
|
|
33
|
+
outputTokens: number;
|
|
34
|
+
avgLatencyMs: number;
|
|
35
|
+
percentOfTotal: number;
|
|
36
|
+
}>;
|
|
37
|
+
/** Daily activity (last N days), oldest first */
|
|
38
|
+
daily: Array<{
|
|
39
|
+
date: string;
|
|
40
|
+
requests: number;
|
|
41
|
+
costUsd: number;
|
|
42
|
+
}>;
|
|
43
|
+
/** Projections */
|
|
44
|
+
projections: {
|
|
45
|
+
avgCostPerDay: number;
|
|
46
|
+
projectedMonthlyUsd: number;
|
|
47
|
+
projectedYearlyUsd: number;
|
|
48
|
+
};
|
|
49
|
+
/** Average request cost */
|
|
50
|
+
avgRequestCostUsd: number;
|
|
51
|
+
/** Efficiency: cost per 1K tokens */
|
|
52
|
+
costPer1KTokens: number;
|
|
53
|
+
}
|
|
54
|
+
export declare function generateInsights(days?: number): InsightsReport;
|
|
55
|
+
export declare function formatInsights(report: InsightsReport, days: number): string;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session insights engine.
|
|
3
|
+
*
|
|
4
|
+
* Rich usage analytics from the stats tracker history.
|
|
5
|
+
* Inspired by hermes-agent's `agent/insights.py` and Claude Code's /insights.
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - Per-model cost and request breakdown
|
|
9
|
+
* - Daily activity trend (sparkline)
|
|
10
|
+
* - Top sessions by cost
|
|
11
|
+
* - Tool usage patterns
|
|
12
|
+
* - Cost projections and efficiency metrics
|
|
13
|
+
*/
|
|
14
|
+
import { loadStats } from './tracker.js';
|
|
15
|
+
import { OPUS_PRICING, MODEL_PRICING } from '../pricing.js';
|
|
16
|
+
// ─── Generate Report ──────────────────────────────────────────────────────
|
|
17
|
+
export function generateInsights(days = 30) {
|
|
18
|
+
const stats = loadStats();
|
|
19
|
+
const now = Date.now();
|
|
20
|
+
const windowStart = now - days * 24 * 60 * 60 * 1000;
|
|
21
|
+
const windowHistory = stats.history.filter(r => r.timestamp >= windowStart);
|
|
22
|
+
// Aggregate totals
|
|
23
|
+
let totalCost = 0;
|
|
24
|
+
let totalInput = 0;
|
|
25
|
+
let totalOutput = 0;
|
|
26
|
+
const modelAgg = new Map();
|
|
27
|
+
for (const r of windowHistory) {
|
|
28
|
+
totalCost += r.costUsd;
|
|
29
|
+
totalInput += r.inputTokens;
|
|
30
|
+
totalOutput += r.outputTokens;
|
|
31
|
+
const existing = modelAgg.get(r.model) ?? {
|
|
32
|
+
requests: 0,
|
|
33
|
+
costUsd: 0,
|
|
34
|
+
inputTokens: 0,
|
|
35
|
+
outputTokens: 0,
|
|
36
|
+
totalLatencyMs: 0,
|
|
37
|
+
};
|
|
38
|
+
existing.requests++;
|
|
39
|
+
existing.costUsd += r.costUsd;
|
|
40
|
+
existing.inputTokens += r.inputTokens;
|
|
41
|
+
existing.outputTokens += r.outputTokens;
|
|
42
|
+
existing.totalLatencyMs += r.latencyMs;
|
|
43
|
+
modelAgg.set(r.model, existing);
|
|
44
|
+
}
|
|
45
|
+
// Build byModel array sorted by cost
|
|
46
|
+
const byModel = [];
|
|
47
|
+
for (const [model, agg] of modelAgg.entries()) {
|
|
48
|
+
byModel.push({
|
|
49
|
+
model,
|
|
50
|
+
requests: agg.requests,
|
|
51
|
+
costUsd: agg.costUsd,
|
|
52
|
+
inputTokens: agg.inputTokens,
|
|
53
|
+
outputTokens: agg.outputTokens,
|
|
54
|
+
avgLatencyMs: agg.requests > 0 ? Math.round(agg.totalLatencyMs / agg.requests) : 0,
|
|
55
|
+
percentOfTotal: totalCost > 0 ? (agg.costUsd / totalCost) * 100 : 0,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
byModel.sort((a, b) => b.costUsd - a.costUsd);
|
|
59
|
+
// Daily activity
|
|
60
|
+
const dailyMap = new Map();
|
|
61
|
+
for (let i = 0; i < days; i++) {
|
|
62
|
+
const d = new Date(now - i * 24 * 60 * 60 * 1000);
|
|
63
|
+
const key = d.toISOString().slice(0, 10);
|
|
64
|
+
dailyMap.set(key, { requests: 0, costUsd: 0 });
|
|
65
|
+
}
|
|
66
|
+
for (const r of windowHistory) {
|
|
67
|
+
const key = new Date(r.timestamp).toISOString().slice(0, 10);
|
|
68
|
+
const existing = dailyMap.get(key);
|
|
69
|
+
if (existing) {
|
|
70
|
+
existing.requests++;
|
|
71
|
+
existing.costUsd += r.costUsd;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const daily = Array.from(dailyMap.entries())
|
|
75
|
+
.map(([date, v]) => ({ date, ...v }))
|
|
76
|
+
.sort((a, b) => a.date.localeCompare(b.date));
|
|
77
|
+
// Calculate savings vs Opus
|
|
78
|
+
const opusCostPer1M = (OPUS_PRICING.input + OPUS_PRICING.output) / 2;
|
|
79
|
+
const opusWouldCost = ((totalInput + totalOutput) / 1_000_000) * opusCostPer1M;
|
|
80
|
+
const savedVsOpusUsd = Math.max(0, opusWouldCost - totalCost);
|
|
81
|
+
// Projections
|
|
82
|
+
const avgCostPerDay = days > 0 ? totalCost / days : 0;
|
|
83
|
+
const projections = {
|
|
84
|
+
avgCostPerDay,
|
|
85
|
+
projectedMonthlyUsd: avgCostPerDay * 30,
|
|
86
|
+
projectedYearlyUsd: avgCostPerDay * 365,
|
|
87
|
+
};
|
|
88
|
+
// Efficiency
|
|
89
|
+
const totalTokens = totalInput + totalOutput;
|
|
90
|
+
const costPer1KTokens = totalTokens > 0 ? (totalCost / totalTokens) * 1000 : 0;
|
|
91
|
+
const avgRequestCostUsd = windowHistory.length > 0 ? totalCost / windowHistory.length : 0;
|
|
92
|
+
return {
|
|
93
|
+
days,
|
|
94
|
+
windowRecords: windowHistory.length,
|
|
95
|
+
totalCostUsd: totalCost,
|
|
96
|
+
totalInputTokens: totalInput,
|
|
97
|
+
totalOutputTokens: totalOutput,
|
|
98
|
+
savedVsOpusUsd,
|
|
99
|
+
byModel,
|
|
100
|
+
daily,
|
|
101
|
+
projections,
|
|
102
|
+
avgRequestCostUsd,
|
|
103
|
+
costPer1KTokens,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
// ─── Format for Display ───────────────────────────────────────────────────
|
|
107
|
+
function sparkline(values) {
|
|
108
|
+
if (values.length === 0)
|
|
109
|
+
return '';
|
|
110
|
+
const max = Math.max(...values);
|
|
111
|
+
if (max === 0)
|
|
112
|
+
return '▁'.repeat(values.length);
|
|
113
|
+
const chars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
114
|
+
return values.map(v => chars[Math.min(7, Math.floor((v / max) * 8))]).join('');
|
|
115
|
+
}
|
|
116
|
+
function formatUsd(n) {
|
|
117
|
+
if (n === 0)
|
|
118
|
+
return '$0';
|
|
119
|
+
if (n < 0.01)
|
|
120
|
+
return `$${n.toFixed(4)}`;
|
|
121
|
+
if (n < 1)
|
|
122
|
+
return `$${n.toFixed(3)}`;
|
|
123
|
+
return `$${n.toFixed(2)}`;
|
|
124
|
+
}
|
|
125
|
+
function formatTokens(n) {
|
|
126
|
+
if (n < 1000)
|
|
127
|
+
return String(n);
|
|
128
|
+
if (n < 1_000_000)
|
|
129
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
130
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
131
|
+
}
|
|
132
|
+
function shortModelName(model) {
|
|
133
|
+
// Strip provider prefix for display
|
|
134
|
+
const idx = model.indexOf('/');
|
|
135
|
+
return idx > -1 ? model.slice(idx + 1) : model;
|
|
136
|
+
}
|
|
137
|
+
export function formatInsights(report, days) {
|
|
138
|
+
const sep = '─'.repeat(60);
|
|
139
|
+
const lines = [];
|
|
140
|
+
lines.push('');
|
|
141
|
+
lines.push(sep);
|
|
142
|
+
lines.push(` RUNCODE INSIGHTS — last ${days} days`);
|
|
143
|
+
lines.push(sep);
|
|
144
|
+
if (report.windowRecords === 0) {
|
|
145
|
+
lines.push('');
|
|
146
|
+
lines.push(' No activity in this window.');
|
|
147
|
+
lines.push('');
|
|
148
|
+
lines.push(sep);
|
|
149
|
+
lines.push('');
|
|
150
|
+
return lines.join('\n');
|
|
151
|
+
}
|
|
152
|
+
// Summary
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(` Requests: ${report.windowRecords}`);
|
|
155
|
+
lines.push(` Total cost: ${formatUsd(report.totalCostUsd)}`);
|
|
156
|
+
lines.push(` Input tokens: ${formatTokens(report.totalInputTokens)}`);
|
|
157
|
+
lines.push(` Output tokens: ${formatTokens(report.totalOutputTokens)}`);
|
|
158
|
+
lines.push(` Avg/request: ${formatUsd(report.avgRequestCostUsd)} (${formatUsd(report.costPer1KTokens)}/1K tokens)`);
|
|
159
|
+
if (report.savedVsOpusUsd > 0) {
|
|
160
|
+
lines.push(` Saved vs Opus: ${formatUsd(report.savedVsOpusUsd)} by using cheaper models`);
|
|
161
|
+
}
|
|
162
|
+
// Projections
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push(' Projection:');
|
|
165
|
+
lines.push(` Per day: ${formatUsd(report.projections.avgCostPerDay)}`);
|
|
166
|
+
lines.push(` Per month: ${formatUsd(report.projections.projectedMonthlyUsd)}`);
|
|
167
|
+
lines.push(` Per year: ${formatUsd(report.projections.projectedYearlyUsd)}`);
|
|
168
|
+
// Per-model breakdown
|
|
169
|
+
if (report.byModel.length > 0) {
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(' By model:');
|
|
172
|
+
for (const m of report.byModel.slice(0, 10)) {
|
|
173
|
+
const name = shortModelName(m.model).padEnd(30);
|
|
174
|
+
const cost = formatUsd(m.costUsd).padStart(8);
|
|
175
|
+
const pct = `${m.percentOfTotal.toFixed(0)}%`.padStart(4);
|
|
176
|
+
const reqs = `${m.requests}req`.padStart(7);
|
|
177
|
+
lines.push(` ${name} ${cost} ${pct} ${reqs}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// Daily activity sparkline
|
|
181
|
+
if (report.daily.length > 0) {
|
|
182
|
+
const costs = report.daily.map(d => d.costUsd);
|
|
183
|
+
const requests = report.daily.map(d => d.requests);
|
|
184
|
+
lines.push('');
|
|
185
|
+
lines.push(' Daily activity:');
|
|
186
|
+
lines.push(` Requests: ${sparkline(requests)} ${report.daily[0].date} → ${report.daily[report.daily.length - 1].date}`);
|
|
187
|
+
lines.push(` Cost: ${sparkline(costs)}`);
|
|
188
|
+
}
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push(sep);
|
|
191
|
+
lines.push('');
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
|
194
|
+
// Silence unused import warning
|
|
195
|
+
void MODEL_PRICING;
|