@jetcrab/pi-desk-usage 0.0.0-stage → 1.0.1-dev.9001

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,342 @@
1
+ import { usageBucketRanges, usageBucketIndex } from './l4-usage-calendar.js';
2
+ const MAX_DISPLAY_POINTS = 800;
3
+ const MAX_TRACK_ACTIVITIES = 160;
4
+ const MINUTE_MS = 60_000;
5
+ const HOUR_MS = 60 * MINUTE_MS;
6
+ const DAY_MS = 24 * HOUR_MS;
7
+ function emptyTotals() {
8
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
9
+ }
10
+ function addTotals(target, value) {
11
+ target.input += value.input;
12
+ target.output += value.output;
13
+ target.cacheRead += value.cacheRead;
14
+ target.cacheWrite += value.cacheWrite;
15
+ target.cost += value.cost;
16
+ }
17
+ function promptTokens(event) {
18
+ return event.totals.input + event.totals.cacheRead + event.totals.cacheWrite;
19
+ }
20
+ function rate(point) {
21
+ return point.prompt > 0 ? point.cacheRead / point.prompt : 0;
22
+ }
23
+ function pointKey(point) {
24
+ return `${point.at}:${point.prompt}:${point.cacheRead}`;
25
+ }
26
+ function trimToTarget(points, target, mandatoryAt) {
27
+ if (points.length <= target)
28
+ return points;
29
+ const mandatory = points.filter((point, index) => index === 0 || index === points.length - 1 || mandatoryAt.has(point.at));
30
+ const mandatoryKeys = new Set(mandatory.map(pointKey));
31
+ const optional = points.filter((point) => !mandatoryKeys.has(pointKey(point)));
32
+ const remaining = Math.max(0, target - mandatory.length);
33
+ if (remaining === 0)
34
+ return mandatory.sort((left, right) => left.at - right.at);
35
+ const selected = [...mandatory];
36
+ const bucketCount = Math.max(1, Math.floor(remaining / 2));
37
+ const bucketSize = optional.length / bucketCount;
38
+ for (let bucket = 0; bucket < bucketCount; bucket += 1) {
39
+ const start = Math.floor(bucket * bucketSize);
40
+ const end = Math.max(start + 1, Math.floor((bucket + 1) * bucketSize));
41
+ const values = optional.slice(start, end);
42
+ if (values.length === 0)
43
+ continue;
44
+ const promptMin = values.reduce((best, point) => (point.prompt < best.prompt ? point : best));
45
+ const promptMax = values.reduce((best, point) => (point.prompt > best.prompt ? point : best));
46
+ const rateMin = values.reduce((best, point) => (rate(point) < rate(best) ? point : best));
47
+ const rateMax = values.reduce((best, point) => (rate(point) > rate(best) ? point : best));
48
+ for (const point of [promptMin, promptMax, rateMin, rateMax].sort((left, right) => left.at - right.at)) {
49
+ if (selected.length >= target)
50
+ break;
51
+ if (!selected.some((item) => pointKey(item) === pointKey(point)))
52
+ selected.push(point);
53
+ }
54
+ }
55
+ return selected.sort((left, right) => left.at - right.at);
56
+ }
57
+ function chooseBucketMs(duration) {
58
+ if (duration <= 2 * HOUR_MS)
59
+ return MINUTE_MS;
60
+ if (duration <= 10 * HOUR_MS)
61
+ return 5 * MINUTE_MS;
62
+ if (duration <= 30 * HOUR_MS)
63
+ return 15 * MINUTE_MS;
64
+ if (duration <= 60 * HOUR_MS)
65
+ return 30 * MINUTE_MS;
66
+ if (duration <= 5 * DAY_MS)
67
+ return HOUR_MS;
68
+ if (duration <= 15 * DAY_MS)
69
+ return 3 * HOUR_MS;
70
+ if (duration <= 30 * DAY_MS)
71
+ return 6 * HOUR_MS;
72
+ if (duration <= 90 * DAY_MS)
73
+ return 12 * HOUR_MS;
74
+ return DAY_MS;
75
+ }
76
+ function runIndex(runs, timestamp) {
77
+ if (runs.length <= 1)
78
+ return 0;
79
+ let result = 0;
80
+ for (let index = 1; index < runs.length; index += 1) {
81
+ if (timestamp < runs[index].startAt)
82
+ break;
83
+ result = index;
84
+ }
85
+ return result;
86
+ }
87
+ function inRange(timestamp, startAt, endAt) {
88
+ return timestamp >= startAt && timestamp < endAt;
89
+ }
90
+ function intersects(startAt, endAt, rangeStart, rangeEnd) {
91
+ return startAt < rangeEnd && (endAt === null || endAt >= rangeStart);
92
+ }
93
+ function contextStates(inputs) {
94
+ return inputs.map((input) => {
95
+ const calls = input.events
96
+ .filter((event) => event.kind === 'assistant' && promptTokens(event) > 0)
97
+ .map((event) => ({
98
+ timestamp: event.timestamp,
99
+ order: event.order,
100
+ prompt: promptTokens(event),
101
+ cacheRead: event.totals.cacheRead
102
+ }))
103
+ .sort((left, right) => left.timestamp - right.timestamp || left.order - right.order);
104
+ const markers = [...input.markers].sort((left, right) => left.timestamp - right.timestamp || left.order - right.order);
105
+ const segments = Array.from({ length: Math.max(1, input.kind === 'main' ? 1 : input.runs.length) }, () => []);
106
+ const mandatoryAt = new Set();
107
+ for (const call of calls) {
108
+ segments[input.kind === 'main' ? 0 : runIndex(input.runs, call.timestamp)].push({
109
+ at: call.timestamp,
110
+ prompt: call.prompt,
111
+ cacheRead: call.cacheRead
112
+ });
113
+ }
114
+ for (const marker of markers) {
115
+ const previous = [...calls].reverse().find((call) => call.timestamp < marker.timestamp);
116
+ const next = calls.find((call) => call.timestamp > marker.timestamp);
117
+ mandatoryAt.add(marker.timestamp);
118
+ if (previous)
119
+ mandatoryAt.add(previous.timestamp);
120
+ if (next) {
121
+ mandatoryAt.add(next.timestamp);
122
+ const segment = input.kind === 'main' ? 0 : runIndex(input.runs, marker.timestamp);
123
+ segments[segment].push({
124
+ at: marker.timestamp,
125
+ prompt: next.prompt,
126
+ cacheRead: next.cacheRead
127
+ });
128
+ }
129
+ }
130
+ for (const segment of segments)
131
+ segment.sort((left, right) => left.at - right.at);
132
+ return {
133
+ input,
134
+ calls,
135
+ markers,
136
+ segments: segments.filter((segment) => segment.length > 0),
137
+ mandatoryAt
138
+ };
139
+ });
140
+ }
141
+ function sampledSources(states) {
142
+ const totalPoints = states.reduce((total, state) => total + state.segments.reduce((sum, segment) => sum + segment.length, 0), 0);
143
+ const sampled = totalPoints > MAX_DISPLAY_POINTS;
144
+ const ratio = sampled ? MAX_DISPLAY_POINTS / totalPoints : 1;
145
+ return {
146
+ sampled,
147
+ sources: states.map((state) => {
148
+ const pointCount = state.segments.reduce((sum, segment) => sum + segment.length, 0);
149
+ let remainingTarget = sampled
150
+ ? Math.max(state.segments.length * 2, Math.floor(pointCount * ratio))
151
+ : pointCount;
152
+ let remainingPoints = pointCount;
153
+ const segments = state.segments.map((segment, index) => {
154
+ const target = sampled
155
+ ? Math.min(segment.length, Math.max(Math.min(segment.length, 2), index === state.segments.length - 1
156
+ ? remainingTarget
157
+ : Math.floor((segment.length / Math.max(1, remainingPoints)) * remainingTarget)))
158
+ : segment.length;
159
+ remainingTarget = Math.max(0, remainingTarget - target);
160
+ remainingPoints -= segment.length;
161
+ return trimToTarget(segment, target, state.mandatoryAt);
162
+ });
163
+ return {
164
+ key: state.input.key,
165
+ kind: state.input.kind,
166
+ title: state.input.kind === 'main' ? '主代理' : state.input.title,
167
+ agentType: state.input.agentType,
168
+ calls: state.calls.length,
169
+ segments,
170
+ runs: state.input.runs
171
+ };
172
+ })
173
+ };
174
+ }
175
+ function timelineMarkers(states) {
176
+ return states
177
+ .flatMap((state) => state.markers.map((marker) => {
178
+ const previous = [...state.calls]
179
+ .reverse()
180
+ .find((call) => call.timestamp < marker.timestamp);
181
+ const next = state.calls.find((call) => call.timestamp > marker.timestamp);
182
+ return {
183
+ x: marker.timestamp,
184
+ sourceKey: state.input.key,
185
+ kind: marker.kind,
186
+ timestamp: marker.timestamp,
187
+ mode: marker.mode,
188
+ beforePrompt: previous?.prompt ?? null,
189
+ projectedPrompt: marker.projectedTokens,
190
+ afterPrompt: next?.prompt ?? null,
191
+ tokensBefore: marker.tokensBefore
192
+ };
193
+ }))
194
+ .sort((left, right) => left.timestamp - right.timestamp);
195
+ }
196
+ function createBuckets(inputs, startAt, endAt, bucketMs, timeZone) {
197
+ const buckets = usageBucketRanges(startAt, endAt, bucketMs, timeZone).map((range) => ({
198
+ ...range,
199
+ totals: emptyTotals(),
200
+ calls: 0,
201
+ sources: inputs.map((source) => ({ sourceKey: source.key, totals: emptyTotals(), calls: 0 }))
202
+ }));
203
+ const sourceIndexes = new Map(inputs.map((source, index) => [source.key, index]));
204
+ for (const source of inputs) {
205
+ const sourceIndex = sourceIndexes.get(source.key);
206
+ for (const event of source.events) {
207
+ if (!inRange(event.timestamp, startAt, endAt))
208
+ continue;
209
+ const index = usageBucketIndex(buckets, event.timestamp);
210
+ const bucket = buckets[index];
211
+ const sourceBucket = bucket.sources[sourceIndex];
212
+ addTotals(bucket.totals, event.totals);
213
+ addTotals(sourceBucket.totals, event.totals);
214
+ if (event.kind === 'assistant') {
215
+ bucket.calls += 1;
216
+ sourceBucket.calls += 1;
217
+ }
218
+ }
219
+ }
220
+ return buckets;
221
+ }
222
+ export function createUsageActivities(inputs, startAt, endAt) {
223
+ const activities = [];
224
+ for (const source of inputs) {
225
+ for (const event of source.events) {
226
+ if (!inRange(event.timestamp, startAt, endAt))
227
+ continue;
228
+ activities.push({
229
+ id: event.identity,
230
+ timestamp: event.timestamp,
231
+ endAt: null,
232
+ sourceKey: source.key,
233
+ kind: 'model',
234
+ label: event.label,
235
+ detail: event.kind === 'cache-warm'
236
+ ? '缓存保温'
237
+ : event.kind === 'usage'
238
+ ? '其他模型调用'
239
+ : event.kind === 'assistant'
240
+ ? '模型调用'
241
+ : '工具与摘要用量',
242
+ status: null,
243
+ totals: { ...event.totals }
244
+ });
245
+ }
246
+ for (const tool of source.tools) {
247
+ if (!intersects(tool.timestamp, tool.endAt, startAt, endAt))
248
+ continue;
249
+ activities.push({
250
+ id: tool.identity,
251
+ timestamp: tool.timestamp,
252
+ endAt: tool.endAt,
253
+ sourceKey: source.key,
254
+ kind: 'tool',
255
+ label: tool.toolName,
256
+ detail: tool.reasoning,
257
+ status: tool.status,
258
+ totals: null
259
+ });
260
+ }
261
+ if (source.kind === 'subagent') {
262
+ for (const [index, run] of source.runs.entries()) {
263
+ if (!intersects(run.startAt, run.endAt, startAt, endAt))
264
+ continue;
265
+ activities.push({
266
+ id: `${source.key}:run:${index}`,
267
+ timestamp: run.startAt,
268
+ endAt: run.endAt,
269
+ sourceKey: source.key,
270
+ kind: 'subagent-run',
271
+ label: '子代理运行',
272
+ detail: source.title,
273
+ status: run.endAt === null ? 'running' : 'completed',
274
+ totals: null
275
+ });
276
+ }
277
+ }
278
+ for (const marker of source.markers) {
279
+ if (!inRange(marker.timestamp, startAt, endAt))
280
+ continue;
281
+ activities.push({
282
+ id: marker.identity,
283
+ timestamp: marker.timestamp,
284
+ endAt: null,
285
+ sourceKey: source.key,
286
+ kind: marker.kind,
287
+ label: marker.kind === 'compaction' ? '上下文压缩' : '上下文忽略',
288
+ detail: null,
289
+ status: null,
290
+ totals: null
291
+ });
292
+ }
293
+ }
294
+ return activities.sort((left, right) => left.timestamp - right.timestamp || left.id.localeCompare(right.id));
295
+ }
296
+ export function createUsageTimeline(inputs, requestedStartAt, requestedEndAt, timeZone = 'UTC') {
297
+ const observedStarts = inputs.flatMap((source) => [
298
+ ...source.events.map((event) => event.timestamp),
299
+ ...source.tools.map((tool) => tool.timestamp),
300
+ ...source.runs.map((run) => run.startAt)
301
+ ]);
302
+ const observedEnds = inputs.flatMap((source) => [
303
+ ...source.events.map((event) => event.timestamp),
304
+ ...source.tools.flatMap((tool) => (tool.endAt === null ? [] : [tool.endAt])),
305
+ ...source.runs.flatMap((run) => (run.endAt === null ? [] : [run.endAt])),
306
+ source.updatedAt
307
+ ]);
308
+ const fallback = Date.now();
309
+ const startAt = requestedStartAt ?? Math.min(...observedStarts, fallback);
310
+ const defaultEndAt = observedEnds.length > 0 ? Math.max(...observedEnds) : fallback;
311
+ const endAt = Math.max(startAt + 1, requestedEndAt ?? defaultEndAt);
312
+ const bucketMs = chooseBucketMs(endAt - startAt);
313
+ const states = contextStates(inputs);
314
+ const sampled = sampledSources(states);
315
+ const markers = timelineMarkers(states).filter((marker) => inRange(marker.timestamp, startAt, endAt));
316
+ const allActivities = createUsageActivities(inputs, startAt, endAt);
317
+ const activities = allActivities
318
+ .filter((activity) => {
319
+ if (activity.kind === 'subagent-run')
320
+ return true;
321
+ if (activity.kind !== 'tool')
322
+ return false;
323
+ const end = activity.endAt ?? endAt;
324
+ return end - activity.timestamp >= bucketMs;
325
+ })
326
+ .sort((left, right) => (right.endAt ?? endAt) - right.timestamp - ((left.endAt ?? endAt) - left.timestamp))
327
+ .slice(0, MAX_TRACK_ACTIVITIES)
328
+ .sort((left, right) => left.timestamp - right.timestamp);
329
+ return {
330
+ generatedAt: Date.now(),
331
+ startAt,
332
+ endAt,
333
+ bucketMs,
334
+ totalCalls: inputs.reduce((total, source) => total + source.events.filter((event) => event.kind === 'assistant').length, 0),
335
+ displayedPoints: sampled.sources.reduce((total, source) => total + source.segments.reduce((sum, segment) => sum + segment.length, 0), 0),
336
+ sampled: sampled.sampled,
337
+ buckets: createBuckets(inputs, startAt, endAt, bucketMs, timeZone),
338
+ sources: sampled.sources,
339
+ markers,
340
+ activities
341
+ };
342
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,13 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ import { scanUsageFiles } from './usage-scan.js';
3
+ const input = workerData;
4
+ if (!parentPort)
5
+ throw new Error('用量 Worker 缺少 parentPort');
6
+ try {
7
+ parentPort.postMessage({ results: await scanUsageFiles(input.files, 2) });
8
+ }
9
+ catch (error) {
10
+ parentPort.postMessage({
11
+ error: error instanceof Error ? error.message : String(error)
12
+ });
13
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@jetcrab/pi-desk-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "repository": {
4
+ "type": "git",
5
+ "url": "git+https://github.com/JetCrab/pi-desk.git",
6
+ "directory": "plugins/pi-desk-usage"
7
+ },
8
+ "version": "1.0.1-dev.9001",
9
+ "license": "Apache-2.0",
10
+ "publishConfig": {
11
+ "registry": "https://registry.npmjs.org",
12
+ "access": "public"
13
+ },
14
+ "description": "Lazy Pi Desk dashboard for recent model, token, cache, and cost usage",
15
+ "type": "module",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/pi-desk.d.ts",
19
+ "import": "./dist/pi-desk.js",
20
+ "default": "./dist/pi-desk.js"
21
+ }
22
+ },
23
+ "types": "./dist/pi-desk.d.ts",
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "keywords": [
29
+ "pi-package"
30
+ ],
31
+ "pi": {
32
+ "extensions": []
33
+ },
34
+ "piDesk": {
35
+ "entry": "./dist/pi-desk.js"
36
+ },
37
+ "dependencies": {
38
+ "zod": "^4.4.3",
39
+ "@jetcrab/pi-desk-sdk": "^1.0.1-dev.9001"
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-coding-agent": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@base-ui/react": "^1.7.0",
46
+ "@types/node": "^20",
47
+ "@types/react": "^19",
48
+ "@types/react-dom": "^19",
49
+ "esbuild": "^0.25.0",
50
+ "react": "19.2.8",
51
+ "react-dom": "19.2.8",
52
+ "tsx": "^4.23.12",
53
+ "typescript": "^5"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc -p tsconfig.json && node -e \"const fs=require('node:fs');fs.rmSync('dist/browser',{recursive:true,force:true});for(const name of fs.readdirSync('dist'))if(name.startsWith('browser')&&['.js','.d.ts','.js.map'].some(ext=>name.endsWith(ext)))fs.rmSync('dist/'+name,{force:true})\" && esbuild src/browser-entry.ts --bundle --splitting --format=esm --platform=browser --target=es2022 --minify --define:process.env.NODE_ENV='production' --external:react --external:react/jsx-runtime --external:react/jsx-dev-runtime --external:react-dom --external:react-dom/client --external:@jetcrab/pi-desk-sdk/react/base --external:@jetcrab/pi-desk-sdk/react/markdown --outdir=dist/browser --entry-names=entry --chunk-names=chunks/[name]-[hash]",
57
+ "test": "node --import tsx --test tests/*.test.ts"
58
+ }
6
59
  }