@chrysb/alphaclaw 0.4.2 → 0.4.4

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.
@@ -26,40 +26,78 @@ const SummaryCard = ({ title, tokens, cost }) => html`
26
26
  `;
27
27
 
28
28
  const AgentCostDistribution = ({ summary }) => {
29
- const agents = Array.isArray(summary?.costByAgent?.agents) ? summary.costByAgent.agents : [];
30
- const [selectedAgent, setSelectedAgent] = useState(() => String(agents[0]?.agent || ""));
29
+ const agents = Array.isArray(summary?.costByAgent?.agents)
30
+ ? summary.costByAgent.agents
31
+ : [];
32
+ const missingPricingModels = Array.from(
33
+ new Set(
34
+ (summary?.daily || [])
35
+ .flatMap((dayRow) => dayRow?.models || [])
36
+ .filter(
37
+ (modelRow) =>
38
+ !modelRow?.pricingFound && Number(modelRow?.totalTokens || 0) > 0,
39
+ )
40
+ .map(
41
+ (modelRow) =>
42
+ String(modelRow?.model || "unknown").trim() || "unknown",
43
+ ),
44
+ ),
45
+ ).sort((leftValue, rightValue) => leftValue.localeCompare(rightValue));
46
+ const missingPricingPreview = missingPricingModels.slice(0, 3).join(", ");
47
+ const hasMoreMissingPricingModels = missingPricingModels.length > 3;
48
+ const missingPricingLabel = missingPricingModels.length
49
+ ? hasMoreMissingPricingModels
50
+ ? `${missingPricingPreview}, +${missingPricingModels.length - 3} more`
51
+ : missingPricingPreview
52
+ : "";
53
+ const [selectedAgent, setSelectedAgent] = useState(() =>
54
+ String(agents[0]?.agent || ""),
55
+ );
31
56
  useEffect(() => {
32
57
  if (agents.length === 0) {
33
58
  if (selectedAgent) setSelectedAgent("");
34
59
  return;
35
60
  }
36
- const hasSelectedAgent = agents.some((row) => String(row.agent || "") === selectedAgent);
61
+ const hasSelectedAgent = agents.some(
62
+ (row) => String(row.agent || "") === selectedAgent,
63
+ );
37
64
  if (!hasSelectedAgent) setSelectedAgent(String(agents[0]?.agent || ""));
38
65
  }, [agents, selectedAgent]);
39
66
  const selectedAgentRow =
40
- agents.find((row) => String(row.agent || "") === selectedAgent) || agents[0] || null;
67
+ agents.find((row) => String(row.agent || "") === selectedAgent) ||
68
+ agents[0] ||
69
+ null;
41
70
 
42
71
  return html`
43
72
  <div class="bg-surface border border-border rounded-xl p-4">
44
73
  ${agents.length === 0
45
74
  ? html`
46
- <div class="flex flex-wrap items-start sm:items-center justify-between gap-3 mb-3">
47
- <h2 class="card-label text-xs">Cost breakdown</h2>
75
+ <div
76
+ class="flex flex-wrap items-start sm:items-center justify-between gap-3 mb-3"
77
+ >
78
+ <h2 class="card-label text-xs">Estimated cost breakdown</h2>
48
79
  </div>
49
- <p class="text-xs text-gray-500">No agent usage recorded for this range.</p>
80
+ <p class="text-xs text-gray-500">
81
+ No agent usage recorded for this range.
82
+ </p>
50
83
  `
51
84
  : html`
52
85
  <div class="space-y-3">
53
- <div class="flex flex-wrap items-start sm:items-center justify-between gap-3">
54
- <h2 class="card-label text-xs">Cost breakdown</h2>
55
- <div class="inline-flex flex-wrap items-center gap-3 text-xs text-gray-500">
56
- <span class="text-gray-300">${formatUsd(selectedAgentRow?.totalCost)}</span>
57
- <span>${formatInteger(selectedAgentRow?.totalTokens)} tok</span>
58
- <label class="inline-flex items-center gap-2 text-xs text-gray-500">
86
+ <div
87
+ class="flex flex-wrap items-start sm:items-center justify-between gap-3"
88
+ >
89
+ <h2 class="card-label text-xs">Estimated cost breakdown</h2>
90
+ <div
91
+ class="inline-flex flex-wrap items-center gap-3 text-xs text-gray-500"
92
+ >
93
+ <label
94
+ class="inline-flex items-center gap-2 text-xs text-gray-500"
95
+ >
59
96
  <select
60
97
  class="bg-black/30 border border-border rounded-lg text-xs px-2.5 py-1.5 text-gray-200 focus:border-gray-500"
61
98
  value=${String(selectedAgentRow?.agent || "")}
62
- onChange=${(e) => setSelectedAgent(String(e.currentTarget?.value || ""))}
99
+ onChange=${(e) =>
100
+ setSelectedAgent(String(e.currentTarget?.value || ""))}
63
101
  >
64
102
  ${agents.map(
65
103
  (agentRow) => html`
@@ -74,17 +112,29 @@ const AgentCostDistribution = ({ summary }) => {
74
112
  </div>
75
113
  <div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
76
114
  ${kUsageSourceOrder.map((sourceName) => {
77
- const sourceRow = (selectedAgentRow?.sourceBreakdown || []).find(
78
- (row) => String(row.source || "") === sourceName,
79
- ) || { source: sourceName, totalCost: 0, totalTokens: 0, turnCount: 0 };
115
+ const sourceRow = (
116
+ selectedAgentRow?.sourceBreakdown || []
117
+ ).find((row) => String(row.source || "") === sourceName) || {
118
+ source: sourceName,
119
+ totalCost: 0,
120
+ totalTokens: 0,
121
+ turnCount: 0,
122
+ };
80
123
  return html`
81
124
  <div class="ac-surface-inset px-2.5 py-2">
82
- <p class="text-[11px] text-gray-500">${renderSourceLabel(sourceRow.source)}</p>
83
- <p class="text-xs text-gray-300 mt-0.5">${formatUsd(sourceRow.totalCost)}</p>
125
+ <p class="text-[11px] text-gray-500">
126
+ ${renderSourceLabel(sourceRow.source)}
127
+ </p>
128
+ <p class="text-xs text-gray-300 mt-0.5">
129
+ ${formatUsd(sourceRow.totalCost)}
130
+ </p>
84
131
  <p class="text-[11px] text-gray-500 mt-0.5">
85
- ${formatInteger(sourceRow.totalTokens)} tok
86
- ·
87
- ${formatCountLabel(sourceRow.turnCount, "turn", "turns")}
132
+ ${formatInteger(sourceRow.totalTokens)} tok ·
133
+ ${formatCountLabel(
134
+ sourceRow.turnCount,
135
+ "turn",
136
+ "turns",
137
+ )}
88
138
  </p>
89
139
  </div>
90
140
  `;
@@ -92,6 +142,19 @@ const AgentCostDistribution = ({ summary }) => {
92
142
  </div>
93
143
  </div>
94
144
  `}
145
+ ${missingPricingModels.length
146
+ ? html`
147
+ <div class="mt-3">
148
+ <p class="text-[11px] text-gray-500">
149
+ <span>
150
+ . Missing model pricing for ${missingPricingModels.length}
151
+ ${missingPricingModels.length === 1 ? "model" : "models"}:
152
+ ${missingPricingLabel}.
153
+ </span>
154
+ </p>
155
+ </div>
156
+ `
157
+ : null}
95
158
  </div>
96
159
  `;
97
160
  };
@@ -107,7 +170,11 @@ export const OverviewSection = ({
107
170
  }) => html`
108
171
  <div class="space-y-4">
109
172
  <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
110
- <${SummaryCard} title="Today" tokens=${periodSummary.today.tokens} cost=${periodSummary.today.cost} />
173
+ <${SummaryCard}
174
+ title="Today"
175
+ tokens=${periodSummary.today.tokens}
176
+ cost=${periodSummary.today.cost}
177
+ />
111
178
  <${SummaryCard}
112
179
  title="Last 7 days"
113
180
  tokens=${periodSummary.week.tokens}
@@ -120,11 +187,18 @@ export const OverviewSection = ({
120
187
  />
121
188
  </div>
122
189
  <div class="bg-surface border border-border rounded-xl p-4">
123
- <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
124
- <h2 class="card-label text-xs">Daily ${metric === "tokens" ? "tokens" : "cost"} by model</h2>
190
+ <div
191
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3"
192
+ >
193
+ <h2 class="card-label text-xs">
194
+ Daily ${metric === "tokens" ? "tokens" : "cost"} by model
195
+ </h2>
125
196
  <div class="flex items-center gap-2">
126
197
  <${SegmentedControl}
127
- options=${kRangeOptions.map((option) => ({ label: option.label, value: option.value }))}
198
+ options=${kRangeOptions.map((option) => ({
199
+ label: option.label,
200
+ value: option.value,
201
+ }))}
128
202
  value=${days}
129
203
  onChange=${onDaysChange}
130
204
  />
@@ -576,6 +576,37 @@ export const deleteBrowseFile = async (filePath) => {
576
576
  return parseJsonOrThrow(res, 'Could not delete file');
577
577
  };
578
578
 
579
+ export const downloadBrowseFile = async (filePath) => {
580
+ const params = new URLSearchParams({ path: String(filePath || "") });
581
+ const res = await authFetch(`/api/browse/download?${params.toString()}`);
582
+ if (!res.ok) {
583
+ const errorText = await res.text();
584
+ throw new Error(errorText || "Could not download file");
585
+ }
586
+ const fileBlob = await res.blob();
587
+ const urlApi = window?.URL || URL;
588
+ if (!urlApi?.createObjectURL || !urlApi?.revokeObjectURL) {
589
+ throw new Error("Download is not supported in this browser");
590
+ }
591
+ const downloadUrl = urlApi.createObjectURL(fileBlob);
592
+ const fileName =
593
+ String(filePath || "")
594
+ .split("/")
595
+ .filter(Boolean)
596
+ .pop() || "download";
597
+ try {
598
+ const downloadLink = document.createElement("a");
599
+ downloadLink.href = downloadUrl;
600
+ downloadLink.download = fileName;
601
+ document.body?.appendChild(downloadLink);
602
+ downloadLink.click();
603
+ downloadLink.remove();
604
+ } finally {
605
+ urlApi.revokeObjectURL(downloadUrl);
606
+ }
607
+ return { ok: true };
608
+ };
609
+
579
610
  export const restoreBrowseFile = async (filePath) => {
580
611
  const res = await authFetch('/api/browse/restore', {
581
612
  method: 'POST',
@@ -0,0 +1,35 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { DatabaseSync } = require("node:sqlite");
4
+ const { ensureSchema } = require("./schema");
5
+ const { getDailySummary } = require("./summary");
6
+ const { getSessionsList, getSessionDetail } = require("./sessions");
7
+ const { getSessionTimeSeries } = require("./timeseries");
8
+ const { kGlobalModelPricing } = require("./pricing");
9
+
10
+ let db = null;
11
+ let usageDbPath = "";
12
+
13
+ const ensureDb = () => {
14
+ if (!db) throw new Error("Usage DB not initialized");
15
+ return db;
16
+ };
17
+
18
+ const initUsageDb = ({ rootDir }) => {
19
+ const dbDir = path.join(rootDir, "db");
20
+ fs.mkdirSync(dbDir, { recursive: true });
21
+ usageDbPath = path.join(dbDir, "usage.db");
22
+ db = new DatabaseSync(usageDbPath);
23
+ ensureSchema(db);
24
+ return { path: usageDbPath };
25
+ };
26
+
27
+ module.exports = {
28
+ initUsageDb,
29
+ getDailySummary: (options = {}) => getDailySummary({ database: ensureDb(), ...options }),
30
+ getSessionsList: (options = {}) => getSessionsList({ database: ensureDb(), ...options }),
31
+ getSessionDetail: (options = {}) => getSessionDetail({ database: ensureDb(), ...options }),
32
+ getSessionTimeSeries: (options = {}) =>
33
+ getSessionTimeSeries({ database: ensureDb(), ...options }),
34
+ kGlobalModelPricing,
35
+ };
@@ -0,0 +1,82 @@
1
+ const kTokensPerMillion = 1_000_000;
2
+ const kLongContextThresholdTokens = 200_000;
3
+ const kGlobalModelPricing = {
4
+ "claude-opus-4-6": {
5
+ input: (tokens) => (tokens > kLongContextThresholdTokens ? 10.0 : 5.0),
6
+ output: (tokens) => (tokens > kLongContextThresholdTokens ? 37.5 : 25.0),
7
+ },
8
+ "claude-sonnet-4-6": { input: 3.0, output: 15.0 },
9
+ "claude-haiku-4-6": { input: 0.8, output: 4.0 },
10
+ "gpt-5": { input: 1.25, output: 10.0 },
11
+ "gpt-5.1-codex": { input: 2.5, output: 10.0 },
12
+ "gpt-5.3-codex": { input: 2.5, output: 10.0 },
13
+ "gpt-4.1": { input: 2.0, output: 8.0 },
14
+ "gpt-4o": { input: 2.5, output: 10.0 },
15
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
16
+ "gemini-3-pro-preview": { input: 2.0, output: 12.0 },
17
+ "gemini-3-flash-preview": { input: 0.5, output: 3.0 },
18
+ "gemini-2.0-flash": { input: 0.1, output: 0.4 },
19
+ };
20
+
21
+ const toInt = (value, fallbackValue = 0) => {
22
+ const parsed = Number.parseInt(String(value ?? ""), 10);
23
+ return Number.isFinite(parsed) ? parsed : fallbackValue;
24
+ };
25
+
26
+ const resolvePricing = (model) => {
27
+ const normalized = String(model || "").toLowerCase();
28
+ if (!normalized) return null;
29
+ const exact = kGlobalModelPricing[normalized];
30
+ if (exact) return exact;
31
+ const matchKey = Object.keys(kGlobalModelPricing).find((key) =>
32
+ normalized.includes(key),
33
+ );
34
+ return matchKey ? kGlobalModelPricing[matchKey] : null;
35
+ };
36
+
37
+ const resolvePerMillionRate = (rate, tokens) => {
38
+ if (typeof rate === "function") {
39
+ return Number(rate(toInt(tokens)));
40
+ }
41
+ return Number(rate || 0);
42
+ };
43
+
44
+ const deriveCostBreakdown = ({
45
+ inputTokens = 0,
46
+ outputTokens = 0,
47
+ cacheReadTokens = 0,
48
+ cacheWriteTokens = 0,
49
+ model = "",
50
+ }) => {
51
+ const pricing = resolvePricing(model);
52
+ if (!pricing) {
53
+ return {
54
+ inputCost: 0,
55
+ outputCost: 0,
56
+ cacheReadCost: 0,
57
+ cacheWriteCost: 0,
58
+ totalCost: 0,
59
+ pricingFound: false,
60
+ };
61
+ }
62
+ const inputRate = resolvePerMillionRate(pricing.input, inputTokens);
63
+ const outputRate = resolvePerMillionRate(pricing.output, outputTokens);
64
+ const inputCost = (inputTokens / kTokensPerMillion) * inputRate;
65
+ const outputCost = (outputTokens / kTokensPerMillion) * outputRate;
66
+ const cacheReadCost = 0;
67
+ const cacheWriteRate = resolvePerMillionRate(pricing.input, cacheWriteTokens);
68
+ const cacheWriteCost = (cacheWriteTokens / kTokensPerMillion) * cacheWriteRate;
69
+ return {
70
+ inputCost,
71
+ outputCost,
72
+ cacheReadCost,
73
+ cacheWriteCost,
74
+ totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost,
75
+ pricingFound: true,
76
+ };
77
+ };
78
+
79
+ module.exports = {
80
+ kGlobalModelPricing,
81
+ deriveCostBreakdown,
82
+ };
@@ -0,0 +1,87 @@
1
+ const safeAlterTable = (database, sql) => {
2
+ try {
3
+ database.exec(sql);
4
+ } catch (err) {
5
+ const message = String(err?.message || "").toLowerCase();
6
+ if (!message.includes("duplicate column name")) throw err;
7
+ }
8
+ };
9
+
10
+ const ensureSchema = (database) => {
11
+ database.exec("PRAGMA journal_mode=WAL;");
12
+ database.exec("PRAGMA synchronous=NORMAL;");
13
+ database.exec("PRAGMA busy_timeout=5000;");
14
+ database.exec(`
15
+ CREATE TABLE IF NOT EXISTS usage_events (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ timestamp INTEGER NOT NULL,
18
+ session_id TEXT,
19
+ session_key TEXT,
20
+ run_id TEXT,
21
+ provider TEXT NOT NULL,
22
+ model TEXT NOT NULL,
23
+ input_tokens INTEGER NOT NULL DEFAULT 0,
24
+ output_tokens INTEGER NOT NULL DEFAULT 0,
25
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
26
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
27
+ total_tokens INTEGER NOT NULL DEFAULT 0
28
+ );
29
+ `);
30
+ database.exec(`
31
+ CREATE INDEX IF NOT EXISTS idx_usage_events_ts
32
+ ON usage_events(timestamp DESC);
33
+ `);
34
+ database.exec(`
35
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session
36
+ ON usage_events(session_id);
37
+ `);
38
+ safeAlterTable(
39
+ database,
40
+ "ALTER TABLE usage_events ADD COLUMN session_key TEXT;",
41
+ );
42
+ database.exec(`
43
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session_key
44
+ ON usage_events(session_key);
45
+ `);
46
+ database.exec(`
47
+ CREATE TABLE IF NOT EXISTS usage_daily (
48
+ date TEXT NOT NULL,
49
+ model TEXT NOT NULL,
50
+ provider TEXT,
51
+ input_tokens INTEGER NOT NULL DEFAULT 0,
52
+ output_tokens INTEGER NOT NULL DEFAULT 0,
53
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
54
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
55
+ total_tokens INTEGER NOT NULL DEFAULT 0,
56
+ turn_count INTEGER NOT NULL DEFAULT 0,
57
+ PRIMARY KEY (date, model)
58
+ );
59
+ `);
60
+ database.exec(`
61
+ CREATE TABLE IF NOT EXISTS tool_events (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ timestamp INTEGER NOT NULL,
64
+ session_id TEXT,
65
+ session_key TEXT,
66
+ tool_name TEXT NOT NULL,
67
+ success INTEGER NOT NULL DEFAULT 1,
68
+ duration_ms INTEGER
69
+ );
70
+ `);
71
+ database.exec(`
72
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session
73
+ ON tool_events(session_id);
74
+ `);
75
+ safeAlterTable(
76
+ database,
77
+ "ALTER TABLE tool_events ADD COLUMN session_key TEXT;",
78
+ );
79
+ database.exec(`
80
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session_key
81
+ ON tool_events(session_key);
82
+ `);
83
+ };
84
+
85
+ module.exports = {
86
+ ensureSchema,
87
+ };
@@ -0,0 +1,217 @@
1
+ const {
2
+ kDefaultSessionLimit,
3
+ kMaxSessionLimit,
4
+ coerceInt,
5
+ clampInt,
6
+ getUsageMetricsFromEventRow,
7
+ } = require("./shared");
8
+
9
+ const getSessionsList = ({ database, limit = kDefaultSessionLimit } = {}) => {
10
+ const safeLimit = clampInt(limit, 1, kMaxSessionLimit, kDefaultSessionLimit);
11
+ const rows = database
12
+ .prepare(`
13
+ SELECT
14
+ COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) AS session_ref,
15
+ MAX(session_key) AS session_key,
16
+ MAX(session_id) AS session_id,
17
+ MIN(timestamp) AS first_activity_ms,
18
+ MAX(timestamp) AS last_activity_ms,
19
+ COUNT(*) AS turn_count,
20
+ SUM(input_tokens) AS input_tokens,
21
+ SUM(output_tokens) AS output_tokens,
22
+ SUM(cache_read_tokens) AS cache_read_tokens,
23
+ SUM(cache_write_tokens) AS cache_write_tokens,
24
+ SUM(total_tokens) AS total_tokens
25
+ FROM usage_events
26
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) IS NOT NULL
27
+ GROUP BY session_ref
28
+ ORDER BY last_activity_ms DESC
29
+ LIMIT $limit
30
+ `)
31
+ .all({ $limit: safeLimit });
32
+ return rows.map((row) => {
33
+ const eventRows = database
34
+ .prepare(`
35
+ SELECT
36
+ model,
37
+ input_tokens,
38
+ output_tokens,
39
+ cache_read_tokens,
40
+ cache_write_tokens,
41
+ total_tokens
42
+ FROM usage_events
43
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
44
+ `)
45
+ .all({ $sessionRef: row.session_ref });
46
+ let totalCost = 0;
47
+ const modelTokenTotals = new Map();
48
+ for (const eventRow of eventRows) {
49
+ const metrics = getUsageMetricsFromEventRow(eventRow);
50
+ totalCost += metrics.totalCost;
51
+ const model = String(eventRow.model || "");
52
+ modelTokenTotals.set(model, (modelTokenTotals.get(model) || 0) + metrics.totalTokens);
53
+ }
54
+ const dominantModel = Array.from(modelTokenTotals.entries())
55
+ .sort((a, b) => b[1] - a[1])[0]?.[0] || "";
56
+ return {
57
+ sessionId: row.session_ref,
58
+ sessionKey: String(row.session_key || ""),
59
+ rawSessionId: String(row.session_id || ""),
60
+ firstActivityMs: coerceInt(row.first_activity_ms),
61
+ lastActivityMs: coerceInt(row.last_activity_ms),
62
+ durationMs: Math.max(
63
+ 0,
64
+ coerceInt(row.last_activity_ms) - coerceInt(row.first_activity_ms),
65
+ ),
66
+ turnCount: coerceInt(row.turn_count),
67
+ inputTokens: coerceInt(row.input_tokens),
68
+ outputTokens: coerceInt(row.output_tokens),
69
+ cacheReadTokens: coerceInt(row.cache_read_tokens),
70
+ cacheWriteTokens: coerceInt(row.cache_write_tokens),
71
+ totalTokens: coerceInt(row.total_tokens),
72
+ totalCost,
73
+ dominantModel,
74
+ };
75
+ });
76
+ };
77
+
78
+ const getSessionDetail = ({ database, sessionId }) => {
79
+ const safeSessionRef = String(sessionId || "").trim();
80
+ if (!safeSessionRef) return null;
81
+ const summaryRow = database
82
+ .prepare(`
83
+ SELECT
84
+ MAX(session_key) AS session_key,
85
+ MAX(session_id) AS session_id,
86
+ MIN(timestamp) AS first_activity_ms,
87
+ MAX(timestamp) AS last_activity_ms,
88
+ COUNT(*) AS turn_count,
89
+ SUM(input_tokens) AS input_tokens,
90
+ SUM(output_tokens) AS output_tokens,
91
+ SUM(cache_read_tokens) AS cache_read_tokens,
92
+ SUM(cache_write_tokens) AS cache_write_tokens,
93
+ SUM(total_tokens) AS total_tokens
94
+ FROM usage_events
95
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
96
+ `)
97
+ .get({ $sessionRef: safeSessionRef });
98
+ if (!summaryRow || !coerceInt(summaryRow.turn_count)) return null;
99
+
100
+ const modelEvents = database
101
+ .prepare(`
102
+ SELECT
103
+ provider,
104
+ model,
105
+ input_tokens,
106
+ output_tokens,
107
+ cache_read_tokens,
108
+ cache_write_tokens,
109
+ total_tokens
110
+ FROM usage_events
111
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
112
+ `)
113
+ .all({ $sessionRef: safeSessionRef });
114
+ const byProviderModel = new Map();
115
+ for (const eventRow of modelEvents) {
116
+ const provider = String(eventRow.provider || "unknown");
117
+ const model = String(eventRow.model || "unknown");
118
+ const mapKey = `${provider}\u0000${model}`;
119
+ if (!byProviderModel.has(mapKey)) {
120
+ byProviderModel.set(mapKey, {
121
+ provider,
122
+ model,
123
+ turnCount: 0,
124
+ inputTokens: 0,
125
+ outputTokens: 0,
126
+ cacheReadTokens: 0,
127
+ cacheWriteTokens: 0,
128
+ totalTokens: 0,
129
+ totalCost: 0,
130
+ inputCost: 0,
131
+ outputCost: 0,
132
+ cacheReadCost: 0,
133
+ cacheWriteCost: 0,
134
+ pricingFound: false,
135
+ });
136
+ }
137
+ const aggregate = byProviderModel.get(mapKey);
138
+ const metrics = getUsageMetricsFromEventRow(eventRow);
139
+ aggregate.turnCount += 1;
140
+ aggregate.inputTokens += metrics.inputTokens;
141
+ aggregate.outputTokens += metrics.outputTokens;
142
+ aggregate.cacheReadTokens += metrics.cacheReadTokens;
143
+ aggregate.cacheWriteTokens += metrics.cacheWriteTokens;
144
+ aggregate.totalTokens += metrics.totalTokens;
145
+ aggregate.totalCost += metrics.totalCost;
146
+ aggregate.inputCost += metrics.inputCost;
147
+ aggregate.outputCost += metrics.outputCost;
148
+ aggregate.cacheReadCost += metrics.cacheReadCost;
149
+ aggregate.cacheWriteCost += metrics.cacheWriteCost;
150
+ aggregate.pricingFound = aggregate.pricingFound || metrics.pricingFound;
151
+ }
152
+ const modelRows = Array.from(byProviderModel.values()).sort(
153
+ (a, b) => b.totalTokens - a.totalTokens,
154
+ );
155
+
156
+ const toolRows = database
157
+ .prepare(`
158
+ SELECT
159
+ tool_name,
160
+ COUNT(*) AS call_count,
161
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count,
162
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS error_count,
163
+ AVG(duration_ms) AS avg_duration_ms,
164
+ MIN(duration_ms) AS min_duration_ms,
165
+ MAX(duration_ms) AS max_duration_ms
166
+ FROM tool_events
167
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
168
+ GROUP BY tool_name
169
+ ORDER BY call_count DESC
170
+ `)
171
+ .all({ $sessionRef: safeSessionRef })
172
+ .map((row) => {
173
+ const callCount = coerceInt(row.call_count);
174
+ const successCount = coerceInt(row.success_count);
175
+ const errorCount = coerceInt(row.error_count);
176
+ return {
177
+ toolName: row.tool_name,
178
+ callCount,
179
+ successCount,
180
+ errorCount,
181
+ errorRate: callCount > 0 ? errorCount / callCount : 0,
182
+ avgDurationMs: Number(row.avg_duration_ms || 0),
183
+ minDurationMs: coerceInt(row.min_duration_ms),
184
+ maxDurationMs: coerceInt(row.max_duration_ms),
185
+ };
186
+ });
187
+
188
+ const firstActivityMs = coerceInt(summaryRow.first_activity_ms);
189
+ const lastActivityMs = coerceInt(summaryRow.last_activity_ms);
190
+ const totalCost = modelRows.reduce(
191
+ (sum, modelRow) => sum + Number(modelRow.totalCost || 0),
192
+ 0,
193
+ );
194
+
195
+ return {
196
+ sessionId: safeSessionRef,
197
+ sessionKey: String(summaryRow.session_key || ""),
198
+ rawSessionId: String(summaryRow.session_id || ""),
199
+ firstActivityMs,
200
+ lastActivityMs,
201
+ durationMs: Math.max(0, lastActivityMs - firstActivityMs),
202
+ turnCount: coerceInt(summaryRow.turn_count),
203
+ inputTokens: coerceInt(summaryRow.input_tokens),
204
+ outputTokens: coerceInt(summaryRow.output_tokens),
205
+ cacheReadTokens: coerceInt(summaryRow.cache_read_tokens),
206
+ cacheWriteTokens: coerceInt(summaryRow.cache_write_tokens),
207
+ totalTokens: coerceInt(summaryRow.total_tokens),
208
+ totalCost,
209
+ modelBreakdown: modelRows,
210
+ toolUsage: toolRows,
211
+ };
212
+ };
213
+
214
+ module.exports = {
215
+ getSessionsList,
216
+ getSessionDetail,
217
+ };