@jetcrab/pi-desk-usage 0.0.0-stage → 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.
@@ -0,0 +1,186 @@
1
+ export const HOUR_MS = 60 * 60 * 1000;
2
+ export const DAY_MS = 24 * HOUR_MS;
3
+ const EMPTY_TOTALS = {
4
+ input: 0,
5
+ output: 0,
6
+ cacheRead: 0,
7
+ cacheWrite: 0,
8
+ cost: 0
9
+ };
10
+ const formatters = new Map();
11
+ function zonedParts(value, timeZone) {
12
+ let formatter = formatters.get(timeZone);
13
+ if (!formatter) {
14
+ formatter = new Intl.DateTimeFormat('en-CA', {
15
+ timeZone,
16
+ year: 'numeric',
17
+ month: '2-digit',
18
+ day: '2-digit',
19
+ hour: '2-digit',
20
+ minute: '2-digit',
21
+ second: '2-digit',
22
+ hourCycle: 'h23'
23
+ });
24
+ if (formatters.size >= 8)
25
+ formatters.delete(formatters.keys().next().value);
26
+ formatters.set(timeZone, formatter);
27
+ }
28
+ return Object.fromEntries(formatter
29
+ .formatToParts(value)
30
+ .filter((part) => part.type !== 'literal')
31
+ .map((part) => [part.type, Number(part.value)]));
32
+ }
33
+ function dateKeyFromParts(year, month, day) {
34
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
35
+ }
36
+ export function dateKeyAt(value, timeZone = 'UTC') {
37
+ const parts = zonedParts(value, timeZone);
38
+ return dateKeyFromParts(parts.year, parts.month, parts.day);
39
+ }
40
+ export function dateStart(date, timeZone = 'UTC') {
41
+ const [year, month, day] = date.split('-').map(Number);
42
+ const utc = Date.UTC(year, month - 1, day);
43
+ let low = utc - 2 * DAY_MS;
44
+ let high = utc + 2 * DAY_MS;
45
+ // 查找该日期的第一个真实时刻,午夜被夏令时跳过时也不假定固定偏移。
46
+ while (high - low > 1) {
47
+ const middle = Math.floor((low + high) / 2);
48
+ if (dateKeyAt(middle, timeZone) < date)
49
+ low = middle;
50
+ else
51
+ high = middle;
52
+ }
53
+ return high;
54
+ }
55
+ function offsetAt(value, timeZone) {
56
+ const parts = zonedParts(value, timeZone);
57
+ return (Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) -
58
+ Math.floor(value / 1000) * 1000);
59
+ }
60
+ export function zonedHourStart(value, timeZone = 'UTC') {
61
+ const parts = zonedParts(value, timeZone);
62
+ let low = value - parts.minute * 60_000 - parts.second * 1000 - (value % 1000);
63
+ const offset = offsetAt(value, timeZone);
64
+ if (offsetAt(low, timeZone) === offset)
65
+ return low;
66
+ // 半小时切换可能使本次小时从 :30 开始;保留当前重复小时的实际边界。
67
+ let high = value;
68
+ while (high - low > 1) {
69
+ const middle = Math.floor((low + high) / 2);
70
+ if (offsetAt(middle, timeZone) === offset)
71
+ high = middle;
72
+ else
73
+ low = middle;
74
+ }
75
+ return high;
76
+ }
77
+ export function addDays(date, amount) {
78
+ const [year, month, day] = date.split('-').map(Number);
79
+ const value = new Date(Date.UTC(year, month - 1, day + amount));
80
+ return dateKeyFromParts(value.getUTCFullYear(), value.getUTCMonth() + 1, value.getUTCDate());
81
+ }
82
+ export function monthKeyAt(value, timeZone = 'UTC') {
83
+ return dateKeyAt(value, timeZone).slice(0, 7);
84
+ }
85
+ export function addMonths(monthKey, amount) {
86
+ const [year, month] = monthKey.split('-').map(Number);
87
+ const value = new Date(Date.UTC(year, month - 1 + amount, 1));
88
+ return `${value.getUTCFullYear()}-${String(value.getUTCMonth() + 1).padStart(2, '0')}`;
89
+ }
90
+ export function monthRange(monthKey, timeZone = 'UTC') {
91
+ return {
92
+ startAt: dateStart(`${monthKey}-01`, timeZone),
93
+ endAt: dateStart(`${addMonths(monthKey, 1)}-01`, timeZone)
94
+ };
95
+ }
96
+ export function monthWeekdayOffset(monthKey) {
97
+ const weekday = new Date(`${monthKey}-01T00:00:00Z`).getUTCDay();
98
+ return weekday === 0 ? 6 : weekday - 1;
99
+ }
100
+ export function formatMonth(monthKey, timeZone = 'UTC') {
101
+ return new Intl.DateTimeFormat('zh-CN', {
102
+ timeZone,
103
+ year: 'numeric',
104
+ month: 'long'
105
+ }).format(monthRange(monthKey, timeZone).startAt);
106
+ }
107
+ export function usageBucketRanges(startAt, endAt, bucketMs, timeZone = 'UTC') {
108
+ const buckets = [];
109
+ let cursor = startAt;
110
+ while (cursor < endAt) {
111
+ const nextDay = dateStart(addDays(dateKeyAt(cursor, timeZone), 1), timeZone);
112
+ const dayEnd = Math.min(endAt, nextDay);
113
+ while (cursor < dayEnd) {
114
+ // 日粒度使用完整自然日;更细粒度在午夜截断,次日重新开始。
115
+ const bucketEnd = bucketMs === DAY_MS ? dayEnd : Math.min(dayEnd, cursor + bucketMs);
116
+ buckets.push({ startAt: cursor, endAt: bucketEnd });
117
+ cursor = bucketEnd;
118
+ }
119
+ }
120
+ return buckets;
121
+ }
122
+ export function usageBucketIndex(buckets, timestamp) {
123
+ let low = 0;
124
+ let high = buckets.length;
125
+ while (low < high) {
126
+ const middle = Math.floor((low + high) / 2);
127
+ if (buckets[middle].endAt <= timestamp)
128
+ low = middle + 1;
129
+ else
130
+ high = middle;
131
+ }
132
+ return low;
133
+ }
134
+ function pointTotals(point, modelKey) {
135
+ if (!modelKey)
136
+ return point.totals;
137
+ return point.models.find((model) => model.key === modelKey)?.totals ?? EMPTY_TOTALS;
138
+ }
139
+ function addTotals(target, value) {
140
+ target.input += value.input;
141
+ target.output += value.output;
142
+ target.cacheRead += value.cacheRead;
143
+ target.cacheWrite += value.cacheWrite;
144
+ target.cost += value.cost;
145
+ }
146
+ function aggregateTotalsByDate(points, modelKey, timeZone) {
147
+ const totalsByDate = new Map();
148
+ for (const point of points) {
149
+ const date = dateKeyAt(point.startAt, timeZone);
150
+ const totals = totalsByDate.get(date) ?? { ...EMPTY_TOTALS };
151
+ addTotals(totals, pointTotals(point, modelKey));
152
+ totalsByDate.set(date, totals);
153
+ }
154
+ return totalsByDate;
155
+ }
156
+ export function buildUsageCalendarRange(startAt, endAt, points, modelKey, timeZone = 'UTC') {
157
+ if (endAt <= startAt)
158
+ return [];
159
+ const totalsByDate = aggregateTotalsByDate(points, modelKey, timeZone);
160
+ const firstDate = dateKeyAt(startAt, timeZone);
161
+ const lastDate = dateKeyAt(endAt - 1, timeZone);
162
+ const days = [];
163
+ let date = firstDate;
164
+ let dayStart = dateStart(date, timeZone);
165
+ while (true) {
166
+ const dayEnd = dateStart(addDays(date, 1), timeZone);
167
+ days.push({
168
+ date,
169
+ startAt: Math.max(startAt, dayStart),
170
+ endAt: Math.min(endAt, dayEnd),
171
+ totals: totalsByDate.get(date) ?? { ...EMPTY_TOTALS }
172
+ });
173
+ if (date === lastDate)
174
+ break;
175
+ date = addDays(date, 1);
176
+ dayStart = dayEnd;
177
+ }
178
+ return days;
179
+ }
180
+ export function buildUsageCalendar(monthKey, points, modelKey, dataEndAt, timeZone = 'UTC') {
181
+ const range = monthRange(monthKey, timeZone);
182
+ const days = buildUsageCalendarRange(range.startAt, range.endAt, points, modelKey, timeZone);
183
+ if (dataEndAt === undefined || dataEndAt >= range.endAt)
184
+ return days;
185
+ return days.map((day) => day.startAt < dataEndAt ? { ...day, endAt: Math.min(day.endAt, dataEndAt) } : day);
186
+ }
@@ -0,0 +1,5 @@
1
+ import type { UsageFileDescriptor } from './usage-scan.js';
2
+ export declare function pathKey(value: string): string;
3
+ export declare function nestedParentSessionId(filePath: string): string | null;
4
+ export declare function descriptor(path: string, nestedSubagent: boolean): Promise<UsageFileDescriptor | null>;
5
+ export declare function listStandardSessionFiles(root: string): Promise<UsageFileDescriptor[]>;
@@ -0,0 +1,97 @@
1
+ import { readdir, stat } from 'node:fs/promises';
2
+ import { resolve, join } from 'node:path';
3
+ const DIRECTORY_CONCURRENCY = 4;
4
+ export function pathKey(value) {
5
+ const normalized = resolve(value);
6
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
7
+ }
8
+ export function nestedParentSessionId(filePath) {
9
+ const parts = resolve(filePath).split(/[\\/]+/);
10
+ const index = parts.lastIndexOf('subagents');
11
+ return index >= 0 ? (parts[index + 1] ?? null) : null;
12
+ }
13
+ async function mapWithConcurrency(values, limit, mapper) {
14
+ const results = new Array(values.length);
15
+ let nextIndex = 0;
16
+ async function run() {
17
+ while (nextIndex < values.length) {
18
+ const index = nextIndex;
19
+ nextIndex += 1;
20
+ results[index] = await mapper(values[index]);
21
+ }
22
+ }
23
+ await Promise.all(Array.from({ length: Math.min(limit, values.length) }, () => run()));
24
+ return results;
25
+ }
26
+ export async function descriptor(path, nestedSubagent) {
27
+ try {
28
+ const metadata = await stat(path);
29
+ if (!metadata.isFile())
30
+ return null;
31
+ return {
32
+ path: resolve(path),
33
+ size: metadata.size,
34
+ modifiedAt: metadata.mtime.getTime(),
35
+ nestedSubagent
36
+ };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ function isSessionJsonlFile(name) {
43
+ return name.endsWith('.jsonl') && !name.includes('.jsonl.');
44
+ }
45
+ async function nestedSessionFiles(directory) {
46
+ let entries;
47
+ try {
48
+ entries = await readdir(directory, { withFileTypes: true });
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ const files = [];
54
+ for (const entry of entries) {
55
+ const path = join(directory, entry.name);
56
+ if (entry.isDirectory()) {
57
+ files.push(...(await nestedSessionFiles(path)));
58
+ }
59
+ else if (entry.isFile() && isSessionJsonlFile(entry.name)) {
60
+ const file = await descriptor(path, true);
61
+ if (file)
62
+ files.push(file);
63
+ }
64
+ }
65
+ return files;
66
+ }
67
+ async function projectSessionFiles(directory) {
68
+ let entries;
69
+ try {
70
+ entries = await readdir(directory, { withFileTypes: true });
71
+ }
72
+ catch {
73
+ return [];
74
+ }
75
+ const directFiles = await mapWithConcurrency(entries.filter((entry) => entry.isFile() && isSessionJsonlFile(entry.name)), DIRECTORY_CONCURRENCY, (entry) => descriptor(join(directory, entry.name), false));
76
+ const subagentDirectory = entries.find((entry) => entry.isDirectory() && entry.name === 'subagents');
77
+ const nested = subagentDirectory
78
+ ? await nestedSessionFiles(join(directory, subagentDirectory.name))
79
+ : [];
80
+ return [...directFiles.filter((file) => file !== null), ...nested];
81
+ }
82
+ export async function listStandardSessionFiles(root) {
83
+ let entries;
84
+ try {
85
+ entries = await readdir(root, { withFileTypes: true });
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ const rootFiles = await mapWithConcurrency(entries.filter((entry) => entry.isFile() && isSessionJsonlFile(entry.name)), DIRECTORY_CONCURRENCY, (entry) => descriptor(join(root, entry.name), false));
91
+ const directories = entries.filter((entry) => entry.isDirectory());
92
+ const nested = await mapWithConcurrency(directories, DIRECTORY_CONCURRENCY, (entry) => projectSessionFiles(join(root, entry.name)));
93
+ return [
94
+ ...rootFiles.filter((file) => file !== null),
95
+ ...nested.flat()
96
+ ];
97
+ }
@@ -0,0 +1 @@
1
+ export declare function formatTokens(value: number): string;
@@ -0,0 +1,12 @@
1
+ export function formatTokens(value) {
2
+ if (value < 1_000)
3
+ return Math.round(value).toLocaleString('zh-CN');
4
+ if (value < 1_000_000)
5
+ return `${(value / 1_000).toFixed(value < 10_000 ? 1 : 0)}K`;
6
+ if (value < 100_000_000) {
7
+ return `${(value / 1_000_000).toFixed(value < 10_000_000 ? 2 : 1)}M`;
8
+ }
9
+ const precision = value < 1_000_000_000 ? 2 : 1;
10
+ const formatted = (value / 100_000_000).toFixed(precision).replace(/\.?0+$/, '');
11
+ return `${formatted}亿`;
12
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@jetcrab/pi-desk-sdk/entry").PiDeskPluginDefinition;
2
+ export default _default;
@@ -0,0 +1,18 @@
1
+ import { definePiDeskPlugin } from '@jetcrab/pi-desk-sdk/entry';
2
+ import { UsageRuntime } from './usage-runtime.js';
3
+ export default definePiDeskPlugin({
4
+ name: 'usage',
5
+ setup(plugin) {
6
+ const runtime = new UsageRuntime({
7
+ workSessions: plugin.host.workSessions,
8
+ settings: plugin.host.settings
9
+ });
10
+ plugin.registerMethod('report-get', (input, context) => runtime.report(input, context.signal));
11
+ plugin.registerMethod('session-list', (input, context) => runtime.sessions(input, context.signal));
12
+ plugin.registerMethod('session-analysis-get', (input, context) => runtime.sessionAnalysis(input, context.signal));
13
+ plugin.registerMethod('session-timeline-get', (input, context) => runtime.sessionTimeline(input, context.signal));
14
+ plugin.registerMethod('session-activity-get', (input, context) => runtime.sessionActivities(input, context.signal));
15
+ plugin.registerBrowserEntry('./dist/browser/entry.js');
16
+ return () => runtime.dispose();
17
+ }
18
+ });
@@ -0,0 +1,189 @@
1
+ export interface UsageTotals {
2
+ input: number;
3
+ output: number;
4
+ cacheRead: number;
5
+ cacheWrite: number;
6
+ cost: number;
7
+ }
8
+ export interface UsageModelSnapshot {
9
+ key: string;
10
+ provider: string | null;
11
+ model: string | null;
12
+ label: string;
13
+ calls: number;
14
+ totals: UsageTotals;
15
+ }
16
+ export interface UsageSeriesPoint {
17
+ startAt: number;
18
+ endAt: number;
19
+ totals: UsageTotals;
20
+ models: UsageModelSnapshot[];
21
+ }
22
+ export interface UsageReportSnapshot {
23
+ generatedAt: number;
24
+ startAt: number;
25
+ endAt: number;
26
+ bucketMs: number;
27
+ totals: UsageTotals;
28
+ series: UsageSeriesPoint[];
29
+ models: UsageModelSnapshot[];
30
+ skippedFiles: number;
31
+ }
32
+ export interface UsageSubagentSessionSnapshot {
33
+ sessionId: string;
34
+ taskId: string;
35
+ title: string;
36
+ agentType: string | null;
37
+ startedAt: number;
38
+ updatedAt: number;
39
+ totals: UsageTotals;
40
+ models: UsageModelSnapshot[];
41
+ }
42
+ export interface UsageSessionSnapshot {
43
+ sessionId: string;
44
+ cwd: string;
45
+ title: string;
46
+ startedAt: number;
47
+ updatedAt: number;
48
+ totals: UsageTotals;
49
+ mainTotals: UsageTotals;
50
+ models: UsageModelSnapshot[];
51
+ subagents: UsageSubagentSessionSnapshot[];
52
+ }
53
+ export interface UsageSessionPage {
54
+ total: number;
55
+ offset: number;
56
+ limit: number;
57
+ items: UsageSessionSnapshot[];
58
+ }
59
+ export interface UsageStructureCounts {
60
+ userMessages: number;
61
+ assistantMessages: number;
62
+ toolCalls: number;
63
+ toolResults: number;
64
+ compactions: number;
65
+ branchSummaries: number;
66
+ }
67
+ export type UsageAnalysisCallKind = 'assistant' | 'tool-result' | 'compaction' | 'branch-summary' | 'cache-warm' | 'usage';
68
+ export interface UsageAnalysisCall {
69
+ timestamp: number;
70
+ sourceKey: string;
71
+ kind: UsageAnalysisCallKind;
72
+ modelKey: string;
73
+ requestedModel: string | null;
74
+ actualModel: string;
75
+ totals: UsageTotals;
76
+ }
77
+ interface UsageAnalysisSourceBase {
78
+ key: string;
79
+ kind: 'main' | 'subagent';
80
+ title: string;
81
+ startedAt: number;
82
+ updatedAt: number;
83
+ totals: UsageTotals;
84
+ models: UsageModelSnapshot[];
85
+ counts: UsageStructureCounts;
86
+ error: string | null;
87
+ }
88
+ export interface UsageMainAnalysisSource extends UsageAnalysisSourceBase {
89
+ key: 'main';
90
+ kind: 'main';
91
+ }
92
+ export interface UsageSubagentAnalysisSource extends UsageAnalysisSourceBase {
93
+ kind: 'subagent';
94
+ taskId: string;
95
+ agentType: string;
96
+ status: 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
97
+ runCount: number;
98
+ }
99
+ export type UsageAnalysisSource = UsageMainAnalysisSource | UsageSubagentAnalysisSource;
100
+ export interface UsageSessionAnalysisSnapshot {
101
+ generatedAt: number;
102
+ session: {
103
+ sessionId: string;
104
+ title: string;
105
+ cwd: string;
106
+ startedAt: number;
107
+ updatedAt: number;
108
+ };
109
+ inherited: UsageTotals | null;
110
+ sources: UsageAnalysisSource[];
111
+ recentCalls: UsageAnalysisCall[];
112
+ }
113
+ export interface UsageTimelinePoint {
114
+ at: number;
115
+ prompt: number;
116
+ cacheRead: number;
117
+ }
118
+ export interface UsageTimelineRun {
119
+ startAt: number;
120
+ endAt: number | null;
121
+ }
122
+ export interface UsageTimelineSourceSnapshot {
123
+ key: string;
124
+ kind: 'main' | 'subagent';
125
+ title: string;
126
+ agentType: string | null;
127
+ calls: number;
128
+ segments: UsageTimelinePoint[][];
129
+ runs: UsageTimelineRun[];
130
+ }
131
+ export interface UsageTimelineBucketSource {
132
+ sourceKey: string;
133
+ totals: UsageTotals;
134
+ calls: number;
135
+ }
136
+ export interface UsageTimelineBucket {
137
+ startAt: number;
138
+ endAt: number;
139
+ totals: UsageTotals;
140
+ calls: number;
141
+ sources: UsageTimelineBucketSource[];
142
+ }
143
+ export type UsageActivityKind = 'model' | 'tool' | 'subagent-run' | 'context-ignore' | 'compaction';
144
+ export interface UsageActivity {
145
+ id: string;
146
+ timestamp: number;
147
+ endAt: number | null;
148
+ sourceKey: string;
149
+ kind: UsageActivityKind;
150
+ label: string;
151
+ detail: string | null;
152
+ status: 'completed' | 'failed' | 'running' | null;
153
+ totals: UsageTotals | null;
154
+ }
155
+ export type UsageTimelineMarkerKind = 'context-ignore' | 'compaction';
156
+ export type UsageTimelineIgnoreMode = 'user-turns' | 'internal-turns' | 'combined';
157
+ export interface UsageTimelineMarker {
158
+ x: number;
159
+ sourceKey: string;
160
+ kind: UsageTimelineMarkerKind;
161
+ timestamp: number;
162
+ mode: UsageTimelineIgnoreMode | null;
163
+ beforePrompt: number | null;
164
+ projectedPrompt: number | null;
165
+ afterPrompt: number | null;
166
+ tokensBefore: number | null;
167
+ }
168
+ export interface UsageSessionTimelineSnapshot {
169
+ generatedAt: number;
170
+ startAt: number;
171
+ endAt: number;
172
+ bucketMs: number;
173
+ totalCalls: number;
174
+ displayedPoints: number;
175
+ sampled: boolean;
176
+ buckets: UsageTimelineBucket[];
177
+ sources: UsageTimelineSourceSnapshot[];
178
+ markers: UsageTimelineMarker[];
179
+ activities: UsageActivity[];
180
+ }
181
+ export interface UsageSessionActivityPage {
182
+ startAt: number;
183
+ endAt: number;
184
+ total: number;
185
+ offset: number;
186
+ limit: number;
187
+ items: UsageActivity[];
188
+ }
189
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import type { PiDeskWorkSessionCapability, PluginJsonObject } from '@jetcrab/pi-desk-sdk/entry';
2
+ import type { HostSettings } from '@jetcrab/pi-desk-sdk/settings';
3
+ import type { UsageFileDescriptor, UsageFileScanResult } from './usage-scan.js';
4
+ interface UsageRuntimeOptions {
5
+ sessionsRoot?: string;
6
+ workSessions?: PiDeskWorkSessionCapability;
7
+ settings?: HostSettings;
8
+ scanFiles?: (files: readonly UsageFileDescriptor[], signal: AbortSignal) => Promise<UsageFileScanResult[]>;
9
+ logger?: Pick<Console, 'info' | 'warn'>;
10
+ }
11
+ export declare class UsageRuntime {
12
+ private readonly sessionsRoot;
13
+ private readonly scanFiles;
14
+ private readonly logger;
15
+ private readonly workSessions;
16
+ private readonly settings;
17
+ private readonly cache;
18
+ private operation;
19
+ private coverage;
20
+ private skippedFiles;
21
+ private activeWorker;
22
+ private disposed;
23
+ constructor(options?: UsageRuntimeOptions);
24
+ report(input: PluginJsonObject, signal: AbortSignal): Promise<PluginJsonObject>;
25
+ sessions(input: PluginJsonObject, signal: AbortSignal): Promise<PluginJsonObject>;
26
+ sessionAnalysis(input: PluginJsonObject, signal: AbortSignal): Promise<PluginJsonObject>;
27
+ sessionTimeline(input: PluginJsonObject, signal: AbortSignal): Promise<PluginJsonObject>;
28
+ sessionActivities(input: PluginJsonObject, signal: AbortSignal): Promise<PluginJsonObject>;
29
+ dispose(): Promise<void>;
30
+ private readModelNames;
31
+ private total;
32
+ private series;
33
+ private sessionSnapshots;
34
+ private loadSessionDetails;
35
+ private requireWorkSession;
36
+ private loadSessionById;
37
+ private loadExactFile;
38
+ private timelineInputs;
39
+ private analysisCall;
40
+ private ensureIndexed;
41
+ private isCovered;
42
+ private runExclusive;
43
+ private refreshIndex;
44
+ private shouldLoad;
45
+ private loadChanged;
46
+ private childMetadata;
47
+ private indexedFiles;
48
+ private ownedEvents;
49
+ private pruneCache;
50
+ private isWithinSessionsRoot;
51
+ private scanWithWorker;
52
+ }
53
+ export {};