oh-my-opencode-dashboard 0.1.4 → 0.1.5

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,154 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import { describe, expect, it } from "vitest"
5
+ import { deriveTokenUsage } from "./token-usage"
6
+ import { getStorageRoots } from "./session"
7
+
8
+ function mkStorageRoot(): string {
9
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "omo-storage-"))
10
+ fs.mkdirSync(path.join(root, "session"), { recursive: true })
11
+ fs.mkdirSync(path.join(root, "message"), { recursive: true })
12
+ fs.mkdirSync(path.join(root, "part"), { recursive: true })
13
+ return root
14
+ }
15
+
16
+ function writeMessageMeta(opts: {
17
+ messageDir: string
18
+ messageId: string
19
+ meta: Record<string, unknown>
20
+ }): void {
21
+ fs.mkdirSync(opts.messageDir, { recursive: true })
22
+ fs.writeFileSync(
23
+ path.join(opts.messageDir, `${opts.messageId}.json`),
24
+ JSON.stringify({ id: opts.messageId, sessionID: "", role: "assistant", ...opts.meta }),
25
+ "utf8"
26
+ )
27
+ }
28
+
29
+ describe("deriveTokenUsage token usage", () => {
30
+ it("aggregates token usage across main + background sessions", () => {
31
+ // #given
32
+ const storageRoot = mkStorageRoot()
33
+ const storage = getStorageRoots(storageRoot)
34
+ const mainSessionId = "ses_main"
35
+ const backgroundSessionId = "ses_bg"
36
+
37
+ writeMessageMeta({
38
+ messageDir: path.join(storage.message, mainSessionId),
39
+ messageId: "msg_main",
40
+ meta: {
41
+ sessionID: mainSessionId,
42
+ role: "assistant",
43
+ providerID: "openai",
44
+ modelID: "gpt-5.2",
45
+ tokens: { input: 2, output: 1, reasoning: 1, cache: { read: 0, write: 0 } },
46
+ },
47
+ })
48
+
49
+ writeMessageMeta({
50
+ messageDir: path.join(storage.message, backgroundSessionId),
51
+ messageId: "msg_bg",
52
+ meta: {
53
+ sessionID: backgroundSessionId,
54
+ role: "assistant",
55
+ providerID: "openai",
56
+ modelID: "gpt-5.2",
57
+ tokens: { input: 3, output: 0, reasoning: 0, cache: { read: 1, write: 0 } },
58
+ },
59
+ })
60
+
61
+ // #when
62
+ const result = deriveTokenUsage({
63
+ storage,
64
+ mainSessionId,
65
+ backgroundSessionIds: [backgroundSessionId],
66
+ })
67
+
68
+ // #then
69
+ expect(result.rows.length).toBe(1)
70
+ expect(result.rows[0]).toEqual({
71
+ model: "openai/gpt-5.2",
72
+ input: 5,
73
+ output: 1,
74
+ reasoning: 1,
75
+ cacheRead: 1,
76
+ cacheWrite: 0,
77
+ total: 8,
78
+ })
79
+ expect(result.totals).toEqual({
80
+ input: 5,
81
+ output: 1,
82
+ reasoning: 1,
83
+ cacheRead: 1,
84
+ cacheWrite: 0,
85
+ total: 8,
86
+ })
87
+ })
88
+
89
+ it("returns empty payload when message directories are missing", () => {
90
+ // #given
91
+ const storageRoot = mkStorageRoot()
92
+ const storage = getStorageRoots(storageRoot)
93
+
94
+ // #when
95
+ const result = deriveTokenUsage({
96
+ storage,
97
+ mainSessionId: "ses_missing",
98
+ backgroundSessionIds: ["ses_other"],
99
+ })
100
+
101
+ // #then
102
+ expect(result.rows).toEqual([])
103
+ expect(result.totals).toEqual({
104
+ input: 0,
105
+ output: 0,
106
+ reasoning: 0,
107
+ cacheRead: 0,
108
+ cacheWrite: 0,
109
+ total: 0,
110
+ })
111
+ })
112
+
113
+ it("dedupes message ids across sessions and ignores empty ids", () => {
114
+ // #given
115
+ const storageRoot = mkStorageRoot()
116
+ const storage = getStorageRoots(storageRoot)
117
+ const mainSessionId = "ses_main"
118
+ const backgroundSessionId = "ses_bg"
119
+
120
+ writeMessageMeta({
121
+ messageDir: path.join(storage.message, mainSessionId),
122
+ messageId: "msg_dupe",
123
+ meta: {
124
+ sessionID: mainSessionId,
125
+ role: "assistant",
126
+ providerID: "openai",
127
+ modelID: "gpt-5.2",
128
+ tokens: { input: 4, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
129
+ },
130
+ })
131
+
132
+ writeMessageMeta({
133
+ messageDir: path.join(storage.message, backgroundSessionId),
134
+ messageId: "msg_dupe",
135
+ meta: {
136
+ sessionID: backgroundSessionId,
137
+ role: "assistant",
138
+ providerID: "openai",
139
+ modelID: "gpt-5.2",
140
+ tokens: { input: 9, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
141
+ },
142
+ })
143
+
144
+ // #when
145
+ const result = deriveTokenUsage({
146
+ storage,
147
+ mainSessionId,
148
+ backgroundSessionIds: [backgroundSessionId, "", " ", null],
149
+ })
150
+
151
+ // #then
152
+ expect(result.totals.input).toBe(4)
153
+ })
154
+ })
@@ -0,0 +1,66 @@
1
+ import * as fs from "node:fs"
2
+ import * as path from "node:path"
3
+ import type { OpenCodeStorageRoots } from "./session"
4
+ import { getMessageDir } from "./session"
5
+ import { aggregateTokenUsage } from "./token-usage-core"
6
+
7
+ function listJsonFiles(dir: string): string[] {
8
+ try {
9
+ return fs.readdirSync(dir).filter((f) => f.endsWith(".json"))
10
+ } catch {
11
+ return []
12
+ }
13
+ }
14
+
15
+ function readJsonFile(filePath: string): unknown | null {
16
+ try {
17
+ const content = fs.readFileSync(filePath, "utf8")
18
+ return JSON.parse(content) as unknown
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ function normalizeSessionId(value: unknown): string | null {
25
+ if (typeof value !== "string") return null
26
+ const trimmed = value.trim()
27
+ return trimmed ? trimmed : null
28
+ }
29
+
30
+ function readSessionMetas(messageDir: string): unknown[] {
31
+ if (!messageDir) return []
32
+ const files = listJsonFiles(messageDir)
33
+ const metas: unknown[] = []
34
+ for (const file of files) {
35
+ const meta = readJsonFile(path.join(messageDir, file))
36
+ if (meta) metas.push(meta)
37
+ }
38
+ return metas
39
+ }
40
+
41
+ export function deriveTokenUsage(opts: {
42
+ storage: OpenCodeStorageRoots
43
+ mainSessionId: string | null
44
+ backgroundSessionIds?: Array<string | null | undefined>
45
+ }): ReturnType<typeof aggregateTokenUsage> {
46
+ const sessionIds: string[] = []
47
+ const seen = new Set<string>()
48
+ const push = (value: unknown): void => {
49
+ const id = normalizeSessionId(value)
50
+ if (!id || seen.has(id)) return
51
+ seen.add(id)
52
+ sessionIds.push(id)
53
+ }
54
+
55
+ push(opts.mainSessionId)
56
+ for (const id of opts.backgroundSessionIds ?? []) push(id)
57
+
58
+ const metas: unknown[] = []
59
+ for (const sessionId of sessionIds) {
60
+ const messageDir = getMessageDir(opts.storage.message, sessionId)
61
+ if (!messageDir) continue
62
+ metas.push(...readSessionMetas(messageDir))
63
+ }
64
+
65
+ return aggregateTokenUsage(metas)
66
+ }
@@ -355,4 +355,105 @@ describe("buildDashboardPayload", () => {
355
355
  fs.rmSync(projectRoot, { recursive: true, force: true })
356
356
  }
357
357
  })
358
+
359
+ it("includes tokenUsage totals and rows for main session", () => {
360
+ const storageRoot = mkStorageRoot()
361
+ const storage = getStorageRoots(storageRoot)
362
+ const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omo-project-"))
363
+ const sessionId = "ses_token_usage"
364
+ const messageId = "msg_token_1"
365
+ const projectID = "proj_1"
366
+ const providerID = "openai"
367
+ const modelID = "gpt-4o"
368
+ const expectedMessageTokens = {
369
+ input: 12,
370
+ output: 34,
371
+ reasoning: 5,
372
+ cache: {
373
+ read: 2,
374
+ write: 3,
375
+ },
376
+ }
377
+ const expectedTotals = {
378
+ input: 12,
379
+ output: 34,
380
+ reasoning: 5,
381
+ cacheRead: 2,
382
+ cacheWrite: 3,
383
+ total: 56,
384
+ }
385
+
386
+ try {
387
+ const sessionMetaDir = path.join(storage.session, projectID)
388
+ fs.mkdirSync(sessionMetaDir, { recursive: true })
389
+ fs.writeFileSync(
390
+ path.join(sessionMetaDir, `${sessionId}.json`),
391
+ JSON.stringify({
392
+ id: sessionId,
393
+ projectID,
394
+ directory: projectRoot,
395
+ time: { created: 1000, updated: 1000 },
396
+ }),
397
+ "utf8"
398
+ )
399
+
400
+ const messageDir = path.join(storage.message, sessionId)
401
+ fs.mkdirSync(messageDir, { recursive: true })
402
+ fs.writeFileSync(
403
+ path.join(messageDir, `${messageId}.json`),
404
+ JSON.stringify({
405
+ id: messageId,
406
+ sessionID: sessionId,
407
+ role: "assistant",
408
+ providerID,
409
+ modelID,
410
+ tokens: expectedMessageTokens,
411
+ time: { created: 1200 },
412
+ }),
413
+ "utf8"
414
+ )
415
+
416
+ type TokenUsageTotals = typeof expectedTotals
417
+ type TokenUsageRow = {
418
+ model: string
419
+ input: number
420
+ output: number
421
+ reasoning: number
422
+ cacheRead: number
423
+ cacheWrite: number
424
+ total: number
425
+ }
426
+ type DashboardPayloadWithTokenUsage = ReturnType<typeof buildDashboardPayload> & {
427
+ tokenUsage: {
428
+ totals: TokenUsageTotals
429
+ rows: TokenUsageRow[]
430
+ }
431
+ }
432
+
433
+ const payload = buildDashboardPayload({
434
+ projectRoot,
435
+ storage,
436
+ nowMs: 2000,
437
+ }) as DashboardPayloadWithTokenUsage
438
+
439
+ expect(payload).toHaveProperty("tokenUsage")
440
+ expect(payload.tokenUsage.totals).toEqual(expectedTotals)
441
+ expect(payload.tokenUsage.rows).toEqual(
442
+ expect.arrayContaining([
443
+ expect.objectContaining({
444
+ model: `${providerID}/${modelID}`,
445
+ input: expectedTotals.input,
446
+ output: expectedTotals.output,
447
+ reasoning: expectedTotals.reasoning,
448
+ cacheRead: expectedTotals.cacheRead,
449
+ cacheWrite: expectedTotals.cacheWrite,
450
+ total: expectedTotals.total,
451
+ }),
452
+ ])
453
+ )
454
+ } finally {
455
+ fs.rmSync(storageRoot, { recursive: true, force: true })
456
+ fs.rmSync(projectRoot, { recursive: true, force: true })
457
+ }
458
+ })
358
459
  })
@@ -5,6 +5,7 @@ import { deriveBackgroundTasks } from "../ingest/background-tasks"
5
5
  import { deriveTimeSeriesActivity, type TimeSeriesPayload } from "../ingest/timeseries"
6
6
  import { getMainSessionView, getStorageRoots, pickActiveSessionId, readMainSessionMetas, type MainSessionView, type OpenCodeStorageRoots, type SessionMetadata } from "../ingest/session"
7
7
  import { deriveToolCalls } from "../ingest/tool-calls"
8
+ import { deriveTokenUsage } from "../ingest/token-usage"
8
9
 
9
10
  export type DashboardPayload = {
10
11
  mainSession: {
@@ -48,6 +49,7 @@ export type DashboardPayload = {
48
49
  sessionId: string | null
49
50
  }>
50
51
  timeSeries: TimeSeriesPayload
52
+ tokenUsage?: ReturnType<typeof deriveTokenUsage>
51
53
  raw: unknown
52
54
  }
53
55
 
@@ -212,6 +214,12 @@ export function buildDashboardPayload(opts: {
212
214
  ]
213
215
  })()
214
216
 
217
+ const tokenUsage = deriveTokenUsage({
218
+ storage: opts.storage,
219
+ mainSessionId: sessionId ?? null,
220
+ backgroundSessionIds: tasks.map((task) => task.sessionId ?? null),
221
+ })
222
+
215
223
  const payload: DashboardPayload = {
216
224
  mainSession: {
217
225
  agent: main.agent,
@@ -243,6 +251,7 @@ export function buildDashboardPayload(opts: {
243
251
  })),
244
252
  mainSessionTasks,
245
253
  timeSeries,
254
+ tokenUsage,
246
255
  raw: null,
247
256
  }
248
257