@leo000001/opencode-quota-sidebar 1.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/CONTRIBUTING.md +102 -0
  3. package/LICENSE +21 -0
  4. package/README.md +216 -0
  5. package/SECURITY.md +26 -0
  6. package/dist/cache.d.ts +6 -0
  7. package/dist/cache.js +22 -0
  8. package/dist/cost.d.ts +13 -0
  9. package/dist/cost.js +76 -0
  10. package/dist/format.d.ts +21 -0
  11. package/dist/format.js +426 -0
  12. package/dist/helpers.d.ts +14 -0
  13. package/dist/helpers.js +50 -0
  14. package/dist/index.d.ts +5 -0
  15. package/dist/index.js +699 -0
  16. package/dist/period.d.ts +1 -0
  17. package/dist/period.js +14 -0
  18. package/dist/providers/common.d.ts +24 -0
  19. package/dist/providers/common.js +114 -0
  20. package/dist/providers/core/anthropic.d.ts +2 -0
  21. package/dist/providers/core/anthropic.js +46 -0
  22. package/dist/providers/core/copilot.d.ts +2 -0
  23. package/dist/providers/core/copilot.js +117 -0
  24. package/dist/providers/core/openai.d.ts +2 -0
  25. package/dist/providers/core/openai.js +159 -0
  26. package/dist/providers/index.d.ts +8 -0
  27. package/dist/providers/index.js +14 -0
  28. package/dist/providers/registry.d.ts +9 -0
  29. package/dist/providers/registry.js +38 -0
  30. package/dist/providers/third_party/rightcode.d.ts +2 -0
  31. package/dist/providers/third_party/rightcode.js +230 -0
  32. package/dist/providers/types.d.ts +58 -0
  33. package/dist/providers/types.js +1 -0
  34. package/dist/quota.d.ts +49 -0
  35. package/dist/quota.js +116 -0
  36. package/dist/quota_render.d.ts +5 -0
  37. package/dist/quota_render.js +85 -0
  38. package/dist/storage.d.ts +32 -0
  39. package/dist/storage.js +328 -0
  40. package/dist/storage_chunks.d.ts +9 -0
  41. package/dist/storage_chunks.js +147 -0
  42. package/dist/storage_dates.d.ts +9 -0
  43. package/dist/storage_dates.js +88 -0
  44. package/dist/storage_parse.d.ts +4 -0
  45. package/dist/storage_parse.js +149 -0
  46. package/dist/storage_paths.d.ts +14 -0
  47. package/dist/storage_paths.js +31 -0
  48. package/dist/title.d.ts +8 -0
  49. package/dist/title.js +38 -0
  50. package/dist/types.d.ts +116 -0
  51. package/dist/types.js +1 -0
  52. package/dist/usage.d.ts +51 -0
  53. package/dist/usage.js +243 -0
  54. package/package.json +68 -0
  55. package/quota-sidebar.config.example.json +25 -0
@@ -0,0 +1,149 @@
1
+ import { asNumber, isRecord } from './helpers.js';
2
+ function parseSessionTitleState(value) {
3
+ if (!isRecord(value))
4
+ return undefined;
5
+ if (typeof value.baseTitle !== 'string')
6
+ return undefined;
7
+ if (value.lastAppliedTitle !== undefined &&
8
+ typeof value.lastAppliedTitle !== 'string') {
9
+ return undefined;
10
+ }
11
+ return {
12
+ baseTitle: value.baseTitle,
13
+ lastAppliedTitle: value.lastAppliedTitle,
14
+ };
15
+ }
16
+ function parseProviderUsage(value) {
17
+ if (!isRecord(value))
18
+ return undefined;
19
+ return {
20
+ input: asNumber(value.input, 0),
21
+ output: asNumber(value.output, 0),
22
+ reasoning: asNumber(value.reasoning, 0),
23
+ cacheRead: asNumber(value.cacheRead, 0),
24
+ cacheWrite: asNumber(value.cacheWrite, 0),
25
+ total: asNumber(value.total, 0),
26
+ cost: asNumber(value.cost, 0),
27
+ apiCost: asNumber(value.apiCost, 0),
28
+ assistantMessages: asNumber(value.assistantMessages, 0),
29
+ };
30
+ }
31
+ function parseCachedUsage(value) {
32
+ if (!isRecord(value))
33
+ return undefined;
34
+ const providersRaw = isRecord(value.providers) ? value.providers : {};
35
+ const providers = Object.entries(providersRaw).reduce((acc, [providerID, providerUsage]) => {
36
+ const parsed = parseProviderUsage(providerUsage);
37
+ if (!parsed)
38
+ return acc;
39
+ acc[providerID] = parsed;
40
+ return acc;
41
+ }, {});
42
+ return {
43
+ input: asNumber(value.input, 0),
44
+ output: asNumber(value.output, 0),
45
+ reasoning: asNumber(value.reasoning, 0),
46
+ cacheRead: asNumber(value.cacheRead, 0),
47
+ cacheWrite: asNumber(value.cacheWrite, 0),
48
+ total: asNumber(value.total, 0),
49
+ cost: asNumber(value.cost, 0),
50
+ apiCost: asNumber(value.apiCost, 0),
51
+ assistantMessages: asNumber(value.assistantMessages, 0),
52
+ providers,
53
+ };
54
+ }
55
+ function parseCursor(value) {
56
+ if (!isRecord(value))
57
+ return undefined;
58
+ return {
59
+ lastMessageId: typeof value.lastMessageId === 'string' ? value.lastMessageId : undefined,
60
+ lastMessageTime: asNumber(value.lastMessageTime),
61
+ };
62
+ }
63
+ export function parseSessionState(value) {
64
+ if (!isRecord(value))
65
+ return undefined;
66
+ const title = parseSessionTitleState(value);
67
+ if (!title)
68
+ return undefined;
69
+ const createdAt = asNumber(value.createdAt, 0);
70
+ if (!createdAt)
71
+ return undefined;
72
+ return {
73
+ ...title,
74
+ createdAt,
75
+ usage: parseCachedUsage(value.usage),
76
+ cursor: parseCursor(value.cursor),
77
+ };
78
+ }
79
+ export function parseSessionTitleForMigration(value) {
80
+ return parseSessionTitleState(value);
81
+ }
82
+ export function parseQuotaCache(value) {
83
+ const raw = isRecord(value) ? value : {};
84
+ return Object.entries(raw).reduce((acc, [key, item]) => {
85
+ if (!isRecord(item))
86
+ return acc;
87
+ const checkedAt = asNumber(item.checkedAt, 0);
88
+ if (!checkedAt)
89
+ return acc;
90
+ const status = item.status;
91
+ if (status !== 'ok' &&
92
+ status !== 'unavailable' &&
93
+ status !== 'unsupported' &&
94
+ status !== 'error') {
95
+ return acc;
96
+ }
97
+ const label = typeof item.label === 'string' ? item.label : key;
98
+ const adapterID = typeof item.adapterID === 'string' ? item.adapterID : undefined;
99
+ const shortLabel = typeof item.shortLabel === 'string' ? item.shortLabel : undefined;
100
+ const sortOrder = typeof item.sortOrder === 'number' ? item.sortOrder : undefined;
101
+ const balance = isRecord(item.balance)
102
+ ? {
103
+ amount: typeof item.balance.amount === 'number' ? item.balance.amount : 0,
104
+ currency: typeof item.balance.currency === 'string'
105
+ ? item.balance.currency
106
+ : '$',
107
+ }
108
+ : undefined;
109
+ const windows = Array.isArray(item.windows)
110
+ ? item.windows
111
+ .filter((window) => isRecord(window))
112
+ .map((window) => ({
113
+ label: typeof window.label === 'string' ? window.label : '',
114
+ showPercent: typeof window.showPercent === 'boolean'
115
+ ? window.showPercent
116
+ : undefined,
117
+ resetLabel: typeof window.resetLabel === 'string'
118
+ ? window.resetLabel
119
+ : undefined,
120
+ remainingPercent: typeof window.remainingPercent === 'number'
121
+ ? window.remainingPercent
122
+ : undefined,
123
+ usedPercent: typeof window.usedPercent === 'number'
124
+ ? window.usedPercent
125
+ : undefined,
126
+ resetAt: typeof window.resetAt === 'string' ? window.resetAt : undefined,
127
+ }))
128
+ .filter((window) => window.label || window.remainingPercent !== undefined)
129
+ : undefined;
130
+ acc[key] = {
131
+ providerID: typeof item.providerID === 'string' ? item.providerID : key,
132
+ adapterID,
133
+ label,
134
+ shortLabel,
135
+ sortOrder,
136
+ status,
137
+ checkedAt,
138
+ remainingPercent: typeof item.remainingPercent === 'number'
139
+ ? item.remainingPercent
140
+ : undefined,
141
+ usedPercent: typeof item.usedPercent === 'number' ? item.usedPercent : undefined,
142
+ resetAt: typeof item.resetAt === 'string' ? item.resetAt : undefined,
143
+ balance,
144
+ note: typeof item.note === 'string' ? item.note : undefined,
145
+ windows,
146
+ };
147
+ return acc;
148
+ }, {});
149
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve the OpenCode data directory.
3
+ *
4
+ * OpenCode uses `xdg-basedir@5.1.0` which has NO platform-specific logic:
5
+ * xdgData = $XDG_DATA_HOME || $HOME/.local/share
6
+ * This applies on all platforms including Windows and macOS.
7
+ *
8
+ * S4 fix: renamed env var from OPENCODE_TEST_HOME to OPENCODE_QUOTA_DATA_HOME.
9
+ */
10
+ export declare function resolveOpencodeDataDir(): string;
11
+ export declare function stateFilePath(dataDir: string): string;
12
+ export declare function authFilePath(dataDir: string): string;
13
+ export declare function chunkRootPathFromStateFile(statePath: string): string;
14
+ export declare function chunkFilePath(rootPath: string, dateKey: string): string;
@@ -0,0 +1,31 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ /**
4
+ * Resolve the OpenCode data directory.
5
+ *
6
+ * OpenCode uses `xdg-basedir@5.1.0` which has NO platform-specific logic:
7
+ * xdgData = $XDG_DATA_HOME || $HOME/.local/share
8
+ * This applies on all platforms including Windows and macOS.
9
+ *
10
+ * S4 fix: renamed env var from OPENCODE_TEST_HOME to OPENCODE_QUOTA_DATA_HOME.
11
+ */
12
+ export function resolveOpencodeDataDir() {
13
+ const home = process.env.OPENCODE_QUOTA_DATA_HOME || os.homedir();
14
+ const xdg = process.env.XDG_DATA_HOME;
15
+ if (xdg)
16
+ return path.join(xdg, 'opencode');
17
+ return path.join(home, '.local', 'share', 'opencode');
18
+ }
19
+ export function stateFilePath(dataDir) {
20
+ return path.join(dataDir, 'quota-sidebar.state.json');
21
+ }
22
+ export function authFilePath(dataDir) {
23
+ return path.join(dataDir, 'auth.json');
24
+ }
25
+ export function chunkRootPathFromStateFile(statePath) {
26
+ return path.join(path.dirname(statePath), 'quota-sidebar-sessions');
27
+ }
28
+ export function chunkFilePath(rootPath, dateKey) {
29
+ const [year, month, day] = dateKey.split('-');
30
+ return path.join(rootPath, year, month, `${day}.json`);
31
+ }
@@ -0,0 +1,8 @@
1
+ export declare function normalizeBaseTitle(title: string): string;
2
+ export declare function stripAnsi(value: string): string;
3
+ export declare function canonicalizeTitle(value: string): string;
4
+ /**
5
+ * Detect whether a title already contains our decoration.
6
+ * Current layout has token/quota lines after base title line.
7
+ */
8
+ export declare function looksDecorated(title: string): boolean;
package/dist/title.js ADDED
@@ -0,0 +1,38 @@
1
+ export function normalizeBaseTitle(title) {
2
+ return stripAnsi(title).split(/\r?\n/, 1)[0] || 'Session';
3
+ }
4
+ export function stripAnsi(value) {
5
+ return value.replace(/\u001b\[[0-9;]*m/g, '');
6
+ }
7
+ export function canonicalizeTitle(value) {
8
+ return stripAnsi(value)
9
+ .split(/\r?\n/)
10
+ .map((line) => line.trimEnd())
11
+ .join('\n');
12
+ }
13
+ /**
14
+ * Detect whether a title already contains our decoration.
15
+ * Current layout has token/quota lines after base title line.
16
+ */
17
+ export function looksDecorated(title) {
18
+ const lines = stripAnsi(title).split(/\r?\n/);
19
+ if (lines.length < 2)
20
+ return false;
21
+ const detail = lines.slice(1).map((line) => line.trim());
22
+ return detail.some((line) => {
23
+ if (!line)
24
+ return false;
25
+ if (/^Input\s+\S+\s+Output\s+\S+/.test(line))
26
+ return true;
27
+ if (/^Cache\s+(Read|Write)\s+\S+/.test(line))
28
+ return true;
29
+ if (/^\$\S+\s+as API cost/.test(line))
30
+ return true;
31
+ // Backward compatibility: old plugin versions had a separate Reasoning line.
32
+ if (/^Reasoning\s+\S+/.test(line))
33
+ return true;
34
+ if (/^(OpenAI|Copilot|Anthropic|RightCode|RC)\b/.test(line))
35
+ return true;
36
+ return false;
37
+ });
38
+ }
@@ -0,0 +1,116 @@
1
+ export type QuotaStatus = 'ok' | 'unavailable' | 'unsupported' | 'error';
2
+ export type QuotaWindow = {
3
+ label: string;
4
+ /** Set false when this window line should not render a trailing percentage. */
5
+ showPercent?: boolean;
6
+ /** Prefix for reset/expiry time text in sidebar (default: Rst). */
7
+ resetLabel?: string;
8
+ remainingPercent?: number;
9
+ usedPercent?: number;
10
+ resetAt?: string;
11
+ };
12
+ export type QuotaSnapshot = {
13
+ providerID: string;
14
+ /** Adapter ID that produced this snapshot (e.g. openai, rightcode). */
15
+ adapterID?: string;
16
+ label: string;
17
+ /** Short sidebar label (e.g. OpenAI, Copilot, RC). */
18
+ shortLabel?: string;
19
+ /** Sort priority: smaller values appear first. */
20
+ sortOrder?: number;
21
+ status: QuotaStatus;
22
+ checkedAt: number;
23
+ remainingPercent?: number;
24
+ usedPercent?: number;
25
+ resetAt?: string;
26
+ /** Balance-style quota (for providers that expose balance instead of percent). */
27
+ balance?: {
28
+ amount: number;
29
+ currency: string;
30
+ };
31
+ note?: string;
32
+ /** Multi-window quota (e.g. OpenAI short-term + weekly). */
33
+ windows?: QuotaWindow[];
34
+ };
35
+ export type SessionTitleState = {
36
+ baseTitle: string;
37
+ lastAppliedTitle?: string;
38
+ };
39
+ export type CachedProviderUsage = {
40
+ input: number;
41
+ output: number;
42
+ reasoning: number;
43
+ cacheRead: number;
44
+ cacheWrite: number;
45
+ total: number;
46
+ cost: number;
47
+ /** Equivalent API billing cost (USD) computed from model pricing. */
48
+ apiCost: number;
49
+ assistantMessages: number;
50
+ };
51
+ export type CachedSessionUsage = {
52
+ input: number;
53
+ output: number;
54
+ reasoning: number;
55
+ cacheRead: number;
56
+ cacheWrite: number;
57
+ total: number;
58
+ cost: number;
59
+ /** Equivalent API billing cost (USD) computed from model pricing. */
60
+ apiCost: number;
61
+ assistantMessages: number;
62
+ providers: Record<string, CachedProviderUsage>;
63
+ };
64
+ /** Tracks incremental aggregation cursor for a session (P1). */
65
+ export type IncrementalCursor = {
66
+ /** ID of the last processed assistant message. */
67
+ lastMessageId?: string;
68
+ /** Timestamp of the last processed assistant message. */
69
+ lastMessageTime?: number;
70
+ };
71
+ export type SessionState = SessionTitleState & {
72
+ createdAt: number;
73
+ usage?: CachedSessionUsage;
74
+ /** Incremental aggregation cursor (P1). */
75
+ cursor?: IncrementalCursor;
76
+ };
77
+ export type SessionDayChunk = {
78
+ version: 1;
79
+ dateKey: string;
80
+ sessions: Record<string, SessionState>;
81
+ };
82
+ export type QuotaSidebarState = {
83
+ version: 2;
84
+ /** Global toggle — when false, sidebar titles are not modified */
85
+ titleEnabled: boolean;
86
+ sessionDateMap: Record<string, string>;
87
+ sessions: Record<string, SessionState>;
88
+ quotaCache: Record<string, QuotaSnapshot>;
89
+ };
90
+ export type QuotaSidebarConfig = {
91
+ sidebar: {
92
+ enabled: boolean;
93
+ width: number;
94
+ showCost: boolean;
95
+ showQuota: boolean;
96
+ };
97
+ quota: {
98
+ refreshMs: number;
99
+ includeOpenAI: boolean;
100
+ includeCopilot: boolean;
101
+ includeAnthropic: boolean;
102
+ /** Generic per-adapter switches (e.g. rightcode). */
103
+ providers?: Record<string, {
104
+ enabled?: boolean;
105
+ }>;
106
+ /** When true, refreshes OpenAI OAuth access token using refresh token */
107
+ refreshAccessToken: boolean;
108
+ /** Timeout for external quota fetches */
109
+ requestTimeoutMs: number;
110
+ };
111
+ toast: {
112
+ durationMs: number;
113
+ };
114
+ /** Session retention in days. Sessions older than this are evicted from memory (M2). */
115
+ retentionDays: number;
116
+ };
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
1
+ import type { AssistantMessage, Message } from '@opencode-ai/sdk';
2
+ import type { CachedSessionUsage, IncrementalCursor } from './types.js';
3
+ export type ProviderUsage = {
4
+ providerID: string;
5
+ input: number;
6
+ output: number;
7
+ /** Legacy field kept for cache compatibility; display merges into output. */
8
+ reasoning: number;
9
+ cacheRead: number;
10
+ cacheWrite: number;
11
+ total: number;
12
+ cost: number;
13
+ apiCost: number;
14
+ assistantMessages: number;
15
+ };
16
+ export type UsageSummary = {
17
+ input: number;
18
+ output: number;
19
+ /** Legacy field kept for cache compatibility; display merges into output. */
20
+ reasoning: number;
21
+ cacheRead: number;
22
+ cacheWrite: number;
23
+ total: number;
24
+ cost: number;
25
+ apiCost: number;
26
+ assistantMessages: number;
27
+ sessionCount: number;
28
+ providers: Record<string, ProviderUsage>;
29
+ };
30
+ export type UsageOptions = {
31
+ /** Equivalent API cost calculator for the message. */
32
+ calcApiCost?: (message: AssistantMessage) => number;
33
+ };
34
+ export declare function emptyUsageSummary(): UsageSummary;
35
+ export declare function summarizeMessages(entries: Array<{
36
+ info: Message;
37
+ }>, startAt?: number, sessionCount?: number, options?: UsageOptions): UsageSummary;
38
+ /**
39
+ * P1: Incremental usage aggregation.
40
+ * Only processes messages newer than the cursor. Returns updated cursor.
41
+ * If `forceRescan` is true (e.g. after message.removed), does a full rescan.
42
+ */
43
+ export declare function summarizeMessagesIncremental(entries: Array<{
44
+ info: Message;
45
+ }>, existingUsage: CachedSessionUsage | undefined, cursor: IncrementalCursor | undefined, forceRescan: boolean, options?: UsageOptions): {
46
+ usage: UsageSummary;
47
+ cursor: IncrementalCursor;
48
+ };
49
+ export declare function mergeUsage(target: UsageSummary, source: UsageSummary): UsageSummary;
50
+ export declare function toCachedSessionUsage(summary: UsageSummary): CachedSessionUsage;
51
+ export declare function fromCachedSessionUsage(cached: CachedSessionUsage, sessionCount?: number): UsageSummary;
package/dist/usage.js ADDED
@@ -0,0 +1,243 @@
1
+ export function emptyUsageSummary() {
2
+ return {
3
+ input: 0,
4
+ output: 0,
5
+ reasoning: 0,
6
+ cacheRead: 0,
7
+ cacheWrite: 0,
8
+ total: 0,
9
+ cost: 0,
10
+ apiCost: 0,
11
+ assistantMessages: 0,
12
+ sessionCount: 0,
13
+ providers: {},
14
+ };
15
+ }
16
+ function isAssistant(message) {
17
+ return message.role === 'assistant';
18
+ }
19
+ function tokenTotal(message) {
20
+ return (message.tokens.input +
21
+ message.tokens.output +
22
+ message.tokens.reasoning +
23
+ message.tokens.cache.read +
24
+ message.tokens.cache.write);
25
+ }
26
+ function mergedOutput(message) {
27
+ // Reasoning is counted into output to keep one output statistic.
28
+ return message.tokens.output + message.tokens.reasoning;
29
+ }
30
+ function addMessageUsage(target, message, options) {
31
+ const total = tokenTotal(message);
32
+ const output = mergedOutput(message);
33
+ const cost = typeof message.cost === 'number' && Number.isFinite(message.cost)
34
+ ? message.cost
35
+ : 0;
36
+ const apiCostRaw = options?.calcApiCost ? options.calcApiCost(message) : 0;
37
+ const apiCost = Number.isFinite(apiCostRaw) && apiCostRaw > 0 ? apiCostRaw : 0;
38
+ target.input += message.tokens.input;
39
+ target.output += output;
40
+ target.cacheRead += message.tokens.cache.read;
41
+ target.cacheWrite += message.tokens.cache.write;
42
+ target.total += total;
43
+ target.assistantMessages += 1;
44
+ target.cost += cost;
45
+ target.apiCost += apiCost;
46
+ const provider = target.providers[message.providerID] ||
47
+ {
48
+ providerID: message.providerID,
49
+ input: 0,
50
+ output: 0,
51
+ reasoning: 0,
52
+ cacheRead: 0,
53
+ cacheWrite: 0,
54
+ total: 0,
55
+ cost: 0,
56
+ apiCost: 0,
57
+ assistantMessages: 0,
58
+ };
59
+ provider.input += message.tokens.input;
60
+ provider.output += output;
61
+ provider.cacheRead += message.tokens.cache.read;
62
+ provider.cacheWrite += message.tokens.cache.write;
63
+ provider.total += total;
64
+ provider.cost += cost;
65
+ provider.apiCost += apiCost;
66
+ provider.assistantMessages += 1;
67
+ target.providers[message.providerID] = provider;
68
+ }
69
+ export function summarizeMessages(entries, startAt = 0, sessionCount = 1, options) {
70
+ const summary = emptyUsageSummary();
71
+ summary.sessionCount = sessionCount;
72
+ for (const entry of entries) {
73
+ if (!isAssistant(entry.info))
74
+ continue;
75
+ if (!entry.info.time.completed)
76
+ continue;
77
+ if (entry.info.time.created < startAt)
78
+ continue;
79
+ addMessageUsage(summary, entry.info, options);
80
+ }
81
+ return summary;
82
+ }
83
+ /**
84
+ * P1: Incremental usage aggregation.
85
+ * Only processes messages newer than the cursor. Returns updated cursor.
86
+ * If `forceRescan` is true (e.g. after message.removed), does a full rescan.
87
+ */
88
+ export function summarizeMessagesIncremental(entries, existingUsage, cursor, forceRescan, options) {
89
+ // If no cursor or force rescan, do full scan
90
+ if (forceRescan || !cursor?.lastMessageId || !existingUsage) {
91
+ const usage = summarizeMessages(entries, 0, 1, options);
92
+ const lastMsg = findLastCompletedAssistant(entries);
93
+ return {
94
+ usage,
95
+ cursor: {
96
+ lastMessageId: lastMsg?.id,
97
+ lastMessageTime: lastMsg?.time.completed ?? undefined,
98
+ },
99
+ };
100
+ }
101
+ // Incremental: start from existing usage, only process new messages
102
+ const summary = fromCachedSessionUsage(existingUsage, 1);
103
+ let foundCursor = false;
104
+ let newCursor = { ...cursor };
105
+ for (const entry of entries) {
106
+ if (!isAssistant(entry.info))
107
+ continue;
108
+ if (!entry.info.time.completed)
109
+ continue;
110
+ // Skip messages we've already processed
111
+ if (!foundCursor) {
112
+ if (entry.info.id === cursor.lastMessageId) {
113
+ foundCursor = true;
114
+ }
115
+ continue;
116
+ }
117
+ // Process new message
118
+ addMessageUsage(summary, entry.info, options);
119
+ newCursor = {
120
+ lastMessageId: entry.info.id,
121
+ lastMessageTime: entry.info.time.completed ?? undefined,
122
+ };
123
+ }
124
+ // If we never found the cursor message, the history may have been modified.
125
+ // Fall back to full rescan.
126
+ if (!foundCursor) {
127
+ const usage = summarizeMessages(entries, 0, 1, options);
128
+ const lastMsg = findLastCompletedAssistant(entries);
129
+ return {
130
+ usage,
131
+ cursor: {
132
+ lastMessageId: lastMsg?.id,
133
+ lastMessageTime: lastMsg?.time.completed ?? undefined,
134
+ },
135
+ };
136
+ }
137
+ return { usage: summary, cursor: newCursor };
138
+ }
139
+ function findLastCompletedAssistant(entries) {
140
+ for (let i = entries.length - 1; i >= 0; i--) {
141
+ const msg = entries[i].info;
142
+ if (isAssistant(msg) && msg.time.completed)
143
+ return msg;
144
+ }
145
+ return undefined;
146
+ }
147
+ export function mergeUsage(target, source) {
148
+ target.input += source.input;
149
+ target.output += source.output;
150
+ target.cacheRead += source.cacheRead;
151
+ target.cacheWrite += source.cacheWrite;
152
+ target.total += source.total;
153
+ target.cost += source.cost;
154
+ target.apiCost += source.apiCost;
155
+ target.assistantMessages += source.assistantMessages;
156
+ target.sessionCount += source.sessionCount;
157
+ for (const provider of Object.values(source.providers)) {
158
+ const existing = target.providers[provider.providerID] ||
159
+ {
160
+ providerID: provider.providerID,
161
+ input: 0,
162
+ output: 0,
163
+ reasoning: 0,
164
+ cacheRead: 0,
165
+ cacheWrite: 0,
166
+ total: 0,
167
+ cost: 0,
168
+ apiCost: 0,
169
+ assistantMessages: 0,
170
+ };
171
+ existing.input += provider.input;
172
+ existing.output += provider.output;
173
+ existing.cacheRead += provider.cacheRead;
174
+ existing.cacheWrite += provider.cacheWrite;
175
+ existing.total += provider.total;
176
+ existing.cost += provider.cost;
177
+ existing.apiCost += provider.apiCost;
178
+ existing.assistantMessages += provider.assistantMessages;
179
+ target.providers[provider.providerID] = existing;
180
+ }
181
+ return target;
182
+ }
183
+ export function toCachedSessionUsage(summary) {
184
+ const providers = Object.entries(summary.providers).reduce((acc, [providerID, provider]) => {
185
+ acc[providerID] = {
186
+ input: provider.input,
187
+ output: provider.output,
188
+ // Legacy persisted field for backward compatibility with old chunks.
189
+ reasoning: provider.reasoning,
190
+ cacheRead: provider.cacheRead,
191
+ cacheWrite: provider.cacheWrite,
192
+ total: provider.total,
193
+ cost: provider.cost,
194
+ apiCost: provider.apiCost,
195
+ assistantMessages: provider.assistantMessages,
196
+ };
197
+ return acc;
198
+ }, {});
199
+ return {
200
+ input: summary.input,
201
+ output: summary.output,
202
+ // Legacy persisted field for backward compatibility with old chunks.
203
+ reasoning: summary.reasoning,
204
+ cacheRead: summary.cacheRead,
205
+ cacheWrite: summary.cacheWrite,
206
+ total: summary.total,
207
+ cost: summary.cost,
208
+ apiCost: summary.apiCost,
209
+ assistantMessages: summary.assistantMessages,
210
+ providers,
211
+ };
212
+ }
213
+ export function fromCachedSessionUsage(cached, sessionCount = 1) {
214
+ // Merge legacy cached reasoning into output for a single output metric.
215
+ const mergedOutputValue = cached.output + cached.reasoning;
216
+ return {
217
+ input: cached.input,
218
+ output: mergedOutputValue,
219
+ reasoning: 0,
220
+ cacheRead: cached.cacheRead,
221
+ cacheWrite: cached.cacheWrite,
222
+ total: cached.total,
223
+ cost: cached.cost,
224
+ apiCost: cached.apiCost || 0,
225
+ assistantMessages: cached.assistantMessages,
226
+ sessionCount,
227
+ providers: Object.entries(cached.providers).reduce((acc, [providerID, provider]) => {
228
+ acc[providerID] = {
229
+ providerID,
230
+ input: provider.input,
231
+ output: provider.output + provider.reasoning,
232
+ reasoning: 0,
233
+ cacheRead: provider.cacheRead,
234
+ cacheWrite: provider.cacheWrite,
235
+ total: provider.total,
236
+ cost: provider.cost,
237
+ apiCost: provider.apiCost || 0,
238
+ assistantMessages: provider.assistantMessages,
239
+ };
240
+ return acc;
241
+ }, {}),
242
+ };
243
+ }