agentlytics 0.1.5 → 0.1.7
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/README.md +1 -1
- package/cache.js +440 -9
- package/editors/cursor.js +28 -6
- package/editors/vscode.js +6 -0
- package/package.json +1 -1
- package/server.js +84 -4
- package/ui/package-lock.json +60 -375
- package/ui/package.json +1 -1
- package/ui/src/App.jsx +14 -1
- package/ui/src/lib/api.js +39 -0
- package/ui/src/lib/constants.js +8 -0
- package/ui/src/pages/ChatDetail.jsx +5 -2
- package/ui/src/pages/CostAnalysis.jsx +356 -0
- package/ui/src/pages/Dashboard.jsx +29 -8
- package/ui/src/pages/DeepAnalysis.jsx +11 -4
- package/ui/src/pages/ProjectDetail.jsx +12 -4
- package/ui/src/pages/Projects.jsx +9 -3
- package/ui/src/pages/Sessions.jsx +5 -1
- package/ui/src/pages/Settings.jsx +142 -0
package/README.md
CHANGED
|
@@ -179,7 +179,7 @@ All endpoints accept optional `editor` filter. See **[API.md](API.md)** for full
|
|
|
179
179
|
- [ ] **LLM-powered insights** — Use an LLM to analyze session patterns, generate summaries, detect coding habits, and surface actionable recommendations
|
|
180
180
|
- [ ] **Linux & Windows support** — Adapt editor paths for non-macOS platforms
|
|
181
181
|
- [ ] **Export & reports** — PDF/CSV export of analytics and session data
|
|
182
|
-
- [
|
|
182
|
+
- [x] **Cost tracking** — Estimate API costs per editor/model based on token usage
|
|
183
183
|
|
|
184
184
|
## Contributions Needed
|
|
185
185
|
|
package/cache.js
CHANGED
|
@@ -3,9 +3,11 @@ const path = require('path');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const { getAllChats, getMessages, findChat: findChatRaw, resetCaches } = require('./editors');
|
|
6
|
+
const { calculateCost, getModelPricing, normalizeModelName } = require('./pricing');
|
|
6
7
|
|
|
7
8
|
const CACHE_DIR = path.join(os.homedir(), '.agentlytics');
|
|
8
9
|
const CACHE_DB = path.join(CACHE_DIR, 'cache.db');
|
|
10
|
+
const SCHEMA_VERSION = 4; // bump this when schema changes to auto-revalidate
|
|
9
11
|
|
|
10
12
|
let db = null;
|
|
11
13
|
|
|
@@ -15,6 +17,28 @@ let db = null;
|
|
|
15
17
|
|
|
16
18
|
function initDb() {
|
|
17
19
|
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
20
|
+
|
|
21
|
+
// Check schema version; wipe DB on mismatch
|
|
22
|
+
if (fs.existsSync(CACHE_DB)) {
|
|
23
|
+
try {
|
|
24
|
+
const tmp = new Database(CACHE_DB, { readonly: true });
|
|
25
|
+
const row = tmp.prepare("SELECT value FROM meta WHERE key = 'schema_version'").get();
|
|
26
|
+
tmp.close();
|
|
27
|
+
if (!row || parseInt(row.value) !== SCHEMA_VERSION) {
|
|
28
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
29
|
+
const f = CACHE_DB + suffix;
|
|
30
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Corrupt or unreadable DB — wipe it
|
|
35
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
36
|
+
const f = CACHE_DB + suffix;
|
|
37
|
+
if (fs.existsSync(f)) fs.unlinkSync(f);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
db = new Database(CACHE_DB);
|
|
19
43
|
db.pragma('journal_mode = WAL');
|
|
20
44
|
db.pragma('synchronous = NORMAL');
|
|
@@ -86,6 +110,9 @@ function initDb() {
|
|
|
86
110
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name);
|
|
87
111
|
CREATE INDEX IF NOT EXISTS idx_tool_calls_chat ON tool_calls(chat_id);
|
|
88
112
|
`);
|
|
113
|
+
|
|
114
|
+
// Store schema version so future runs can detect mismatches
|
|
115
|
+
db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION.toString());
|
|
89
116
|
}
|
|
90
117
|
|
|
91
118
|
// ============================================================
|
|
@@ -266,9 +293,23 @@ function scanAll(onProgress, opts = {}) {
|
|
|
266
293
|
// Query helpers (used by server.js)
|
|
267
294
|
// ============================================================
|
|
268
295
|
|
|
296
|
+
// Returns { sql, params } for excluding hidden folders
|
|
297
|
+
function hiddenFolderFilter(opts, colName = 'folder') {
|
|
298
|
+
if (!opts.hiddenFolders || opts.hiddenFolders.length === 0) return { sql: '', params: [] };
|
|
299
|
+
const placeholders = opts.hiddenFolders.map(() => '?').join(',');
|
|
300
|
+
return { sql: ` AND (${colName} IS NULL OR ${colName} NOT IN (${placeholders}))`, params: [...opts.hiddenFolders] };
|
|
301
|
+
}
|
|
302
|
+
|
|
269
303
|
function getCachedChats(opts = {}) {
|
|
270
|
-
let sql =
|
|
304
|
+
let sql = `SELECT c.*,
|
|
305
|
+
cs.models AS _models,
|
|
306
|
+
cs.total_input_tokens AS _inTok, cs.total_output_tokens AS _outTok,
|
|
307
|
+
cs.total_cache_read AS _cacheR, cs.total_cache_write AS _cacheW,
|
|
308
|
+
cs.total_user_chars AS _uChars, cs.total_assistant_chars AS _aChars
|
|
309
|
+
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id WHERE 1=1`;
|
|
271
310
|
const params = [];
|
|
311
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
312
|
+
if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
|
|
272
313
|
if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
273
314
|
if (opts.folder) { sql += ' AND c.folder LIKE ?'; params.push(`%${opts.folder}%`); }
|
|
274
315
|
if (opts.named !== false) { sql += ' AND (c.name IS NOT NULL OR c.bubble_count > 0)'; }
|
|
@@ -288,7 +329,14 @@ function getCachedChats(opts = {}) {
|
|
|
288
329
|
r.top_model = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
289
330
|
}
|
|
290
331
|
} catch {}
|
|
291
|
-
|
|
332
|
+
// Per-session cost estimate
|
|
333
|
+
let inTok = r._inTok || 0, outTok = r._outTok || 0;
|
|
334
|
+
if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
|
|
335
|
+
inTok = Math.round((r._uChars || 0) / 4);
|
|
336
|
+
outTok = Math.round((r._aChars || 0) / 4);
|
|
337
|
+
}
|
|
338
|
+
r.cost = r.top_model ? (calculateCost(r.top_model, inTok, outTok, r._cacheR || 0, r._cacheW || 0) || 0) : 0;
|
|
339
|
+
delete r._models; delete r._inTok; delete r._outTok; delete r._cacheR; delete r._cacheW; delete r._uChars; delete r._aChars;
|
|
292
340
|
}
|
|
293
341
|
return rows;
|
|
294
342
|
}
|
|
@@ -296,6 +344,8 @@ function getCachedChats(opts = {}) {
|
|
|
296
344
|
function countCachedChats(opts = {}) {
|
|
297
345
|
let sql = 'SELECT COUNT(*) as cnt FROM chats WHERE 1=1';
|
|
298
346
|
const params = [];
|
|
347
|
+
const hf = hiddenFolderFilter(opts);
|
|
348
|
+
if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
|
|
299
349
|
if (opts.editor) { sql += ' AND source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
300
350
|
if (opts.folder) { sql += ' AND folder LIKE ?'; params.push(`%${opts.folder}%`); }
|
|
301
351
|
if (opts.named !== false) { sql += ' AND (name IS NOT NULL OR bubble_count > 0)'; }
|
|
@@ -308,6 +358,8 @@ function getCachedOverview(opts = {}) {
|
|
|
308
358
|
// Build conditions dynamically to support editor + date range filters
|
|
309
359
|
const conditions = [];
|
|
310
360
|
const params = [];
|
|
361
|
+
const hf = hiddenFolderFilter(opts);
|
|
362
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
311
363
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
312
364
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
313
365
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -372,6 +424,8 @@ function getCachedOverview(opts = {}) {
|
|
|
372
424
|
function getCachedDailyActivity(opts = {}) {
|
|
373
425
|
const conditions = [];
|
|
374
426
|
const params = [];
|
|
427
|
+
const hf = hiddenFolderFilter(opts);
|
|
428
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
375
429
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
376
430
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
377
431
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -402,6 +456,8 @@ function getCachedDailyActivity(opts = {}) {
|
|
|
402
456
|
function getCachedDeepAnalytics(opts = {}) {
|
|
403
457
|
let sql = 'SELECT cs.* FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id WHERE 1=1';
|
|
404
458
|
const params = [];
|
|
459
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
460
|
+
if (hf.sql) { sql += hf.sql; params.push(...hf.params); }
|
|
405
461
|
if (opts.editor) { sql += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
406
462
|
if (opts.folder) { sql += ' AND c.folder = ?'; params.push(opts.folder); }
|
|
407
463
|
if (opts.dateFrom) { sql += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
|
|
@@ -432,15 +488,23 @@ function getCachedDeepAnalytics(opts = {}) {
|
|
|
432
488
|
} catch {}
|
|
433
489
|
try {
|
|
434
490
|
const models = JSON.parse(r.models);
|
|
435
|
-
for (const m of models) { modelFreq[
|
|
491
|
+
for (const m of models) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; }
|
|
436
492
|
} catch {}
|
|
437
493
|
}
|
|
438
494
|
|
|
495
|
+
// Estimate tokens from chars when no token data available
|
|
496
|
+
let tokensEstimated = false;
|
|
497
|
+
if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
|
|
498
|
+
totalInputTokens = Math.round(totalUserChars / 4);
|
|
499
|
+
totalOutputTokens = Math.round(totalAssistantChars / 4);
|
|
500
|
+
tokensEstimated = true;
|
|
501
|
+
}
|
|
502
|
+
|
|
439
503
|
return {
|
|
440
504
|
analyzedChats: rows.length,
|
|
441
505
|
totalMessages, totalToolCalls,
|
|
442
506
|
totalUserChars, totalAssistantChars,
|
|
443
|
-
totalInputTokens, totalOutputTokens,
|
|
507
|
+
totalInputTokens, totalOutputTokens, tokensEstimated,
|
|
444
508
|
totalCacheRead, totalCacheWrite,
|
|
445
509
|
topTools: Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 30).map(([name, count]) => ({ name, count })),
|
|
446
510
|
topModels: Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([name, count]) => ({ name, count })),
|
|
@@ -495,6 +559,10 @@ function getCachedProjects(opts = {}) {
|
|
|
495
559
|
// Build date filter
|
|
496
560
|
let dateFilter = '';
|
|
497
561
|
const dateParams = [];
|
|
562
|
+
if (!opts.includeHidden) {
|
|
563
|
+
const hf = hiddenFolderFilter(opts);
|
|
564
|
+
if (hf.sql) { dateFilter += hf.sql; dateParams.push(...hf.params); }
|
|
565
|
+
}
|
|
498
566
|
if (opts.dateFrom) { dateFilter += ' AND COALESCE(last_updated_at, created_at) >= ?'; dateParams.push(opts.dateFrom); }
|
|
499
567
|
if (opts.dateTo) { dateFilter += ' AND COALESCE(last_updated_at, created_at) <= ?'; dateParams.push(opts.dateTo); }
|
|
500
568
|
|
|
@@ -543,10 +611,18 @@ function getCachedProjects(opts = {}) {
|
|
|
543
611
|
totalAssistantChars += s.total_assistant_chars;
|
|
544
612
|
totalCacheRead += s.total_cache_read;
|
|
545
613
|
totalCacheWrite += s.total_cache_write;
|
|
546
|
-
try { for (const m of JSON.parse(s.models)) { modelFreq[
|
|
614
|
+
try { for (const m of JSON.parse(s.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
|
|
547
615
|
try { for (const t of JSON.parse(s.tool_calls)) { toolFreq[t] = (toolFreq[t] || 0) + 1; totalToolCalls++; } } catch {}
|
|
548
616
|
}
|
|
549
617
|
|
|
618
|
+
// Estimate tokens from chars when no token data available
|
|
619
|
+
let tokensEstimated = false;
|
|
620
|
+
if (totalInputTokens === 0 && totalOutputTokens === 0 && (totalUserChars > 0 || totalAssistantChars > 0)) {
|
|
621
|
+
totalInputTokens = Math.round(totalUserChars / 4);
|
|
622
|
+
totalOutputTokens = Math.round(totalAssistantChars / 4);
|
|
623
|
+
tokensEstimated = true;
|
|
624
|
+
}
|
|
625
|
+
|
|
550
626
|
result.push({
|
|
551
627
|
folder: proj.folder,
|
|
552
628
|
name: proj.folder.split('/').pop(),
|
|
@@ -556,7 +632,7 @@ function getCachedProjects(opts = {}) {
|
|
|
556
632
|
lastSeen: proj.lastSeen,
|
|
557
633
|
totalMessages,
|
|
558
634
|
totalInputTokens,
|
|
559
|
-
totalOutputTokens,
|
|
635
|
+
totalOutputTokens, tokensEstimated,
|
|
560
636
|
totalUserChars,
|
|
561
637
|
totalAssistantChars,
|
|
562
638
|
totalToolCalls,
|
|
@@ -690,6 +766,8 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
690
766
|
// Build conditions dynamically to support editor + date range filters
|
|
691
767
|
const conditions = [];
|
|
692
768
|
const params = [];
|
|
769
|
+
const hf = hiddenFolderFilter(opts);
|
|
770
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
693
771
|
if (opts.editor) { conditions.push('source = ?'); params.push(opts.editor); }
|
|
694
772
|
if (opts.dateFrom) { conditions.push('COALESCE(last_updated_at, created_at) >= ?'); params.push(opts.dateFrom); }
|
|
695
773
|
if (opts.dateTo) { conditions.push('COALESCE(last_updated_at, created_at) <= ?'); params.push(opts.dateTo); }
|
|
@@ -818,7 +896,7 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
818
896
|
`).all(...params);
|
|
819
897
|
const modelFreq = {};
|
|
820
898
|
for (const r of modelRows) {
|
|
821
|
-
try { for (const m of JSON.parse(r.models)) modelFreq[
|
|
899
|
+
try { for (const m of JSON.parse(r.models)) { const k = normalizeModelName(m) || m; modelFreq[k] = (modelFreq[k] || 0) + 1; } } catch {}
|
|
822
900
|
}
|
|
823
901
|
const topModels = Object.entries(modelFreq).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
824
902
|
|
|
@@ -833,18 +911,29 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
833
911
|
}
|
|
834
912
|
const topTools = Object.entries(toolFreq).sort((a, b) => b[1] - a[1]).slice(0, 8);
|
|
835
913
|
|
|
914
|
+
// If no token data but chars exist, estimate tokens (~4 chars/token)
|
|
915
|
+
let inputTokens = tokenRow.input;
|
|
916
|
+
let outputTokens = tokenRow.output;
|
|
917
|
+
let tokensEstimated = false;
|
|
918
|
+
if (inputTokens === 0 && outputTokens === 0 && (tokenRow.userChars > 0 || tokenRow.assistantChars > 0)) {
|
|
919
|
+
inputTokens = Math.round(tokenRow.userChars / 4);
|
|
920
|
+
outputTokens = Math.round(tokenRow.assistantChars / 4);
|
|
921
|
+
tokensEstimated = true;
|
|
922
|
+
}
|
|
923
|
+
|
|
836
924
|
return {
|
|
837
925
|
hourly,
|
|
838
926
|
weekdays,
|
|
839
927
|
depthBuckets,
|
|
840
928
|
tokens: {
|
|
841
|
-
input:
|
|
842
|
-
output:
|
|
929
|
+
input: inputTokens,
|
|
930
|
+
output: outputTokens,
|
|
843
931
|
cacheRead: tokenRow.cacheRead,
|
|
844
932
|
cacheWrite: tokenRow.cacheWrite,
|
|
845
933
|
userChars: tokenRow.userChars,
|
|
846
934
|
assistantChars: tokenRow.assistantChars,
|
|
847
935
|
sessions: tokenRow.sessions,
|
|
936
|
+
estimated: tokensEstimated,
|
|
848
937
|
},
|
|
849
938
|
streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
|
|
850
939
|
monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
|
|
@@ -855,6 +944,346 @@ function getCachedDashboardStats(opts = {}) {
|
|
|
855
944
|
};
|
|
856
945
|
}
|
|
857
946
|
|
|
947
|
+
// ============================================================
|
|
948
|
+
// Cost estimation
|
|
949
|
+
// ============================================================
|
|
950
|
+
|
|
951
|
+
function estimateCosts(whereClause = '', params = []) {
|
|
952
|
+
// Per-model token usage from messages table
|
|
953
|
+
const modelTokens = db.prepare(`
|
|
954
|
+
SELECT m.model, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
|
|
955
|
+
FROM messages m JOIN chats c ON m.chat_id = c.id
|
|
956
|
+
WHERE m.model IS NOT NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
|
|
957
|
+
GROUP BY m.model
|
|
958
|
+
`).all(...params);
|
|
959
|
+
|
|
960
|
+
// Orphaned tokens: messages with token data but NULL model.
|
|
961
|
+
// Attribute these to the session's dominant model from chat_stats.
|
|
962
|
+
const orphanRows = db.prepare(`
|
|
963
|
+
SELECT m.chat_id, SUM(m.input_tokens) as input, SUM(m.output_tokens) as output
|
|
964
|
+
FROM messages m JOIN chats c ON m.chat_id = c.id
|
|
965
|
+
WHERE m.model IS NULL AND (m.input_tokens > 0 OR m.output_tokens > 0)${whereClause}
|
|
966
|
+
GROUP BY m.chat_id
|
|
967
|
+
`).all(...params);
|
|
968
|
+
|
|
969
|
+
const orphanByModel = {};
|
|
970
|
+
for (const r of orphanRows) {
|
|
971
|
+
const stat = db.prepare('SELECT models FROM chat_stats WHERE chat_id = ?').get(r.chat_id);
|
|
972
|
+
if (!stat) continue;
|
|
973
|
+
let models;
|
|
974
|
+
try { models = JSON.parse(stat.models || '[]'); } catch { continue; }
|
|
975
|
+
if (models.length === 0) continue;
|
|
976
|
+
const freq = {};
|
|
977
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
978
|
+
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
979
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
980
|
+
orphanByModel[dominant].input += r.input || 0;
|
|
981
|
+
orphanByModel[dominant].output += r.output || 0;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
// Cache tokens per session with dominant model
|
|
985
|
+
const cacheRows = db.prepare(`
|
|
986
|
+
SELECT cs.total_cache_read, cs.total_cache_write, cs.models
|
|
987
|
+
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
988
|
+
WHERE (cs.total_cache_read > 0 OR cs.total_cache_write > 0)${whereClause}
|
|
989
|
+
`).all(...params);
|
|
990
|
+
|
|
991
|
+
// Aggregate cache tokens by dominant model
|
|
992
|
+
const cacheByModel = {};
|
|
993
|
+
for (const r of cacheRows) {
|
|
994
|
+
let models;
|
|
995
|
+
try { models = JSON.parse(r.models || '[]'); } catch { continue; }
|
|
996
|
+
if (models.length === 0) continue;
|
|
997
|
+
const freq = {};
|
|
998
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
999
|
+
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1000
|
+
if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
|
|
1001
|
+
cacheByModel[dominant].cacheRead += r.total_cache_read;
|
|
1002
|
+
cacheByModel[dominant].cacheWrite += r.total_cache_write;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Char-based estimation: sessions with models + chars but zero tokens.
|
|
1006
|
+
// Estimate ~4 chars per token (user chars → input, assistant chars → output).
|
|
1007
|
+
const CHARS_PER_TOKEN = 4;
|
|
1008
|
+
const charRows = db.prepare(`
|
|
1009
|
+
SELECT cs.models, cs.total_user_chars as userChars, cs.total_assistant_chars as asstChars
|
|
1010
|
+
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
1011
|
+
WHERE cs.models != '[]' AND cs.total_input_tokens = 0 AND cs.total_output_tokens = 0
|
|
1012
|
+
AND (cs.total_user_chars > 0 OR cs.total_assistant_chars > 0)${whereClause}
|
|
1013
|
+
`).all(...params);
|
|
1014
|
+
|
|
1015
|
+
for (const r of charRows) {
|
|
1016
|
+
let models;
|
|
1017
|
+
try { models = JSON.parse(r.models || '[]'); } catch { continue; }
|
|
1018
|
+
if (models.length === 0) continue;
|
|
1019
|
+
const freq = {};
|
|
1020
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1021
|
+
const dominant = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1022
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1023
|
+
orphanByModel[dominant].input += Math.round((r.userChars || 0) / CHARS_PER_TOKEN);
|
|
1024
|
+
orphanByModel[dominant].output += Math.round((r.asstChars || 0) / CHARS_PER_TOKEN);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// Sessions with token totals but empty models (e.g. Cursor composer chats).
|
|
1028
|
+
// Attribute to the dominant model from same editor source.
|
|
1029
|
+
const unmodeledRows = db.prepare(`
|
|
1030
|
+
SELECT c.source, cs.total_input_tokens as input, cs.total_output_tokens as output,
|
|
1031
|
+
cs.total_cache_read as cacheRead, cs.total_cache_write as cacheWrite
|
|
1032
|
+
FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
1033
|
+
WHERE cs.models = '[]' AND (cs.total_input_tokens > 0 OR cs.total_output_tokens > 0)${whereClause}
|
|
1034
|
+
`).all(...params);
|
|
1035
|
+
|
|
1036
|
+
if (unmodeledRows.length > 0) {
|
|
1037
|
+
// Find dominant model per source from sessions that DO have models
|
|
1038
|
+
const sourceModelFreq = {};
|
|
1039
|
+
const allSessions = db.prepare(`
|
|
1040
|
+
SELECT c.source, cs.models FROM chat_stats cs JOIN chats c ON cs.chat_id = c.id
|
|
1041
|
+
WHERE cs.models != '[]'${whereClause}
|
|
1042
|
+
`).all(...params);
|
|
1043
|
+
for (const s of allSessions) {
|
|
1044
|
+
let models;
|
|
1045
|
+
try { models = JSON.parse(s.models || '[]'); } catch { continue; }
|
|
1046
|
+
if (!sourceModelFreq[s.source]) sourceModelFreq[s.source] = {};
|
|
1047
|
+
for (const m of models) sourceModelFreq[s.source][m] = (sourceModelFreq[s.source][m] || 0) + 1;
|
|
1048
|
+
}
|
|
1049
|
+
// Global fallback: dominant model across all sources
|
|
1050
|
+
const globalFreq = {};
|
|
1051
|
+
for (const sf of Object.values(sourceModelFreq)) {
|
|
1052
|
+
for (const [m, c] of Object.entries(sf)) globalFreq[m] = (globalFreq[m] || 0) + c;
|
|
1053
|
+
}
|
|
1054
|
+
const globalDominant = Object.entries(globalFreq).sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
1055
|
+
|
|
1056
|
+
for (const r of unmodeledRows) {
|
|
1057
|
+
const sf = sourceModelFreq[r.source];
|
|
1058
|
+
const dominant = sf
|
|
1059
|
+
? Object.entries(sf).sort((a, b) => b[1] - a[1])[0]?.[0]
|
|
1060
|
+
: globalDominant;
|
|
1061
|
+
if (!dominant) continue;
|
|
1062
|
+
if (!orphanByModel[dominant]) orphanByModel[dominant] = { input: 0, output: 0 };
|
|
1063
|
+
orphanByModel[dominant].input += r.input || 0;
|
|
1064
|
+
orphanByModel[dominant].output += r.output || 0;
|
|
1065
|
+
// Also merge cache data
|
|
1066
|
+
if (!cacheByModel[dominant]) cacheByModel[dominant] = { cacheRead: 0, cacheWrite: 0 };
|
|
1067
|
+
cacheByModel[dominant].cacheRead += r.cacheRead || 0;
|
|
1068
|
+
cacheByModel[dominant].cacheWrite += r.cacheWrite || 0;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
// Merge modelTokens + orphanByModel into a unified map, normalizing keys
|
|
1073
|
+
const tokenMap = {};
|
|
1074
|
+
const addTokens = (rawModel, input, output) => {
|
|
1075
|
+
const key = normalizeModelName(rawModel) || rawModel;
|
|
1076
|
+
if (!tokenMap[key]) tokenMap[key] = { input: 0, output: 0 };
|
|
1077
|
+
tokenMap[key].input += input || 0;
|
|
1078
|
+
tokenMap[key].output += output || 0;
|
|
1079
|
+
};
|
|
1080
|
+
for (const row of modelTokens) addTokens(row.model, row.input, row.output);
|
|
1081
|
+
for (const [model, tok] of Object.entries(orphanByModel)) addTokens(model, tok.input, tok.output);
|
|
1082
|
+
|
|
1083
|
+
// Normalize cacheByModel keys
|
|
1084
|
+
const normCache = {};
|
|
1085
|
+
for (const [model, cache] of Object.entries(cacheByModel)) {
|
|
1086
|
+
const key = normalizeModelName(model) || model;
|
|
1087
|
+
if (!normCache[key]) normCache[key] = { cacheRead: 0, cacheWrite: 0 };
|
|
1088
|
+
normCache[key].cacheRead += cache.cacheRead;
|
|
1089
|
+
normCache[key].cacheWrite += cache.cacheWrite;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
let totalCost = 0;
|
|
1093
|
+
let knownCost = 0;
|
|
1094
|
+
let unknownModels = [];
|
|
1095
|
+
const byModel = [];
|
|
1096
|
+
|
|
1097
|
+
for (const [model, tok] of Object.entries(tokenMap)) {
|
|
1098
|
+
const cache = normCache[model] || { cacheRead: 0, cacheWrite: 0 };
|
|
1099
|
+
const cost = calculateCost(model, tok.input, tok.output, cache.cacheRead, cache.cacheWrite);
|
|
1100
|
+
if (cost !== null) {
|
|
1101
|
+
knownCost += cost;
|
|
1102
|
+
totalCost += cost;
|
|
1103
|
+
byModel.push({ model, inputTokens: tok.input, outputTokens: tok.output, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
|
|
1104
|
+
} else {
|
|
1105
|
+
unknownModels.push(model);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Handle cache tokens for models that had cache but no message-level tokens
|
|
1110
|
+
for (const [model, cache] of Object.entries(normCache)) {
|
|
1111
|
+
if (!tokenMap[model]) {
|
|
1112
|
+
const cost = calculateCost(model, 0, 0, cache.cacheRead, cache.cacheWrite);
|
|
1113
|
+
if (cost !== null) {
|
|
1114
|
+
totalCost += cost;
|
|
1115
|
+
byModel.push({ model, inputTokens: 0, outputTokens: 0, cacheRead: cache.cacheRead, cacheWrite: cache.cacheWrite, cost });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
byModel.sort((a, b) => b.cost - a.cost);
|
|
1121
|
+
unknownModels = [...new Set(unknownModels)];
|
|
1122
|
+
|
|
1123
|
+
return { totalCost, byModel, unknownModels };
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function getCostBreakdown(opts = {}) {
|
|
1127
|
+
let whereClause = '';
|
|
1128
|
+
const params = [];
|
|
1129
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
1130
|
+
if (hf.sql) { whereClause += hf.sql; params.push(...hf.params); }
|
|
1131
|
+
if (opts.editor) { whereClause += ' AND c.source LIKE ?'; params.push(`%${opts.editor}%`); }
|
|
1132
|
+
if (opts.folder) { whereClause += ' AND c.folder = ?'; params.push(opts.folder); }
|
|
1133
|
+
if (opts.dateFrom) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) >= ?'; params.push(opts.dateFrom); }
|
|
1134
|
+
if (opts.dateTo) { whereClause += ' AND COALESCE(c.last_updated_at, c.created_at) <= ?'; params.push(opts.dateTo); }
|
|
1135
|
+
if (opts.chatId) { whereClause += ' AND c.id = ?'; params.push(opts.chatId); }
|
|
1136
|
+
return estimateCosts(whereClause, params);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function getCostAnalytics(opts = {}) {
|
|
1140
|
+
const conditions = [];
|
|
1141
|
+
const params = [];
|
|
1142
|
+
const hf = hiddenFolderFilter(opts, 'c.folder');
|
|
1143
|
+
if (hf.sql) { conditions.push(hf.sql.replace(' AND ', '')); params.push(...hf.params); }
|
|
1144
|
+
if (opts.editor) { conditions.push('c.source LIKE ?'); params.push(`%${opts.editor}%`); }
|
|
1145
|
+
if (opts.dateFrom) { conditions.push('COALESCE(c.last_updated_at, c.created_at) >= ?'); params.push(opts.dateFrom); }
|
|
1146
|
+
if (opts.dateTo) { conditions.push('COALESCE(c.last_updated_at, c.created_at) <= ?'); params.push(opts.dateTo); }
|
|
1147
|
+
const whereAnd = conditions.length > 0 ? ' AND ' + conditions.join(' AND ') : '';
|
|
1148
|
+
|
|
1149
|
+
// Overall cost breakdown by model
|
|
1150
|
+
const overall = getCostBreakdown(opts);
|
|
1151
|
+
|
|
1152
|
+
// Cost by editor: get costs per source
|
|
1153
|
+
const editorRows = db.prepare(`
|
|
1154
|
+
SELECT DISTINCT c.source FROM chats c WHERE c.source IS NOT NULL${whereAnd}
|
|
1155
|
+
`).all(...params);
|
|
1156
|
+
const byEditor = [];
|
|
1157
|
+
for (const { source } of editorRows) {
|
|
1158
|
+
const editorOpts = { ...opts, editor: source };
|
|
1159
|
+
const ec = getCostBreakdown(editorOpts);
|
|
1160
|
+
if (ec.totalCost > 0) {
|
|
1161
|
+
byEditor.push({ editor: source, cost: ec.totalCost, models: ec.byModel.length });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
byEditor.sort((a, b) => b.cost - a.cost);
|
|
1165
|
+
|
|
1166
|
+
// Cost by project (top 20)
|
|
1167
|
+
const projectRows = db.prepare(`
|
|
1168
|
+
SELECT c.folder, COUNT(*) as sessions FROM chats c
|
|
1169
|
+
WHERE c.folder IS NOT NULL${whereAnd}
|
|
1170
|
+
GROUP BY c.folder ORDER BY sessions DESC LIMIT 30
|
|
1171
|
+
`).all(...params);
|
|
1172
|
+
const byProject = [];
|
|
1173
|
+
for (const { folder } of projectRows) {
|
|
1174
|
+
const pc = getCostBreakdown({ ...opts, folder });
|
|
1175
|
+
if (pc.totalCost > 0) {
|
|
1176
|
+
byProject.push({ folder, name: folder.split('/').pop(), cost: pc.totalCost });
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
byProject.sort((a, b) => b.cost - a.cost);
|
|
1180
|
+
|
|
1181
|
+
// Monthly trend
|
|
1182
|
+
const monthRows = db.prepare(`
|
|
1183
|
+
SELECT
|
|
1184
|
+
substr(date(COALESCE(c.last_updated_at, c.created_at)/1000, 'unixepoch'), 1, 7) as month,
|
|
1185
|
+
c.id, c.source,
|
|
1186
|
+
cs.models AS _models,
|
|
1187
|
+
cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
|
|
1188
|
+
cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
|
|
1189
|
+
cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars
|
|
1190
|
+
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
|
|
1191
|
+
WHERE (c.last_updated_at IS NOT NULL OR c.created_at IS NOT NULL)${whereAnd}
|
|
1192
|
+
ORDER BY month
|
|
1193
|
+
`).all(...params);
|
|
1194
|
+
const monthCosts = {};
|
|
1195
|
+
for (const r of monthRows) {
|
|
1196
|
+
if (!r.month) continue;
|
|
1197
|
+
let topModel = null;
|
|
1198
|
+
try {
|
|
1199
|
+
const models = JSON.parse(r._models || '[]');
|
|
1200
|
+
if (models.length > 0) {
|
|
1201
|
+
const freq = {};
|
|
1202
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1203
|
+
topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1204
|
+
}
|
|
1205
|
+
} catch {}
|
|
1206
|
+
if (!topModel) continue;
|
|
1207
|
+
let inTok = r.inTok || 0, outTok = r.outTok || 0;
|
|
1208
|
+
if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
|
|
1209
|
+
inTok = Math.round((r.uChars || 0) / 4);
|
|
1210
|
+
outTok = Math.round((r.aChars || 0) / 4);
|
|
1211
|
+
}
|
|
1212
|
+
const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
|
|
1213
|
+
if (!monthCosts[r.month]) monthCosts[r.month] = { cost: 0, sessions: 0 };
|
|
1214
|
+
monthCosts[r.month].cost += cost;
|
|
1215
|
+
monthCosts[r.month].sessions++;
|
|
1216
|
+
}
|
|
1217
|
+
const monthly = Object.entries(monthCosts).sort((a, b) => a[0].localeCompare(b[0]))
|
|
1218
|
+
.map(([month, d]) => ({ month, cost: Math.round(d.cost * 100) / 100, sessions: d.sessions }));
|
|
1219
|
+
|
|
1220
|
+
// Top expensive sessions
|
|
1221
|
+
const sessionRows = db.prepare(`
|
|
1222
|
+
SELECT c.id, c.source, c.name, c.folder, c.last_updated_at, c.created_at,
|
|
1223
|
+
cs.models AS _models,
|
|
1224
|
+
cs.total_input_tokens AS inTok, cs.total_output_tokens AS outTok,
|
|
1225
|
+
cs.total_cache_read AS cacheR, cs.total_cache_write AS cacheW,
|
|
1226
|
+
cs.total_user_chars AS uChars, cs.total_assistant_chars AS aChars,
|
|
1227
|
+
cs.total_messages AS msgs
|
|
1228
|
+
FROM chats c LEFT JOIN chat_stats cs ON cs.chat_id = c.id
|
|
1229
|
+
WHERE 1=1${whereAnd}
|
|
1230
|
+
`).all(...params);
|
|
1231
|
+
const sessionCosts = [];
|
|
1232
|
+
for (const r of sessionRows) {
|
|
1233
|
+
let topModel = null;
|
|
1234
|
+
try {
|
|
1235
|
+
const models = JSON.parse(r._models || '[]');
|
|
1236
|
+
if (models.length > 0) {
|
|
1237
|
+
const freq = {};
|
|
1238
|
+
for (const m of models) freq[m] = (freq[m] || 0) + 1;
|
|
1239
|
+
topModel = Object.entries(freq).sort((a, b) => b[1] - a[1])[0][0];
|
|
1240
|
+
}
|
|
1241
|
+
} catch {}
|
|
1242
|
+
if (!topModel) continue;
|
|
1243
|
+
let inTok = r.inTok || 0, outTok = r.outTok || 0;
|
|
1244
|
+
if (inTok === 0 && outTok === 0 && ((r.uChars || 0) > 0 || (r.aChars || 0) > 0)) {
|
|
1245
|
+
inTok = Math.round((r.uChars || 0) / 4);
|
|
1246
|
+
outTok = Math.round((r.aChars || 0) / 4);
|
|
1247
|
+
}
|
|
1248
|
+
const cost = calculateCost(topModel, inTok, outTok, r.cacheR || 0, r.cacheW || 0) || 0;
|
|
1249
|
+
if (cost > 0) {
|
|
1250
|
+
sessionCosts.push({
|
|
1251
|
+
id: r.id, source: r.source, name: r.name, folder: r.folder,
|
|
1252
|
+
model: normalizeModelName(topModel) || topModel,
|
|
1253
|
+
cost, messages: r.msgs || 0,
|
|
1254
|
+
lastUpdatedAt: r.last_updated_at || r.created_at,
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
sessionCosts.sort((a, b) => b.cost - a.cost);
|
|
1259
|
+
|
|
1260
|
+
// Summary stats
|
|
1261
|
+
const totalSessions = sessionCosts.length;
|
|
1262
|
+
const avgPerSession = totalSessions > 0 ? overall.totalCost / totalSessions : 0;
|
|
1263
|
+
const totalDays = monthly.length > 0 ? (() => {
|
|
1264
|
+
const first = new Date(monthly[0].month + '-01');
|
|
1265
|
+
const last = new Date(monthly[monthly.length - 1].month + '-01');
|
|
1266
|
+
return Math.max(1, Math.ceil((last - first) / 86400000) + 30);
|
|
1267
|
+
})() : 1;
|
|
1268
|
+
const avgPerDay = overall.totalCost / totalDays;
|
|
1269
|
+
|
|
1270
|
+
return {
|
|
1271
|
+
totalCost: overall.totalCost,
|
|
1272
|
+
byModel: overall.byModel,
|
|
1273
|
+
unknownModels: overall.unknownModels,
|
|
1274
|
+
byEditor,
|
|
1275
|
+
byProject: byProject.slice(0, 20),
|
|
1276
|
+
monthly,
|
|
1277
|
+
topSessions: sessionCosts.slice(0, 50),
|
|
1278
|
+
summary: {
|
|
1279
|
+
totalSessions,
|
|
1280
|
+
avgPerSession: Math.round(avgPerSession * 100) / 100,
|
|
1281
|
+
avgPerDay: Math.round(avgPerDay * 100) / 100,
|
|
1282
|
+
totalDays,
|
|
1283
|
+
},
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
858
1287
|
function getDb() { return db; }
|
|
859
1288
|
|
|
860
1289
|
module.exports = {
|
|
@@ -871,5 +1300,7 @@ module.exports = {
|
|
|
871
1300
|
resetAndRescan,
|
|
872
1301
|
resetAndRescanAsync,
|
|
873
1302
|
getCachedDashboardStats,
|
|
1303
|
+
getCostBreakdown,
|
|
1304
|
+
getCostAnalytics,
|
|
874
1305
|
getDb,
|
|
875
1306
|
};
|
package/editors/cursor.js
CHANGED
|
@@ -91,11 +91,6 @@ function normalizeStoreMessage(json) {
|
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
if (json.model) msg._model = json.model;
|
|
94
|
-
// Extract provider as model hint if no explicit model
|
|
95
|
-
if (!msg._model && json.providerOptions && typeof json.providerOptions === 'object') {
|
|
96
|
-
const providers = Object.keys(json.providerOptions).filter(k => k !== 'type');
|
|
97
|
-
if (providers.length > 0) msg._model = providers[0];
|
|
98
|
-
}
|
|
99
94
|
return msg;
|
|
100
95
|
}
|
|
101
96
|
|
|
@@ -159,6 +154,15 @@ function getComposerHeaders(stateDbPath) {
|
|
|
159
154
|
} catch { return []; }
|
|
160
155
|
}
|
|
161
156
|
|
|
157
|
+
function getModelPreference(globalDb) {
|
|
158
|
+
try {
|
|
159
|
+
const row = globalDb.prepare("SELECT value FROM ItemTable WHERE key = 'cursor/lastSingleModelPreference'").get();
|
|
160
|
+
if (!row) return null;
|
|
161
|
+
const pref = JSON.parse(row.value);
|
|
162
|
+
return pref.composer || pref.agent || null;
|
|
163
|
+
} catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
162
166
|
function getComposerBubbles(globalDb, composerId) {
|
|
163
167
|
const prefix = `bubbleId:${composerId}:`;
|
|
164
168
|
const rows = globalDb.prepare(
|
|
@@ -260,9 +264,11 @@ function getChats() {
|
|
|
260
264
|
composerId: chatId,
|
|
261
265
|
name: meta.name || null,
|
|
262
266
|
createdAt: meta.createdAt || null,
|
|
267
|
+
mode: meta.mode || null,
|
|
263
268
|
folder: null,
|
|
264
269
|
_dbPath: dbPath,
|
|
265
270
|
_rootBlobId: meta.latestRootBlobId,
|
|
271
|
+
_lastUsedModel: meta.lastUsedModel || null,
|
|
266
272
|
_type: 'agent-store',
|
|
267
273
|
});
|
|
268
274
|
}
|
|
@@ -273,6 +279,8 @@ function getChats() {
|
|
|
273
279
|
let globalDb = null;
|
|
274
280
|
try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { /* no global db */ }
|
|
275
281
|
|
|
282
|
+
const modelPref = globalDb ? getModelPreference(globalDb) : null;
|
|
283
|
+
|
|
276
284
|
for (const { hash, folder, stateDb } of getWorkspaceMap()) {
|
|
277
285
|
const headers = getComposerHeaders(stateDb);
|
|
278
286
|
for (const h of headers) {
|
|
@@ -295,6 +303,7 @@ function getChats() {
|
|
|
295
303
|
folder,
|
|
296
304
|
bubbleCount,
|
|
297
305
|
_type: 'workspace',
|
|
306
|
+
_modelPref: modelPref,
|
|
298
307
|
});
|
|
299
308
|
}
|
|
300
309
|
}
|
|
@@ -308,6 +317,12 @@ function getMessages(chat) {
|
|
|
308
317
|
const db = new Database(chat._dbPath, { readonly: true });
|
|
309
318
|
const msgs = collectStoreMessages(db, chat._rootBlobId);
|
|
310
319
|
db.close();
|
|
320
|
+
// Use lastUsedModel as fallback for assistant messages without model info
|
|
321
|
+
if (chat._lastUsedModel) {
|
|
322
|
+
for (const m of msgs) {
|
|
323
|
+
if (m.role === 'assistant' && !m._model) m._model = chat._lastUsedModel;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
311
326
|
return msgs;
|
|
312
327
|
}
|
|
313
328
|
|
|
@@ -315,7 +330,14 @@ function getMessages(chat) {
|
|
|
315
330
|
try { globalDb = new Database(GLOBAL_STORAGE_DB, { readonly: true }); } catch { return []; }
|
|
316
331
|
const bubbles = getComposerBubbles(globalDb, chat.composerId);
|
|
317
332
|
globalDb.close();
|
|
318
|
-
|
|
333
|
+
const msgs = bubblesToMessages(bubbles);
|
|
334
|
+
// Use model preference as fallback for messages without model info
|
|
335
|
+
if (chat._modelPref) {
|
|
336
|
+
for (const m of msgs) {
|
|
337
|
+
if (m.role === 'assistant' && !m._model) m._model = chat._modelPref;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return msgs;
|
|
319
341
|
}
|
|
320
342
|
|
|
321
343
|
module.exports = { name, getChats, getMessages };
|