opencode-top 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.
@@ -0,0 +1,156 @@
1
+ import Decimal from "decimal.js";
2
+
3
+ export class TokenUsage {
4
+ constructor(
5
+ readonly input: number = 0,
6
+ readonly output: number = 0,
7
+ readonly cacheRead: number = 0,
8
+ readonly cacheWrite: number = 0,
9
+ readonly reasoning: number = 0
10
+ ) {}
11
+
12
+ get total(): number {
13
+ return this.input + this.output + this.cacheRead + this.cacheWrite;
14
+ }
15
+
16
+ add(other: TokenUsage): TokenUsage {
17
+ return new TokenUsage(
18
+ this.input + other.input,
19
+ this.output + other.output,
20
+ this.cacheRead + other.cacheRead,
21
+ this.cacheWrite + other.cacheWrite,
22
+ this.reasoning + other.reasoning
23
+ );
24
+ }
25
+
26
+ calculateCost(pricing: ModelPricing): Decimal {
27
+ const inputCost = new Decimal(this.input).mul(pricing.input).div(1_000_000);
28
+ const outputCost = new Decimal(this.output).mul(pricing.output).div(1_000_000);
29
+ const cacheReadCost = new Decimal(this.cacheRead).mul(pricing.cacheRead).div(1_000_000);
30
+ const cacheWriteCost = new Decimal(this.cacheWrite).mul(pricing.cacheWrite).div(1_000_000);
31
+ return inputCost.plus(outputCost).plus(cacheReadCost).plus(cacheWriteCost);
32
+ }
33
+ }
34
+
35
+ export interface TimeData {
36
+ created: number | null;
37
+ completed: number | null;
38
+ }
39
+
40
+ // Part types from the `part` table
41
+ export type MessagePart =
42
+ | {
43
+ type: "text";
44
+ text: string;
45
+ timeStart: number;
46
+ timeEnd: number;
47
+ }
48
+ | {
49
+ type: "tool";
50
+ callId: string;
51
+ toolName: string;
52
+ status: "completed" | "pending" | "error";
53
+ input: Record<string, unknown>;
54
+ output: string;
55
+ title: string | null;
56
+ exitCode: number | null;
57
+ truncated: boolean;
58
+ timeStart: number;
59
+ timeEnd: number;
60
+ }
61
+ | {
62
+ type: "reasoning";
63
+ text: string;
64
+ timeStart: number;
65
+ timeEnd: number;
66
+ }
67
+ | {
68
+ type: "patch";
69
+ hash: string;
70
+ files: string[];
71
+ };
72
+
73
+ export interface Interaction {
74
+ id: string;
75
+ sessionId: string;
76
+ modelId: string;
77
+ providerId: string | null;
78
+ role: "assistant" | "user";
79
+ tokens: TokenUsage;
80
+ time: TimeData;
81
+ agent: string | null;
82
+ finishReason: string | null;
83
+ outputRate: number;
84
+ parts: MessagePart[];
85
+ }
86
+
87
+ export interface Session {
88
+ id: string;
89
+ parentId: string | null;
90
+ projectId: string | null;
91
+ projectName: string | null;
92
+ title: string | null;
93
+ timeCreated: number | null;
94
+ timeArchived: number | null;
95
+ interactions: Interaction[];
96
+ source: "sqlite" | "files";
97
+ }
98
+
99
+ export interface AgentNode {
100
+ session: Session;
101
+ children: AgentNode[];
102
+ depth: number;
103
+ }
104
+
105
+ export interface FlatNode {
106
+ id: string;
107
+ session: Session;
108
+ workflowIndex: number;
109
+ depth: number;
110
+ hasChildren: boolean;
111
+ agentNode: AgentNode;
112
+ }
113
+
114
+ export interface Workflow {
115
+ id: string;
116
+ mainSession: Session;
117
+ subAgentSessions: Session[];
118
+ agentTree: AgentNode;
119
+ }
120
+
121
+ export interface ModelPricing {
122
+ input: Decimal;
123
+ output: Decimal;
124
+ cacheRead: Decimal;
125
+ cacheWrite: Decimal;
126
+ contextWindow: number;
127
+ }
128
+
129
+ export interface ToolUsage {
130
+ name: string;
131
+ calls: number;
132
+ successes: number;
133
+ failures: number;
134
+ totalDurationMs: number;
135
+ avgDurationMs: number;
136
+ recentErrors: string[];
137
+ }
138
+
139
+ export interface OverviewStats {
140
+ totalCost: Decimal;
141
+ totalTokens: TokenUsage;
142
+ modelBreakdown: Map<string, { cost: Decimal; tokens: number; calls: number }>;
143
+ projectBreakdown: Map<string, { cost: Decimal; sessions: number }>;
144
+ agentBreakdown: Map<string, { cost: Decimal; calls: number }>;
145
+ agentToolErrors: Map<string, { calls: number; errors: number }>;
146
+ toolCallCounts: Map<string, { calls: number; errors: number; totalDurationMs: number }>;
147
+ // 7-day daily data
148
+ weeklyTokens: { date: string; tokens: number }[];
149
+ weeklySessions: { date: string; sessions: number }[];
150
+ // 24-hour activity pattern (interactions per hour, all-time)
151
+ hourlyActivity: number[];
152
+ }
153
+
154
+ export type ScreenId = "sessions" | "tools" | "overview";
155
+
156
+ export type { Decimal };
@@ -0,0 +1,82 @@
1
+ import Decimal from "decimal.js";
2
+ import type { ModelPricing } from "../core/types";
3
+
4
+ const DEFAULT_PRICING: ModelPricing = {
5
+ input: new Decimal(0),
6
+ output: new Decimal(0),
7
+ cacheRead: new Decimal(0),
8
+ cacheWrite: new Decimal(0),
9
+ contextWindow: 128000,
10
+ };
11
+
12
+ const KNOWN_PRICING: Record<string, Partial<ModelPricing>> = {
13
+ "claude-sonnet-4-20250514": {
14
+ input: new Decimal(3),
15
+ output: new Decimal(15),
16
+ cacheRead: new Decimal(0.3),
17
+ cacheWrite: new Decimal(3.75),
18
+ contextWindow: 200000,
19
+ },
20
+ "claude-3-5-sonnet-20241022": {
21
+ input: new Decimal(3),
22
+ output: new Decimal(15),
23
+ cacheRead: new Decimal(0.3),
24
+ cacheWrite: new Decimal(3.75),
25
+ contextWindow: 200000,
26
+ },
27
+ "claude-3-5-sonnet-20240620": {
28
+ input: new Decimal(3),
29
+ output: new Decimal(15),
30
+ cacheRead: new Decimal(0.3),
31
+ cacheWrite: new Decimal(3.75),
32
+ contextWindow: 200000,
33
+ },
34
+ "claude-3-5-haiku-20241022": {
35
+ input: new Decimal(1),
36
+ output: new Decimal(5),
37
+ cacheRead: new Decimal(0.1),
38
+ cacheWrite: new Decimal(1.25),
39
+ contextWindow: 200000,
40
+ },
41
+ "claude-3-haiku-20240307": {
42
+ input: new Decimal(0.25),
43
+ output: new Decimal(1.25),
44
+ cacheRead: new Decimal(0.03),
45
+ cacheWrite: new Decimal(0.3),
46
+ contextWindow: 200000,
47
+ },
48
+ "claude-3-opus-20240229": {
49
+ input: new Decimal(15),
50
+ output: new Decimal(75),
51
+ cacheRead: new Decimal(1.5),
52
+ cacheWrite: new Decimal(18.75),
53
+ contextWindow: 200000,
54
+ },
55
+ "claude-opus-4-20250514": {
56
+ input: new Decimal(15),
57
+ output: new Decimal(75),
58
+ cacheRead: new Decimal(1.5),
59
+ cacheWrite: new Decimal(18.75),
60
+ contextWindow: 200000,
61
+ },
62
+ };
63
+
64
+ export function getPricing(modelId: string): ModelPricing {
65
+ const normalized = modelId.toLowerCase();
66
+
67
+ for (const [key, pricing] of Object.entries(KNOWN_PRICING)) {
68
+ if (normalized.includes(key.toLowerCase()) || key.toLowerCase().includes(normalized)) {
69
+ return { ...DEFAULT_PRICING, ...pricing };
70
+ }
71
+ }
72
+
73
+ return DEFAULT_PRICING;
74
+ }
75
+
76
+ export function getAllPricing(): Map<string, ModelPricing> {
77
+ const result = new Map<string, ModelPricing>();
78
+ for (const [key, pricing] of Object.entries(KNOWN_PRICING)) {
79
+ result.set(key.toLowerCase(), { ...DEFAULT_PRICING, ...pricing });
80
+ }
81
+ return result;
82
+ }
@@ -0,0 +1,347 @@
1
+ import Database from "better-sqlite3";
2
+ import { TokenUsage, type Session, type Interaction, type MessagePart } from "../core/types";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ interface DbSession {
7
+ id: string;
8
+ parent_id: string | null;
9
+ project_id: string | null;
10
+ title: string | null;
11
+ time_created: number | null;
12
+ time_archived: number | null;
13
+ project_name: string | null;
14
+ }
15
+
16
+ interface DbMessage {
17
+ id: string;
18
+ session_id: string;
19
+ data: string;
20
+ time_created: number;
21
+ }
22
+
23
+ interface DbPart {
24
+ message_id: string;
25
+ session_id: string;
26
+ data: string;
27
+ }
28
+
29
+ // Real message.data schema from OpenCode
30
+ interface RealMessageData {
31
+ id?: string;
32
+ parentID?: string;
33
+ role?: "assistant" | "user";
34
+ agent?: string;
35
+ mode?: string;
36
+ modelID?: string;
37
+ providerID?: string;
38
+ time?: {
39
+ created?: number;
40
+ completed?: number;
41
+ };
42
+ tokens?: {
43
+ input?: number;
44
+ output?: number;
45
+ reasoning?: number;
46
+ cache?: {
47
+ read?: number;
48
+ write?: number;
49
+ };
50
+ };
51
+ cost?: number;
52
+ finish?: string;
53
+ // Legacy fields (older messages may still have these)
54
+ usage?: {
55
+ input_tokens?: number;
56
+ output_tokens?: number;
57
+ cache_read_input_tokens?: number;
58
+ cache_write_input_tokens?: number;
59
+ };
60
+ model?: string;
61
+ stop_reason?: string;
62
+ }
63
+
64
+ // Real part.data schema from OpenCode
65
+ interface RealPartData {
66
+ type?: string;
67
+ text?: string;
68
+ time?: {
69
+ start?: number;
70
+ end?: number;
71
+ };
72
+ // tool call fields
73
+ callID?: string;
74
+ tool?: string;
75
+ state?: {
76
+ status?: string;
77
+ input?: Record<string, unknown>;
78
+ output?: string;
79
+ title?: string;
80
+ time?: {
81
+ start?: number;
82
+ end?: number;
83
+ };
84
+ metadata?: {
85
+ exit?: number;
86
+ exitCode?: number;
87
+ truncated?: boolean;
88
+ };
89
+ };
90
+ // patch fields
91
+ hash?: string;
92
+ files?: string[];
93
+ }
94
+
95
+ export function getDbPath(): string {
96
+ return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
97
+ }
98
+
99
+ export function loadSessions(dbPath: string = getDbPath()): Session[] {
100
+ const db = new Database(dbPath, { readonly: true });
101
+
102
+ const sessions = db
103
+ .prepare(`
104
+ SELECT
105
+ s.id, s.parent_id, s.project_id, s.title,
106
+ s.time_created, s.time_archived,
107
+ p.name as project_name
108
+ FROM session s
109
+ LEFT JOIN project p ON s.project_id = p.id
110
+ ORDER BY s.time_created DESC
111
+ `)
112
+ .all() as DbSession[];
113
+
114
+ const sessionIds = sessions.map((s) => s.id);
115
+ const interactions = loadInteractions(db, sessionIds);
116
+ db.close();
117
+
118
+ return sessions.map((s) => ({
119
+ id: s.id,
120
+ parentId: s.parent_id,
121
+ projectId: s.project_id,
122
+ projectName: s.project_name,
123
+ title: s.title,
124
+ timeCreated: s.time_created,
125
+ timeArchived: s.time_archived,
126
+ interactions: interactions.get(s.id) ?? [],
127
+ source: "sqlite" as const,
128
+ }));
129
+ }
130
+
131
+ function loadInteractions(
132
+ db: Database.Database,
133
+ sessionIds: string[]
134
+ ): Map<string, Interaction[]> {
135
+ const result = new Map<string, Interaction[]>();
136
+
137
+ if (sessionIds.length === 0) return result;
138
+
139
+ const placeholders = sessionIds.map(() => "?").join(",");
140
+
141
+ const messages = db
142
+ .prepare(`
143
+ SELECT id, session_id, data, time_created
144
+ FROM message
145
+ WHERE session_id IN (${placeholders})
146
+ ORDER BY time_created ASC
147
+ `)
148
+ .all(...sessionIds) as DbMessage[];
149
+
150
+ if (messages.length === 0) return result;
151
+
152
+ // Load all parts for these messages in one batch
153
+ const messageIds = messages.map((m) => m.id);
154
+ const partsByMessageId = loadParts(db, messageIds);
155
+
156
+ for (const msg of messages) {
157
+ if (!result.has(msg.session_id)) {
158
+ result.set(msg.session_id, []);
159
+ }
160
+
161
+ const parts = partsByMessageId.get(msg.id) ?? [];
162
+ const parsed = parseMessageData(msg.data, msg.id, msg.session_id, msg.time_created, parts);
163
+ if (parsed) {
164
+ result.get(msg.session_id)!.push(parsed);
165
+ }
166
+ }
167
+
168
+ return result;
169
+ }
170
+
171
+ function loadParts(db: Database.Database, messageIds: string[]): Map<string, MessagePart[]> {
172
+ const result = new Map<string, MessagePart[]>();
173
+
174
+ if (messageIds.length === 0) return result;
175
+
176
+ // Check if part table exists
177
+ const tableExists = db
178
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='part'")
179
+ .get();
180
+ if (!tableExists) return result;
181
+
182
+ const placeholders = messageIds.map(() => "?").join(",");
183
+ let rows: DbPart[] = [];
184
+
185
+ try {
186
+ rows = db
187
+ .prepare(`
188
+ SELECT message_id, session_id, data
189
+ FROM part
190
+ WHERE message_id IN (${placeholders})
191
+ ORDER BY rowid ASC
192
+ `)
193
+ .all(...messageIds) as DbPart[];
194
+ } catch {
195
+ // part table may have different schema
196
+ return result;
197
+ }
198
+
199
+ for (const row of rows) {
200
+ if (!result.has(row.message_id)) {
201
+ result.set(row.message_id, []);
202
+ }
203
+ const part = parsePart(row.data);
204
+ if (part) {
205
+ result.get(row.message_id)!.push(part);
206
+ }
207
+ }
208
+
209
+ return result;
210
+ }
211
+
212
+ function parsePart(data: string): MessagePart | null {
213
+ try {
214
+ const json = JSON.parse(data) as RealPartData;
215
+ const timeStart = json.time?.start ?? 0;
216
+ const timeEnd = json.time?.end ?? 0;
217
+
218
+ switch (json.type) {
219
+ case "text":
220
+ return {
221
+ type: "text",
222
+ text: json.text ?? "",
223
+ timeStart,
224
+ timeEnd,
225
+ };
226
+
227
+ case "tool": {
228
+ const state = json.state ?? {};
229
+ const status = normalizeToolStatus(state.status);
230
+ // Timing lives in state.time, not top-level time
231
+ const toolTimeStart = state.time?.start ?? timeStart;
232
+ const toolTimeEnd = state.time?.end ?? timeEnd;
233
+ return {
234
+ type: "tool",
235
+ callId: json.callID ?? "",
236
+ toolName: json.tool ?? "unknown",
237
+ status,
238
+ input: state.input ?? {},
239
+ output: state.output ?? "",
240
+ title: state.title ?? null,
241
+ exitCode: state.metadata?.exit ?? state.metadata?.exitCode ?? null,
242
+ truncated: state.metadata?.truncated ?? false,
243
+ timeStart: toolTimeStart,
244
+ timeEnd: toolTimeEnd,
245
+ };
246
+ }
247
+
248
+ case "reasoning":
249
+ return {
250
+ type: "reasoning",
251
+ text: json.text ?? "",
252
+ timeStart,
253
+ timeEnd,
254
+ };
255
+
256
+ case "patch":
257
+ return {
258
+ type: "patch",
259
+ hash: json.hash ?? "",
260
+ files: json.files ?? [],
261
+ };
262
+
263
+ default:
264
+ return null;
265
+ }
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ function normalizeToolStatus(status: string | undefined): "completed" | "pending" | "error" {
272
+ if (status === "completed") return "completed";
273
+ if (status === "error") return "error";
274
+ return "pending";
275
+ }
276
+
277
+ function parseMessageData(
278
+ data: string,
279
+ messageId: string,
280
+ sessionId: string,
281
+ timeCreated: number,
282
+ parts: MessagePart[]
283
+ ): Interaction | null {
284
+ try {
285
+ const json = JSON.parse(data) as RealMessageData;
286
+
287
+ // Support both new schema (tokens.*) and legacy schema (usage.*)
288
+ const newTokens = json.tokens;
289
+ const legacyUsage = json.usage ?? {};
290
+
291
+ const input = newTokens?.input ?? legacyUsage.input_tokens ?? 0;
292
+ const output = newTokens?.output ?? legacyUsage.output_tokens ?? 0;
293
+ const cacheRead = newTokens?.cache?.read ?? legacyUsage.cache_read_input_tokens ?? 0;
294
+ const cacheWrite = newTokens?.cache?.write ?? legacyUsage.cache_write_input_tokens ?? 0;
295
+ const reasoning = newTokens?.reasoning ?? 0;
296
+
297
+ const timeCompleted = json.time?.completed ?? null;
298
+ const timeDelta =
299
+ timeCompleted && json.time?.created
300
+ ? (timeCompleted - json.time.created) / 1000
301
+ : null;
302
+ const outputRate =
303
+ output > 0 && timeDelta && timeDelta > 0 ? output / timeDelta : 0;
304
+
305
+ const role = json.role ?? "assistant";
306
+
307
+ // Only include interactions that have meaningful data (assistant messages with tokens)
308
+ if (role !== "assistant" && input === 0 && output === 0) return null;
309
+
310
+ return {
311
+ id: messageId,
312
+ sessionId,
313
+ modelId: normalizeModelName(json.modelID ?? json.model ?? "unknown"),
314
+ providerId: json.providerID ?? null,
315
+ role,
316
+ tokens: new TokenUsage(input, output, cacheRead, cacheWrite, reasoning),
317
+ time: {
318
+ created: json.time?.created ?? timeCreated,
319
+ completed: timeCompleted,
320
+ },
321
+ agent: json.agent ?? null,
322
+ finishReason: json.finish ?? json.stop_reason ?? null,
323
+ outputRate,
324
+ parts,
325
+ };
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+
331
+ function normalizeModelName(model: string): string {
332
+ return model
333
+ .replace(/-\d{8}$/, "")
334
+ .replace(/:/g, "-")
335
+ .toLowerCase();
336
+ }
337
+
338
+ export function sessionExists(dbPath: string = getDbPath()): boolean {
339
+ try {
340
+ const db = new Database(dbPath, { readonly: true });
341
+ db.prepare("SELECT 1 FROM session LIMIT 1").get();
342
+ db.close();
343
+ return true;
344
+ } catch {
345
+ return false;
346
+ }
347
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { App } from "./ui/App";
2
+ export * from "./core/types";
3
+ export * from "./core/session";
4
+ export * from "./core/agents";
5
+ export * from "./data/sqlite";
6
+ export * from "./data/pricing";