@syncended/dsh-usage 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 syncended
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # DeepSeek Harness Usage
2
+
3
+ A local-first DeepSeek Harness plugin for token usage, estimated model cost, trends, and a GitHub-style activity heatmap.
4
+
5
+ > **Status:** MVP for `@deepseek-ai/dsh` `0.1.1-rc.2`. The plugin is read-only: the durable Harness session log remains the single source of truth and no parallel telemetry database is created.
6
+
7
+ ## What works
8
+
9
+ - Full **Usage** workspace opened from the main sidebar.
10
+ - 30-day, 90-day, one-year, and all-time ranges.
11
+ - Summary cards for estimated spend, total tokens, model calls, sessions, and active days.
12
+ - Interactive trend chart for tokens, estimated cost, or calls.
13
+ - Input/output/cache token mix.
14
+ - GitHub-style 365-day activity heatmap.
15
+ - Per-provider/model usage, session count, call count, token volume, and estimated cost.
16
+ - Browser timezone-aware day grouping.
17
+ - Revision-aware in-memory scan cache: unchanged durable sessions are not reparsed on every refresh.
18
+ - Responsive light/dark UI built on the supported DSH sidebar and center-workspace slots.
19
+
20
+ ## Install
21
+
22
+ Requirements: Node.js 22+ and a working `dsh web` profile.
23
+
24
+ From npm:
25
+
26
+ ```bash
27
+ dsh plugin --profile web add @syncended/dsh-usage
28
+ ```
29
+
30
+ Or from this checkout:
31
+
32
+ ```bash
33
+ pnpm install
34
+ pnpm check
35
+
36
+ dsh plugin --profile web add .
37
+ ```
38
+
39
+ The package declares a DSH bundle, so `dsh plugin` appends it to the Web profile automatically. Restart the running Web Harness after the initial install, refresh the page, and open **Usage** from the sidebar.
40
+
41
+ To remove it:
42
+
43
+ ```bash
44
+ dsh plugin --profile web remove @syncended/dsh-usage
45
+ ```
46
+
47
+ ## Pricing
48
+
49
+ Cost is an estimate derived from provider-reported token buckets and USD-per-million-token rules. The plugin ships starter public-list-price rules for common OpenAI GPT-5, Anthropic Claude 4, and DeepSeek routes. Pricing changes over time and negotiated or subscription plans may not map to token billing, so override the rules for your environment.
50
+
51
+ Rules are matched in order against `provider/model`. `*` is the only wildcard. A route without a matching rule remains visible as **UNPRICED** and is excluded from estimated spend; the dashboard reports pricing coverage.
52
+
53
+ Override the bundle row in `$DSH_HOME/profiles/web/cordis.patch.yml`:
54
+
55
+ ```yaml
56
+ - id: usage
57
+ config:
58
+ scanConcurrency: 4
59
+ pricing:
60
+ - route: openai-codex/gpt-5*
61
+ input: 1.25
62
+ output: 10
63
+ cacheRead: 0.125
64
+ cacheWrite: 1.25
65
+ - route: my-provider/private-model
66
+ input: 0.8
67
+ output: 3.2
68
+ cacheRead: 0.08
69
+ cacheWrite: 0.8
70
+ ```
71
+
72
+ All amounts are USD per one million tokens. Reasoning tokens are already included in the provider's output bucket and are not counted again.
73
+
74
+ ## Data semantics
75
+
76
+ 1. The host lists materialized sessions through `ctx.sessionPersistence.listSnapshots()`.
77
+ 2. Changed sessions are read from the durable persistence prefix and accepted only when a second revision snapshot still matches, which avoids caching buffered live events under a durable revision. The capability transparently handles JSONL, compressed JSONL, SQLite, or another backend.
78
+ 3. Usage chunks and final assistant-message usage are folded with one last-wins sample per `(turn, step)`, matching Harness token-meter semantics.
79
+ 4. The exact provider/model route comes from request headers, request context, or the final model message source.
80
+ 5. The browser requests an aggregate from the package-owned read-only `GET /api/usage` endpoint. Prompts, tool arguments, and message content are never returned.
81
+
82
+ The first dashboard load may scan historical sessions. Subsequent loads reuse cached results while each persistence revision is unchanged.
83
+
84
+ ## Privacy and security
85
+
86
+ - No analytics leave the Harness host.
87
+ - No external telemetry or pricing requests are made.
88
+ - The HTTP API is same-origin and read-only.
89
+ - API output contains dates, route names, token counts, call/session counts, estimated costs, and aggregate read-error count. It does not include prompts, responses, paths, or session IDs.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ pnpm install
95
+ pnpm check
96
+ npm pack --dry-run
97
+ ```
98
+
99
+ The host plugin is strict TypeScript compiled to `dist/`. The external Web Client Plugin is a ready lazy-CJS module in `lib/client.js`, so it does not depend on unpublished DSH monorepo frontend tooling.
100
+
101
+ ## License
102
+
103
+ MIT
package/RELEASING.md ADDED
@@ -0,0 +1,40 @@
1
+ # Releasing
2
+
3
+ Releases use the same tag-driven npm publication flow as the other `@syncended/dsh-*` plugins. The base branch is `trunk`; there are no Changesets or release-it steps.
4
+
5
+ ## One-time setup
6
+
7
+ 1. Add an npm granular access token with package read/write access and 2FA bypass to the GitHub Actions secret `NPM_REGISTRY_TOKEN`.
8
+ 2. Keep GitHub Actions allowed to create provenance attestations and GitHub Releases.
9
+
10
+ ## Every release
11
+
12
+ Start from a clean `trunk` with all checks passing:
13
+
14
+ ```bash
15
+ pnpm install --frozen-lockfile
16
+ pnpm check
17
+ npm pack --dry-run
18
+ ```
19
+
20
+ Then bump, commit, tag, and push:
21
+
22
+ ```bash
23
+ npm version patch # or minor, major, or an explicit version
24
+ pnpm install --lockfile-only
25
+ git add pnpm-lock.yaml
26
+ git commit --amend --no-edit
27
+ git tag -f "v$(node -p 'require("./package.json").version')"
28
+ git push --follow-tags
29
+ ```
30
+
31
+ If the lockfile did not change, the `git add`/amend/tag refresh steps are unnecessary and the usual two commands are enough:
32
+
33
+ ```bash
34
+ npm version patch
35
+ git push --follow-tags
36
+ ```
37
+
38
+ `.github/workflows/release.yml` verifies that the tag equals `v<package version>`, installs with pnpm 11, runs all checks, verifies package contents, publishes to npm with provenance, and creates an idempotent GitHub Release with generated notes.
39
+
40
+ Package page: https://www.npmjs.com/package/@syncended/dsh-usage
@@ -0,0 +1,5 @@
1
+ # @syncended/dsh-usage bundle patch.
2
+ # Reads the existing durable session log; no parallel telemetry store is created.
3
+ - insert:
4
+ - id: usage
5
+ name: "@syncended/dsh-usage"
@@ -0,0 +1,12 @@
1
+ import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session';
2
+ import type { ModelPrice, SessionUsage, UsageRange, UsageSnapshot } from './types.js';
3
+ /**
4
+ * Built-in public-list-price estimates (USD / 1M tokens).
5
+ * Route rules are deliberately overridable through plugin config.
6
+ */
7
+ export declare const DEFAULT_PRICING: ModelPrice[];
8
+ /** Fold one durable session into billable provider usage samples. */
9
+ export declare function extractSessionUsage(meta: SessionHeader, events: readonly SessionEvent[]): SessionUsage;
10
+ export declare function dateKey(timestamp: number, timeZone: string): string;
11
+ export declare function priceFor(route: string, pricing: readonly ModelPrice[]): ModelPrice | undefined;
12
+ export declare function aggregateUsage(sessions: readonly SessionUsage[], pricing: readonly ModelPrice[], range: UsageRange, timeZone: string, now?: number, errors?: number): UsageSnapshot;
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Built-in public-list-price estimates (USD / 1M tokens).
3
+ * Route rules are deliberately overridable through plugin config.
4
+ */
5
+ export const DEFAULT_PRICING = [
6
+ { route: 'openai-codex/gpt-5', input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
7
+ { route: 'openai/gpt-5', input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
8
+ { route: 'anthropic/claude-sonnet-4-20250514', input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
9
+ { route: 'anthropic/claude-opus-4-20250514', input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
10
+ { route: 'deepseek/deepseek-chat', input: 0.28, output: 0.42, cacheRead: 0.028, cacheWrite: 0.28 },
11
+ { route: 'deepseek/deepseek-reasoner', input: 0.55, output: 2.19, cacheRead: 0.14, cacheWrite: 0.55 },
12
+ ];
13
+ const DAY_MS = 86_400_000;
14
+ const dateFormatterCache = new Map();
15
+ function finiteToken(value) {
16
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : 0;
17
+ }
18
+ function buckets(usage) {
19
+ return {
20
+ input: finiteToken(usage.inputTokens),
21
+ output: finiteToken(usage.outputTokens),
22
+ cacheRead: finiteToken(usage.cacheReadTokens),
23
+ cacheWrite: finiteToken(usage.cacheWriteTokens),
24
+ };
25
+ }
26
+ function routeFrom(value) {
27
+ if (typeof value !== 'object' || value === null)
28
+ return null;
29
+ const route = value;
30
+ if (typeof route.provider !== 'string' || route.provider === '')
31
+ return null;
32
+ if (typeof route.model !== 'string' || route.model === '')
33
+ return null;
34
+ return { provider: route.provider, model: route.model };
35
+ }
36
+ function assistantRoute(event) {
37
+ if (event.type !== 'assistant/message')
38
+ return null;
39
+ const message = event.data.message;
40
+ return routeFrom(message.source);
41
+ }
42
+ /** Fold one durable session into billable provider usage samples. */
43
+ export function extractSessionUsage(meta, events) {
44
+ let currentRoute = null;
45
+ const samples = new Map();
46
+ const seedLength = meta.seedLength ?? 0;
47
+ for (let index = 0; index < events.length; index += 1) {
48
+ const event = events[index];
49
+ if (event === undefined)
50
+ continue;
51
+ if (event.type === 'request/header') {
52
+ currentRoute = routeFrom(event.data.header.config);
53
+ continue;
54
+ }
55
+ if (event.type === 'request/context') {
56
+ currentRoute = routeFrom(event.data);
57
+ continue;
58
+ }
59
+ if (index < seedLength)
60
+ continue;
61
+ if (event.type === 'compaction/summary' && event.data.usage !== undefined) {
62
+ const amount = buckets(event.data.usage);
63
+ samples.set(`compaction:${event.seq}`, {
64
+ sessionId: String(meta.id),
65
+ timestamp: event.time,
66
+ provider: event.data.provider,
67
+ model: event.data.model,
68
+ ...amount,
69
+ });
70
+ continue;
71
+ }
72
+ let usage;
73
+ let turn;
74
+ let step;
75
+ if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
76
+ usage = event.data.chunk.usage;
77
+ turn = event.data.turn;
78
+ step = event.data.step;
79
+ }
80
+ else if (event.type === 'assistant/message' && event.data.usage !== undefined) {
81
+ usage = event.data.usage;
82
+ turn = event.data.turn;
83
+ step = event.data.step;
84
+ currentRoute = assistantRoute(event) ?? currentRoute;
85
+ }
86
+ if (usage === undefined || turn === undefined || step === undefined)
87
+ continue;
88
+ const amount = buckets(usage);
89
+ const route = assistantRoute(event) ?? currentRoute ?? { provider: 'unknown', model: 'unknown' };
90
+ samples.set(`${turn}:${step}`, {
91
+ sessionId: String(meta.id),
92
+ timestamp: event.time,
93
+ provider: route.provider,
94
+ model: route.model,
95
+ ...amount,
96
+ });
97
+ }
98
+ return {
99
+ sessionId: String(meta.id),
100
+ createdAt: meta.createdAt,
101
+ ...(meta.cwd === undefined ? {} : { cwd: meta.cwd }),
102
+ records: [...samples.values()].sort((left, right) => left.timestamp - right.timestamp),
103
+ };
104
+ }
105
+ export function dateKey(timestamp, timeZone) {
106
+ let formatter = dateFormatterCache.get(timeZone);
107
+ if (formatter === undefined) {
108
+ formatter = new Intl.DateTimeFormat('en-CA', {
109
+ timeZone,
110
+ year: 'numeric',
111
+ month: '2-digit',
112
+ day: '2-digit',
113
+ });
114
+ dateFormatterCache.set(timeZone, formatter);
115
+ }
116
+ const parts = formatter.formatToParts(new Date(timestamp));
117
+ const year = parts.find((part) => part.type === 'year')?.value;
118
+ const month = parts.find((part) => part.type === 'month')?.value;
119
+ const day = parts.find((part) => part.type === 'day')?.value;
120
+ if (year === undefined || month === undefined || day === undefined)
121
+ throw new Error('could not format usage date');
122
+ return `${year}-${month}-${day}`;
123
+ }
124
+ function shiftDate(date, amount) {
125
+ const shifted = new Date(`${date}T00:00:00.000Z`);
126
+ shifted.setUTCDate(shifted.getUTCDate() + amount);
127
+ return shifted.toISOString().slice(0, 10);
128
+ }
129
+ function datesBetween(start, end) {
130
+ const days = Math.max(0, Math.round((Date.parse(`${end}T00:00:00Z`) - Date.parse(`${start}T00:00:00Z`)) / DAY_MS));
131
+ return Array.from({ length: days + 1 }, (_, index) => shiftDate(start, index));
132
+ }
133
+ function wildcardMatches(pattern, value) {
134
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
135
+ return new RegExp(`^${escaped}$`, 'i').test(value);
136
+ }
137
+ export function priceFor(route, pricing) {
138
+ return pricing.find((price) => wildcardMatches(price.route, route));
139
+ }
140
+ function tokensOf(value) {
141
+ return value.input + value.output + value.cacheRead + value.cacheWrite;
142
+ }
143
+ function costOf(value, price) {
144
+ if (price === undefined)
145
+ return 0;
146
+ return (value.input * price.input +
147
+ value.output * price.output +
148
+ value.cacheRead * price.cacheRead +
149
+ value.cacheWrite * price.cacheWrite) / 1_000_000;
150
+ }
151
+ function emptyDay(date) {
152
+ return {
153
+ date,
154
+ input: 0,
155
+ output: 0,
156
+ cacheRead: 0,
157
+ cacheWrite: 0,
158
+ calls: 0,
159
+ sessions: 0,
160
+ cost: 0,
161
+ pricedTokens: 0,
162
+ totalTokens: 0,
163
+ };
164
+ }
165
+ function rangeDays(range) {
166
+ if (range === '30d')
167
+ return 30;
168
+ if (range === '90d')
169
+ return 90;
170
+ if (range === '365d')
171
+ return 365;
172
+ return null;
173
+ }
174
+ export function aggregateUsage(sessions, pricing, range, timeZone, now = Date.now(), errors = 0) {
175
+ const endDate = dateKey(now, timeZone);
176
+ const days = rangeDays(range);
177
+ let earliest = null;
178
+ if (days === null) {
179
+ for (const session of sessions) {
180
+ for (const record of session.records) {
181
+ const date = dateKey(record.timestamp, timeZone);
182
+ if (earliest === null || date < earliest)
183
+ earliest = date;
184
+ }
185
+ }
186
+ }
187
+ const startDate = days === null ? earliest ?? endDate : shiftDate(endDate, -(days - 1));
188
+ const heatmapStart = shiftDate(endDate, -364);
189
+ const allStart = startDate < heatmapStart ? startDate : heatmapStart;
190
+ const daily = new Map(datesBetween(allStart, endDate).map((date) => [date, emptyDay(date)]));
191
+ const dailySessions = new Map();
192
+ const rangeSessionIds = new Set();
193
+ const modelRows = new Map();
194
+ const priceCache = new Map();
195
+ for (const session of sessions) {
196
+ for (const record of session.records) {
197
+ const date = dateKey(record.timestamp, timeZone);
198
+ if (date < allStart || date > endDate)
199
+ continue;
200
+ const route = `${record.provider}/${record.model}`;
201
+ let price = priceCache.get(route);
202
+ if (!priceCache.has(route)) {
203
+ price = priceFor(route, pricing);
204
+ priceCache.set(route, price);
205
+ }
206
+ const totalTokens = tokensOf(record);
207
+ const pricedTokens = price === undefined ? 0 : totalTokens;
208
+ const cost = costOf(record, price);
209
+ const day = daily.get(date);
210
+ if (day !== undefined) {
211
+ day.input += record.input;
212
+ day.output += record.output;
213
+ day.cacheRead += record.cacheRead;
214
+ day.cacheWrite += record.cacheWrite;
215
+ day.calls += 1;
216
+ day.cost += cost;
217
+ day.pricedTokens += pricedTokens;
218
+ day.totalTokens += totalTokens;
219
+ const sessionIds = dailySessions.get(date) ?? new Set();
220
+ sessionIds.add(record.sessionId);
221
+ dailySessions.set(date, sessionIds);
222
+ }
223
+ if (date < startDate)
224
+ continue;
225
+ rangeSessionIds.add(record.sessionId);
226
+ let model = modelRows.get(route);
227
+ if (model === undefined) {
228
+ model = {
229
+ route,
230
+ provider: record.provider,
231
+ model: record.model,
232
+ input: 0,
233
+ output: 0,
234
+ cacheRead: 0,
235
+ cacheWrite: 0,
236
+ calls: 0,
237
+ sessions: 0,
238
+ cost: 0,
239
+ pricedTokens: 0,
240
+ totalTokens: 0,
241
+ sessionIds: new Set(),
242
+ };
243
+ modelRows.set(route, model);
244
+ }
245
+ model.input += record.input;
246
+ model.output += record.output;
247
+ model.cacheRead += record.cacheRead;
248
+ model.cacheWrite += record.cacheWrite;
249
+ model.calls += 1;
250
+ model.cost += cost;
251
+ model.pricedTokens += pricedTokens;
252
+ model.totalTokens += totalTokens;
253
+ model.sessionIds.add(record.sessionId);
254
+ }
255
+ }
256
+ for (const [date, sessionIds] of dailySessions) {
257
+ const day = daily.get(date);
258
+ if (day !== undefined)
259
+ day.sessions = sessionIds.size;
260
+ }
261
+ const trend = [...daily.values()].filter((day) => day.date >= startDate);
262
+ const heatmap = [...daily.values()].filter((day) => day.date >= heatmapStart);
263
+ const models = [...modelRows.values()]
264
+ .map(({ sessionIds, ...model }) => ({ ...model, sessions: sessionIds.size }))
265
+ .sort((left, right) => right.cost - left.cost || right.totalTokens - left.totalTokens);
266
+ const summary = trend.reduce((total, day) => ({
267
+ input: total.input + day.input,
268
+ output: total.output + day.output,
269
+ cacheRead: total.cacheRead + day.cacheRead,
270
+ cacheWrite: total.cacheWrite + day.cacheWrite,
271
+ totalTokens: total.totalTokens + day.totalTokens,
272
+ calls: total.calls + day.calls,
273
+ cost: total.cost + day.cost,
274
+ pricedTokens: total.pricedTokens + day.pricedTokens,
275
+ activeDays: total.activeDays + (day.calls > 0 ? 1 : 0),
276
+ }), { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, calls: 0, cost: 0, pricedTokens: 0, activeDays: 0 });
277
+ return {
278
+ generatedAt: new Date(now).toISOString(),
279
+ range,
280
+ timeZone,
281
+ startDate,
282
+ endDate,
283
+ summary: {
284
+ ...summary,
285
+ sessions: rangeSessionIds.size,
286
+ pricingCoverage: summary.totalTokens === 0 ? 1 : summary.pricedTokens / summary.totalTokens,
287
+ },
288
+ trend,
289
+ heatmap,
290
+ models,
291
+ errors,
292
+ };
293
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { UsageRange, UsageSnapshot } from './types.js';
3
+ interface UsageSnapshotProvider {
4
+ snapshot(range: UsageRange, timeZone: string): Promise<UsageSnapshot>;
5
+ }
6
+ interface HttpLogger {
7
+ warn(message: string, ...args: unknown[]): void;
8
+ }
9
+ export declare function createUsageHttpHandler(service: UsageSnapshotProvider, prefix: string, logger: HttpLogger): (request: IncomingMessage, response: ServerResponse) => Promise<void>;
10
+ export {};
package/dist/http.js ADDED
@@ -0,0 +1,59 @@
1
+ function sendJson(response, status, value) {
2
+ const body = `${JSON.stringify(value)}\n`;
3
+ response.writeHead(status, {
4
+ 'content-type': 'application/json; charset=utf-8',
5
+ 'content-length': Buffer.byteLength(body),
6
+ 'cache-control': 'no-store',
7
+ 'x-content-type-options': 'nosniff',
8
+ });
9
+ response.end(body);
10
+ }
11
+ function validRange(value) {
12
+ if (value === null)
13
+ return '30d';
14
+ if (value === '30d' || value === '90d' || value === '365d' || value === 'all')
15
+ return value;
16
+ throw Object.assign(new Error('range must be one of 30d, 90d, 365d, or all'), { status: 400 });
17
+ }
18
+ function validTimeZone(value) {
19
+ const timeZone = value?.trim() || 'UTC';
20
+ if (timeZone.length > 100)
21
+ throw Object.assign(new Error('timeZone is too long'), { status: 400 });
22
+ try {
23
+ new Intl.DateTimeFormat('en-US', { timeZone }).format();
24
+ }
25
+ catch {
26
+ throw Object.assign(new Error('timeZone is not recognized'), { status: 400 });
27
+ }
28
+ return timeZone;
29
+ }
30
+ export function createUsageHttpHandler(service, prefix, logger) {
31
+ return async (request, response) => {
32
+ try {
33
+ const url = new URL(request.url ?? '/', 'http://dsh.local');
34
+ const relative = url.pathname.slice(prefix.length).replace(/^\/+|\/+$/g, '');
35
+ if (relative !== '')
36
+ return sendJson(response, 404, { error: { code: 'NOT_FOUND', message: 'Usage API route not found.' } });
37
+ if ((request.method ?? 'GET') !== 'GET') {
38
+ response.setHeader('allow', 'GET');
39
+ return sendJson(response, 405, { error: { code: 'METHOD_NOT_ALLOWED', message: 'Allowed method: GET' } });
40
+ }
41
+ const range = validRange(url.searchParams.get('range'));
42
+ const timeZone = validTimeZone(url.searchParams.get('timeZone'));
43
+ return sendJson(response, 200, await service.snapshot(range, timeZone));
44
+ }
45
+ catch (error) {
46
+ const maybeStatus = error.status;
47
+ const status = typeof maybeStatus === 'number' ? maybeStatus : 500;
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ if (status >= 500)
50
+ logger.warn('usage: HTTP request failed: %s', message);
51
+ return sendJson(response, status, {
52
+ error: {
53
+ code: status >= 500 ? 'INTERNAL_ERROR' : 'INVALID_REQUEST',
54
+ message: status >= 500 ? 'Could not build usage analytics.' : message,
55
+ },
56
+ });
57
+ }
58
+ };
59
+ }
@@ -0,0 +1,22 @@
1
+ import { Context, Service } from '@deepseek-ai/cordis';
2
+ import z from '@deepseek-ai/schemastery';
3
+ import type { UsagePluginConfig, UsageRange, UsageSnapshot } from './types.js';
4
+ export * from './aggregate.js';
5
+ export * from './types.js';
6
+ export declare const name = "usage";
7
+ export declare const Config: z<UsagePluginConfig>;
8
+ /** Read-only analytics over the canonical durable Harness session log. */
9
+ export declare class UsageService extends Service {
10
+ static Config: z<UsagePluginConfig>;
11
+ static inject: string[];
12
+ private readonly pricing;
13
+ private readonly scanConcurrency;
14
+ private readonly cache;
15
+ private refreshPromise;
16
+ constructor(ctx: Context, config: UsagePluginConfig);
17
+ [Service.init](): AsyncGenerator<() => void, void, unknown>;
18
+ snapshot(range: UsageRange, timeZone: string): Promise<UsageSnapshot>;
19
+ private refresh;
20
+ private scan;
21
+ }
22
+ export default UsageService;
package/dist/index.js ADDED
@@ -0,0 +1,106 @@
1
+ import { Context, Service } from '@deepseek-ai/cordis';
2
+ import z from '@deepseek-ai/schemastery';
3
+ import { aggregateUsage, DEFAULT_PRICING, extractSessionUsage } from './aggregate.js';
4
+ import { createUsageHttpHandler } from './http.js';
5
+ export * from './aggregate.js';
6
+ export * from './types.js';
7
+ export const name = 'usage';
8
+ const API_PREFIX = '/api/usage';
9
+ const PriceSchema = z.object({
10
+ route: z.string().required(),
11
+ input: z.number().min(0).default(0),
12
+ output: z.number().min(0).default(0),
13
+ cacheRead: z.number().min(0).default(0),
14
+ cacheWrite: z.number().min(0).default(0),
15
+ });
16
+ export const Config = z.object({
17
+ pricing: z.array(PriceSchema).default(DEFAULT_PRICING.map((price) => ({ ...price }))),
18
+ scanConcurrency: z.number().min(1).max(16).default(4),
19
+ });
20
+ /** Read-only analytics over the canonical durable Harness session log. */
21
+ export class UsageService extends Service {
22
+ static Config = Config;
23
+ static inject = ['sessionPersistence', 'webServer'];
24
+ pricing;
25
+ scanConcurrency;
26
+ cache = new Map();
27
+ refreshPromise;
28
+ constructor(ctx, config) {
29
+ super(ctx, 'usage');
30
+ this.pricing = (config.pricing ?? DEFAULT_PRICING).map((price) => ({ ...price }));
31
+ this.scanConcurrency = config.scanConcurrency ?? 4;
32
+ }
33
+ async *[Service.init]() {
34
+ const unregister = this.ctx.webServer.register({
35
+ kind: 'prefix',
36
+ path: API_PREFIX,
37
+ handler: createUsageHttpHandler(this, API_PREFIX, this.ctx.logger),
38
+ });
39
+ yield () => unregister();
40
+ }
41
+ async snapshot(range, timeZone) {
42
+ const { sessions, errors } = await this.refresh();
43
+ return aggregateUsage(sessions, this.pricing, range, timeZone, Date.now(), errors);
44
+ }
45
+ refresh() {
46
+ if (this.refreshPromise !== undefined)
47
+ return this.refreshPromise;
48
+ const operation = this.scan();
49
+ this.refreshPromise = operation;
50
+ void operation.finally(() => {
51
+ if (this.refreshPromise === operation)
52
+ this.refreshPromise = undefined;
53
+ }).catch(() => undefined);
54
+ return operation;
55
+ }
56
+ async scan() {
57
+ const snapshots = await this.ctx.sessionPersistence.listSnapshots();
58
+ const liveIds = new Set(snapshots.map((snapshot) => String(snapshot.header.id)));
59
+ for (const sessionId of this.cache.keys()) {
60
+ if (!liveIds.has(sessionId))
61
+ this.cache.delete(sessionId);
62
+ }
63
+ let errors = 0;
64
+ let cursor = 0;
65
+ const pending = new Map();
66
+ const workers = Array.from({ length: Math.min(this.scanConcurrency, Math.max(1, snapshots.length)) }, async () => {
67
+ while (cursor < snapshots.length) {
68
+ const index = cursor;
69
+ cursor += 1;
70
+ const snapshot = snapshots[index];
71
+ if (snapshot === undefined)
72
+ continue;
73
+ const sessionId = String(snapshot.header.id);
74
+ const revision = String(snapshot.revision);
75
+ const cached = this.cache.get(sessionId);
76
+ if (cached?.revision === revision)
77
+ continue;
78
+ try {
79
+ const stored = await this.ctx.sessionPersistence.readFrom(snapshot.header.id, 0);
80
+ pending.set(sessionId, {
81
+ revision,
82
+ usage: extractSessionUsage(stored.meta, stored.events),
83
+ });
84
+ }
85
+ catch (error) {
86
+ errors += 1;
87
+ this.ctx.logger.warn('usage: could not read durable session %s', sessionId);
88
+ this.ctx.logger.warn(error instanceof Error ? error.stack ?? error.message : String(error));
89
+ }
90
+ }
91
+ });
92
+ await Promise.all(workers);
93
+ const confirmed = await this.ctx.sessionPersistence.listSnapshots();
94
+ const confirmedRevisions = new Map(confirmed.map((snapshot) => [String(snapshot.header.id), String(snapshot.revision)]));
95
+ for (const [sessionId, entry] of pending) {
96
+ if (confirmedRevisions.get(sessionId) === entry.revision)
97
+ this.cache.set(sessionId, entry);
98
+ }
99
+ for (const sessionId of this.cache.keys()) {
100
+ if (!confirmedRevisions.has(sessionId))
101
+ this.cache.delete(sessionId);
102
+ }
103
+ return { sessions: [...this.cache.values()].map((entry) => entry.usage), errors };
104
+ }
105
+ }
106
+ export default UsageService;
@@ -0,0 +1,74 @@
1
+ export type UsageRange = '30d' | '90d' | '365d' | 'all';
2
+ export interface ModelPrice {
3
+ /** Provider/model glob. Only `*` is special. */
4
+ route: string;
5
+ /** USD per one million uncached input tokens. */
6
+ input: number;
7
+ /** USD per one million output tokens. */
8
+ output: number;
9
+ /** USD per one million cache-read tokens. */
10
+ cacheRead: number;
11
+ /** USD per one million cache-write tokens. */
12
+ cacheWrite: number;
13
+ }
14
+ export interface UsagePluginConfig {
15
+ pricing?: ModelPrice[];
16
+ scanConcurrency?: number;
17
+ }
18
+ export interface UsageBuckets {
19
+ input: number;
20
+ output: number;
21
+ cacheRead: number;
22
+ cacheWrite: number;
23
+ }
24
+ export interface UsageRecord extends UsageBuckets {
25
+ sessionId: string;
26
+ timestamp: number;
27
+ provider: string;
28
+ model: string;
29
+ }
30
+ export interface SessionUsage {
31
+ sessionId: string;
32
+ createdAt: number;
33
+ cwd?: string;
34
+ records: UsageRecord[];
35
+ }
36
+ export interface UsageDay extends UsageBuckets {
37
+ date: string;
38
+ calls: number;
39
+ sessions: number;
40
+ cost: number;
41
+ pricedTokens: number;
42
+ totalTokens: number;
43
+ }
44
+ export interface UsageModel extends UsageBuckets {
45
+ route: string;
46
+ provider: string;
47
+ model: string;
48
+ calls: number;
49
+ sessions: number;
50
+ cost: number;
51
+ pricedTokens: number;
52
+ totalTokens: number;
53
+ }
54
+ export interface UsageSummary extends UsageBuckets {
55
+ totalTokens: number;
56
+ calls: number;
57
+ sessions: number;
58
+ activeDays: number;
59
+ cost: number;
60
+ pricedTokens: number;
61
+ pricingCoverage: number;
62
+ }
63
+ export interface UsageSnapshot {
64
+ generatedAt: string;
65
+ range: UsageRange;
66
+ timeZone: string;
67
+ startDate: string;
68
+ endDate: string;
69
+ summary: UsageSummary;
70
+ trend: UsageDay[];
71
+ heatmap: UsageDay[];
72
+ models: UsageModel[];
73
+ errors: number;
74
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/lib/client.js ADDED
@@ -0,0 +1,263 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@syncended/dsh-usage",
3
+ factory: (require) => {
4
+ const module = { exports: {} };
5
+ const exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+
8
+ const React = require("react");
9
+ const h = React.createElement;
10
+ const { useCallback, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } = React;
11
+ const inject = ["slots"];
12
+ const API_PREFIX = "/api/usage";
13
+ const RANGES = [
14
+ { id: "30d", label: "30D" },
15
+ { id: "90d", label: "90D" },
16
+ { id: "365d", label: "1Y" },
17
+ { id: "all", label: "All" },
18
+ ];
19
+ const METRICS = [
20
+ { id: "totalTokens", label: "Tokens" },
21
+ { id: "cost", label: "Cost" },
22
+ { id: "calls", label: "Calls" },
23
+ ];
24
+
25
+ const STYLE_CSS = String.raw`
26
+ .dsh-usage-sidebar{box-sizing:border-box;width:100%;height:42px;display:flex;align-items:center;margin:4px 0 0}
27
+ .dsh-usage-sidebar-button{box-sizing:border-box;appearance:none;width:calc(100% + 4px);height:42px;margin:0 -2px;padding:0 10px 0 8px;border:0;border-radius:12px;background:transparent;color:var(--dsw-alias-label-primary,#101318);display:flex;align-items:center;gap:8px;overflow:hidden;font:500 14px/22px inherit;cursor:pointer}
28
+ .dsh-usage-sidebar-button:hover,.dsh-usage-sidebar-button[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover,rgba(38,49,72,.06))}
29
+ .dsh-usage-sidebar-button:focus-visible,.dsh-usage-button:focus-visible,.dsh-usage-segment button:focus-visible{outline:2px solid var(--dsh-usage-accent,#5d73e6);outline-offset:2px}
30
+ .dsh-usage-sidebar-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
31
+ .dsh-usage-sidebar-rail{width:36px;height:36px;margin:0}.dsh-usage-sidebar-rail .dsh-usage-sidebar-button{width:36px;height:36px;margin:0;padding:0;justify-content:center;border-radius:50%}
32
+ .dsh-usage-workspace{--dsh-usage-accent:#5d73e6;--dsh-usage-accent-2:#8a6de9;--dsh-usage-green:#27a56b;--dsh-usage-amber:#d68a22;--dsh-usage-text:var(--dsw-alias-label-primary,#15171b);--dsh-usage-muted:var(--dsw-alias-label-tertiary,#747984);--dsh-usage-border:var(--dsw-alias-border-l2,rgba(15,17,21,.12));--dsh-usage-card:var(--dsw-alias-bg-layer-2,#fff);--dsh-usage-soft:var(--dsw-alias-bg-layer-1,#f7f8fa);box-sizing:border-box;width:100%;height:100%;min-width:0;min-height:0;display:flex;flex-direction:column;overflow:hidden;color:var(--dsh-usage-text);background:var(--dsw-alias-bg-base,#fff);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
33
+ .dsh-usage-workspace *,.dsh-usage-workspace *::before,.dsh-usage-workspace *::after{box-sizing:border-box}
34
+ .dsh-usage-toolbar{height:54px;flex:none;display:flex;align-items:center;gap:10px;padding:0 18px;border-bottom:1px solid var(--dsh-usage-border);background:color-mix(in srgb,var(--dsw-alias-bg-base,#fff) 90%,transparent);backdrop-filter:blur(14px)}
35
+ .dsh-usage-brand{width:32px;height:32px;display:grid;place-items:center;border-radius:10px;color:#fff;background:linear-gradient(145deg,var(--dsh-usage-accent),var(--dsh-usage-accent-2));box-shadow:0 8px 20px color-mix(in srgb,var(--dsh-usage-accent) 24%,transparent)}
36
+ .dsh-usage-title{min-width:0;flex:1}.dsh-usage-title strong{display:block;font-size:14px;line-height:19px}.dsh-usage-title span{display:block;color:var(--dsh-usage-muted);font-size:11px;line-height:15px}
37
+ .dsh-usage-button{appearance:none;min-height:32px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--dsh-usage-border);border-radius:9px;padding:0 10px;color:var(--dsh-usage-text);background:var(--dsh-usage-card);font:500 12px/18px inherit;cursor:pointer}.dsh-usage-button:hover{background:var(--dsh-usage-soft)}.dsh-usage-button:disabled{cursor:progress;opacity:.55}
38
+ .dsh-usage-scroll{min-height:0;flex:1;overflow:auto;overscroll-behavior:contain;padding:28px clamp(18px,4vw,52px) 52px}
39
+ .dsh-usage-dashboard{width:min(1180px,100%);margin:0 auto;display:flex;flex-direction:column;gap:18px}
40
+ .dsh-usage-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap}.dsh-usage-heading h1{margin:0;font-size:26px;line-height:1.2;letter-spacing:-.035em}.dsh-usage-heading p{margin:5px 0 0;color:var(--dsh-usage-muted);font-size:13px}.dsh-usage-segment{display:inline-flex;gap:2px;padding:3px;border:1px solid var(--dsh-usage-border);border-radius:10px;background:var(--dsh-usage-soft)}.dsh-usage-segment button{appearance:none;height:28px;border:0;border-radius:7px;padding:0 10px;color:var(--dsh-usage-muted);background:transparent;font:600 11px/18px inherit;cursor:pointer}.dsh-usage-segment button[data-active="true"]{color:var(--dsh-usage-text);background:var(--dsh-usage-card);box-shadow:0 1px 3px rgba(15,17,21,.1)}
41
+ .dsh-usage-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}.dsh-usage-card{position:relative;min-width:0;border:1px solid var(--dsh-usage-border);border-radius:16px;padding:16px;background:var(--dsh-usage-card);box-shadow:0 1px 2px color-mix(in srgb,var(--dsh-usage-text) 4%,transparent)}.dsh-usage-card::after{content:"";position:absolute;left:16px;right:16px;bottom:-1px;height:2px;border-radius:2px;background:var(--card-accent,var(--dsh-usage-accent));opacity:.85}.dsh-usage-card-label{display:flex;align-items:center;justify-content:space-between;gap:8px;color:var(--dsh-usage-muted);font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase}.dsh-usage-card-value{margin-top:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:24px;font-weight:650;line-height:30px;letter-spacing:-.035em;font-variant-numeric:tabular-nums}.dsh-usage-card-detail{margin-top:3px;color:var(--dsh-usage-muted);font-size:11px;line-height:17px}
42
+ .dsh-usage-grid{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(260px,.85fr);gap:12px}.dsh-usage-panel{min-width:0;border:1px solid var(--dsh-usage-border);border-radius:16px;padding:17px;background:var(--dsh-usage-card)}.dsh-usage-panel-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:15px}.dsh-usage-panel-title{margin:0;font-size:14px;line-height:20px}.dsh-usage-panel-sub{margin:2px 0 0;color:var(--dsh-usage-muted);font-size:11px;line-height:16px}
43
+ .dsh-usage-chart{width:100%;height:auto;display:block;overflow:visible}.dsh-usage-chart-grid{stroke:var(--dsh-usage-border);stroke-width:1}.dsh-usage-chart-line{fill:none;stroke:var(--dsh-usage-accent);stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}.dsh-usage-chart-dot{fill:var(--dsh-usage-card);stroke:var(--dsh-usage-accent);stroke-width:2}.dsh-usage-chart-label{fill:var(--dsh-usage-muted);font:10px Inter,system-ui,sans-serif}.dsh-usage-chart-empty{height:212px;display:grid;place-items:center;color:var(--dsh-usage-muted);font-size:12px}
44
+ .dsh-usage-mix{display:flex;align-items:center;gap:18px;min-height:215px}.dsh-usage-donut{position:relative;width:132px;height:132px;flex:none;border-radius:50%;background:conic-gradient(var(--dsh-usage-accent) 0 var(--mix-input),var(--dsh-usage-accent-2) var(--mix-input) var(--mix-output),var(--dsh-usage-green) var(--mix-output) var(--mix-cache),var(--dsh-usage-border) var(--mix-cache) 100%)}.dsh-usage-donut::after{content:"";position:absolute;inset:19px;border-radius:50%;background:var(--dsh-usage-card)}.dsh-usage-donut-center{position:absolute;z-index:1;inset:0;display:grid;place-content:center;text-align:center}.dsh-usage-donut-center strong{font-size:18px;line-height:22px}.dsh-usage-donut-center span{color:var(--dsh-usage-muted);font-size:10px}.dsh-usage-legend{min-width:0;flex:1;display:flex;flex-direction:column;gap:10px}.dsh-usage-legend-row{display:grid;grid-template-columns:9px minmax(0,1fr) auto;align-items:center;gap:8px;font-size:11px}.dsh-usage-legend-dot{width:8px;height:8px;border-radius:3px}.dsh-usage-legend-value{font-variant-numeric:tabular-nums;font-weight:600}
45
+ .dsh-usage-heat-wrap{overflow-x:auto;padding-bottom:4px}.dsh-usage-heat-layout{min-width:760px;display:flex;gap:9px}.dsh-usage-day-labels{width:24px;flex:none;display:grid;grid-template-rows:repeat(7,11px);gap:3px;padding-top:0;color:var(--dsh-usage-muted);font-size:8px;line-height:11px}.dsh-usage-heatmap{display:grid;grid-template-rows:repeat(7,11px);grid-auto-flow:column;grid-auto-columns:11px;gap:3px}.dsh-usage-heat-cell{width:11px;height:11px;border-radius:2.5px;background:var(--dsh-usage-heat-0)}.dsh-usage-heat-cell[data-level="1"]{background:color-mix(in srgb,var(--dsh-usage-accent) 28%,var(--dsh-usage-card))}.dsh-usage-heat-cell[data-level="2"]{background:color-mix(in srgb,var(--dsh-usage-accent) 50%,var(--dsh-usage-card))}.dsh-usage-heat-cell[data-level="3"]{background:color-mix(in srgb,var(--dsh-usage-accent) 72%,var(--dsh-usage-card))}.dsh-usage-heat-cell[data-level="4"]{background:var(--dsh-usage-accent)}.dsh-usage-workspace{--dsh-usage-heat-0:color-mix(in srgb,var(--dsh-usage-muted) 10%,transparent)}.dsh-usage-heat-footer{display:flex;justify-content:space-between;gap:12px;margin-top:11px;color:var(--dsh-usage-muted);font-size:10px}.dsh-usage-heat-key{display:flex;align-items:center;gap:4px}.dsh-usage-heat-key i{display:block;width:10px;height:10px;border-radius:2px}
46
+ .dsh-usage-model-list{display:flex;flex-direction:column}.dsh-usage-model-head,.dsh-usage-model-row{display:grid;grid-template-columns:minmax(170px,1.4fr) minmax(170px,1fr) 100px 82px;gap:14px;align-items:center}.dsh-usage-model-head{padding:0 9px 9px;color:var(--dsh-usage-muted);font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.dsh-usage-model-row{min-height:58px;padding:9px;border-top:1px solid var(--dsh-usage-border);font-size:11px}.dsh-usage-model-name{min-width:0}.dsh-usage-model-name strong{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.dsh-usage-model-name span{display:block;margin-top:2px;color:var(--dsh-usage-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dsh-usage-model-meter{height:5px;margin-top:6px;border-radius:3px;background:var(--dsh-usage-soft);overflow:hidden}.dsh-usage-model-meter i{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,var(--dsh-usage-accent),var(--dsh-usage-accent-2))}.dsh-usage-num{text-align:right;font-variant-numeric:tabular-nums}.dsh-usage-unpriced{color:var(--dsh-usage-amber);font-size:9px}
47
+ .dsh-usage-notice{border:1px solid color-mix(in srgb,var(--dsh-usage-amber) 30%,var(--dsh-usage-border));border-radius:12px;padding:10px 12px;color:var(--dsh-usage-muted);background:color-mix(in srgb,var(--dsh-usage-amber) 7%,transparent);font-size:11px;line-height:17px}.dsh-usage-loading,.dsh-usage-error{min-height:360px;display:grid;place-content:center;justify-items:center;gap:12px;color:var(--dsh-usage-muted);text-align:center}.dsh-usage-spinner{width:24px;height:24px;border:2px solid var(--dsh-usage-border);border-top-color:var(--dsh-usage-accent);border-radius:50%;animation:dsh-usage-spin .8s linear infinite}@keyframes dsh-usage-spin{to{transform:rotate(360deg)}}
48
+ @media(max-width:900px){.dsh-usage-cards{grid-template-columns:repeat(2,minmax(0,1fr))}.dsh-usage-grid{grid-template-columns:1fr}.dsh-usage-mix{min-height:170px}.dsh-usage-model-head,.dsh-usage-model-row{grid-template-columns:minmax(160px,1.3fr) minmax(150px,1fr) 90px}.dsh-usage-model-head>:last-child,.dsh-usage-model-row>:last-child{display:none}}
49
+ @media(max-width:560px){.dsh-usage-toolbar{padding:0 10px}.dsh-usage-title span{display:none}.dsh-usage-button-label{display:none}.dsh-usage-scroll{padding:20px 12px 36px}.dsh-usage-heading{align-items:flex-start;flex-direction:column}.dsh-usage-heading h1{font-size:22px}.dsh-usage-cards{grid-template-columns:1fr 1fr;gap:8px}.dsh-usage-card{padding:13px}.dsh-usage-card-value{font-size:20px}.dsh-usage-panel{padding:14px}.dsh-usage-mix{align-items:flex-start;flex-direction:column}.dsh-usage-donut{align-self:center}.dsh-usage-model-head{display:none}.dsh-usage-model-row{grid-template-columns:minmax(0,1fr) auto;gap:10px}.dsh-usage-model-row>:nth-child(2){display:none}}
50
+ `;
51
+
52
+ function UsageGlyph({ size = 18 }) {
53
+ return h("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" },
54
+ h("path", { d: "M4 19V9m5 10V5m5 14v-7m5 7V3" }),
55
+ h("path", { d: "M2.5 19.5h18" }),
56
+ );
57
+ }
58
+
59
+ function RefreshGlyph() {
60
+ return h("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true" }, h("path", { d: "M20 6v5h-5M4 18v-5h5" }), h("path", { d: "M18.5 9A7 7 0 0 0 6 6.5L4 9m2 6a7 7 0 0 0 12 2.5L20 15" }));
61
+ }
62
+
63
+ function createDisclosureStore() {
64
+ let open = false;
65
+ const listeners = new Set();
66
+ const notify = () => listeners.forEach((listener) => listener());
67
+ return {
68
+ getSnapshot: () => open,
69
+ subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
70
+ toggle() { open = !open; notify(); },
71
+ close() { if (open) { open = false; notify(); } },
72
+ dispose() { open = false; listeners.clear(); },
73
+ };
74
+ }
75
+
76
+ function formatCompact(value) {
77
+ if (!Number.isFinite(value)) return "—";
78
+ return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: value >= 1000000 ? 1 : 0 }).format(value);
79
+ }
80
+ function formatCost(value) {
81
+ if (!Number.isFinite(value)) return "—";
82
+ if (value === 0) return "$0.00";
83
+ if (value < .01) return "<$0.01";
84
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: "USD", maximumFractionDigits: value < 100 ? 2 : 0 }).format(value);
85
+ }
86
+ function formatMetric(value, metric) {
87
+ if (metric === "cost") return formatCost(value);
88
+ return formatCompact(value);
89
+ }
90
+ function formatDate(date) {
91
+ try { return new Date(date + "T00:00:00Z").toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); }
92
+ catch { return date; }
93
+ }
94
+ function rangeDescription(snapshot) {
95
+ if (!snapshot) return "Durable session analytics";
96
+ return formatDate(snapshot.startDate) + " – " + formatDate(snapshot.endDate) + " · " + snapshot.timeZone;
97
+ }
98
+
99
+ function Segment({ values, selected, onChange, label }) {
100
+ return h("div", { className: "dsh-usage-segment", role: "group", "aria-label": label }, values.map((value) => h("button", { key: value.id, type: "button", "data-active": value.id === selected ? "true" : undefined, "aria-pressed": value.id === selected, onClick: () => onChange(value.id) }, value.label)));
101
+ }
102
+
103
+ function StatCard({ label, value, detail, accent }) {
104
+ return h("article", { className: "dsh-usage-card", style: { "--card-accent": accent } },
105
+ h("div", { className: "dsh-usage-card-label" }, label),
106
+ h("div", { className: "dsh-usage-card-value", title: value }, value),
107
+ h("div", { className: "dsh-usage-card-detail" }, detail),
108
+ );
109
+ }
110
+
111
+ function TrendChart({ days, metric }) {
112
+ const width = 760, height = 220, left = 48, right = 10, top = 12, bottom = 28;
113
+ const values = days.map((day) => Number(day[metric]) || 0);
114
+ const max = Math.max(1, ...values);
115
+ if (!days.length) return h("div", { className: "dsh-usage-chart-empty" }, "No usage in this range");
116
+ const x = (index) => left + (days.length === 1 ? (width - left - right) / 2 : index * (width - left - right) / (days.length - 1));
117
+ const y = (value) => top + (height - top - bottom) * (1 - value / max);
118
+ const points = values.map((value, index) => [x(index), y(value)]);
119
+ const line = points.map((point, index) => (index === 0 ? "M" : "L") + point[0].toFixed(2) + " " + point[1].toFixed(2)).join(" ");
120
+ const area = line + " L " + x(days.length - 1) + " " + (height - bottom) + " L " + x(0) + " " + (height - bottom) + " Z";
121
+ const labels = [0, Math.floor((days.length - 1) / 2), days.length - 1].filter((value, index, all) => all.indexOf(value) === index);
122
+ return h("svg", { className: "dsh-usage-chart", viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": "Usage trend" },
123
+ h("defs", null, h("linearGradient", { id: "dsh-usage-area", x1: "0", y1: "0", x2: "0", y2: "1" }, h("stop", { offset: "0%", stopColor: "var(--dsh-usage-accent)", stopOpacity: ".28" }), h("stop", { offset: "100%", stopColor: "var(--dsh-usage-accent)", stopOpacity: "0" }))),
124
+ [0, .5, 1].map((ratio) => h("g", { key: ratio }, h("line", { className: "dsh-usage-chart-grid", x1: left, x2: width - right, y1: y(max * ratio), y2: y(max * ratio) }), h("text", { className: "dsh-usage-chart-label", x: left - 8, y: y(max * ratio) + 3, textAnchor: "end" }, formatMetric(max * ratio, metric)))),
125
+ h("path", { d: area, fill: "url(#dsh-usage-area)" }),
126
+ h("path", { d: line, className: "dsh-usage-chart-line" }),
127
+ points.length <= 45 ? points.map((point, index) => h("circle", { key: index, className: "dsh-usage-chart-dot", cx: point[0], cy: point[1], r: 2.7 }, h("title", null, days[index].date + ": " + formatMetric(values[index], metric)))) : null,
128
+ labels.map((index) => h("text", { key: index, className: "dsh-usage-chart-label", x: x(index), y: height - 6, textAnchor: index === 0 ? "start" : index === days.length - 1 ? "end" : "middle" }, formatDate(days[index].date))),
129
+ );
130
+ }
131
+
132
+ function TokenMix({ summary }) {
133
+ const input = summary.input || 0, output = summary.output || 0, cache = (summary.cacheRead || 0) + (summary.cacheWrite || 0);
134
+ const total = Math.max(1, input + output + cache);
135
+ const inputEnd = input / total * 100;
136
+ const outputEnd = (input + output) / total * 100;
137
+ const cacheEnd = (input + output + cache) / total * 100;
138
+ const rows = [
139
+ { label: "Input", value: input, color: "var(--dsh-usage-accent)" },
140
+ { label: "Output", value: output, color: "var(--dsh-usage-accent-2)" },
141
+ { label: "Cache", value: cache, color: "var(--dsh-usage-green)" },
142
+ ];
143
+ return h("div", { className: "dsh-usage-mix" },
144
+ h("div", { className: "dsh-usage-donut", style: { "--mix-input": inputEnd + "%", "--mix-output": outputEnd + "%", "--mix-cache": cacheEnd + "%" } }, h("div", { className: "dsh-usage-donut-center" }, h("strong", null, formatCompact(summary.totalTokens)), h("span", null, "tokens"))),
145
+ h("div", { className: "dsh-usage-legend" }, rows.map((row) => h("div", { key: row.label, className: "dsh-usage-legend-row" }, h("i", { className: "dsh-usage-legend-dot", style: { background: row.color } }), h("span", null, row.label), h("span", { className: "dsh-usage-legend-value" }, formatCompact(row.value))))),
146
+ );
147
+ }
148
+
149
+ function Heatmap({ days }) {
150
+ const max = Math.max(1, ...days.map((day) => day.totalTokens || 0));
151
+ const firstOffset = days.length ? new Date(days[0].date + "T00:00:00Z").getUTCDay() : 0;
152
+ const cells = Array.from({ length: firstOffset }, (_, index) => h("i", { key: "blank-" + index }));
153
+ for (const day of days) {
154
+ const ratio = Math.log1p(day.totalTokens || 0) / Math.log1p(max);
155
+ const level = day.totalTokens === 0 ? 0 : Math.max(1, Math.ceil(ratio * 4));
156
+ cells.push(h("i", { key: day.date, className: "dsh-usage-heat-cell", "data-level": level }, h("title", null, `${day.date}: ${formatCompact(day.totalTokens)} tokens · ${day.calls} calls · ${formatCost(day.cost)}`)));
157
+ }
158
+ return h(React.Fragment, null,
159
+ h("div", { className: "dsh-usage-heat-wrap" }, h("div", { className: "dsh-usage-heat-layout" }, h("div", { className: "dsh-usage-day-labels", "aria-hidden": "true" }, h("span"), h("span", null, "M"), h("span"), h("span", null, "W"), h("span"), h("span", null, "F"), h("span")), h("div", { className: "dsh-usage-heatmap", role: "img", "aria-label": "Usage activity over the last year" }, cells))),
160
+ h("div", { className: "dsh-usage-heat-footer" }, h("span", null, days.filter((day) => day.calls > 0).length + " active days in the last year"), h("span", { className: "dsh-usage-heat-key" }, "Less", [0,1,2,3,4].map((level) => h("i", { key: level, className: "dsh-usage-heat-cell", "data-level": level })), "More")),
161
+ );
162
+ }
163
+
164
+ function ModelTable({ models }) {
165
+ const max = Math.max(1, ...models.map((model) => model.totalTokens));
166
+ return h("div", { className: "dsh-usage-model-list" },
167
+ h("div", { className: "dsh-usage-model-head" }, h("span", null, "Model"), h("span", null, "Volume"), h("span", { className: "dsh-usage-num" }, "Calls"), h("span", { className: "dsh-usage-num" }, "Cost")),
168
+ models.length ? models.map((model) => h("div", { key: model.route, className: "dsh-usage-model-row" },
169
+ h("div", { className: "dsh-usage-model-name" }, h("strong", { title: model.model }, model.model), h("span", { title: model.provider }, model.provider + " · " + model.sessions + " sessions")),
170
+ h("div", null, h("div", null, formatCompact(model.totalTokens) + " tokens"), h("div", { className: "dsh-usage-model-meter" }, h("i", { style: { width: (model.totalTokens / max * 100).toFixed(1) + "%" } }))),
171
+ h("div", { className: "dsh-usage-num" }, formatCompact(model.calls)),
172
+ h("div", { className: "dsh-usage-num" }, model.pricedTokens ? formatCost(model.cost) : h("span", { className: "dsh-usage-unpriced", title: "Add a pricing rule for this route" }, "UNPRICED")),
173
+ )) : h("div", { className: "dsh-usage-chart-empty" }, "No model usage in this range"),
174
+ );
175
+ }
176
+
177
+ function Dashboard({ snapshot, range, setRange, metric, setMetric }) {
178
+ const summary = snapshot.summary;
179
+ const coverage = Math.round(summary.pricingCoverage * 100);
180
+ return h("div", { className: "dsh-usage-dashboard" },
181
+ h("div", { className: "dsh-usage-heading" }, h("div", null, h("h1", null, "Usage overview"), h("p", null, rangeDescription(snapshot))), h(Segment, { values: RANGES, selected: range, onChange: setRange, label: "Analytics range" })),
182
+ h("section", { className: "dsh-usage-cards", "aria-label": "Usage summary" },
183
+ h(StatCard, { label: "Estimated spend", value: formatCost(summary.cost), detail: coverage + "% of tokens priced", accent: "var(--dsh-usage-green)" }),
184
+ h(StatCard, { label: "Total tokens", value: formatCompact(summary.totalTokens), detail: formatCompact(summary.output) + " output · " + formatCompact(summary.cacheRead) + " cache read", accent: "var(--dsh-usage-accent)" }),
185
+ h(StatCard, { label: "Model calls", value: formatCompact(summary.calls), detail: summary.sessions + " sessions", accent: "var(--dsh-usage-accent-2)" }),
186
+ h(StatCard, { label: "Active days", value: String(summary.activeDays), detail: snapshot.trend.length + " calendar days", accent: "var(--dsh-usage-amber)" }),
187
+ ),
188
+ h("section", { className: "dsh-usage-grid" },
189
+ h("article", { className: "dsh-usage-panel" }, h("header", { className: "dsh-usage-panel-head" }, h("div", null, h("h2", { className: "dsh-usage-panel-title" }, "Usage trend"), h("p", { className: "dsh-usage-panel-sub" }, "Provider-reported durable usage")), h(Segment, { values: METRICS, selected: metric, onChange: setMetric, label: "Chart metric" })), h(TrendChart, { days: snapshot.trend, metric })),
190
+ h("article", { className: "dsh-usage-panel" }, h("header", { className: "dsh-usage-panel-head" }, h("div", null, h("h2", { className: "dsh-usage-panel-title" }, "Token mix"), h("p", { className: "dsh-usage-panel-sub" }, "Input, output, and cache"))), h(TokenMix, { summary })),
191
+ ),
192
+ h("article", { className: "dsh-usage-panel" }, h("header", { className: "dsh-usage-panel-head" }, h("div", null, h("h2", { className: "dsh-usage-panel-title" }, "Activity"), h("p", { className: "dsh-usage-panel-sub" }, "A year of Harness model activity"))), h(Heatmap, { days: snapshot.heatmap })),
193
+ h("article", { className: "dsh-usage-panel" }, h("header", { className: "dsh-usage-panel-head" }, h("div", null, h("h2", { className: "dsh-usage-panel-title" }, "Models"), h("p", { className: "dsh-usage-panel-sub" }, "Usage and estimated cost by provider route"))), h(ModelTable, { models: snapshot.models })),
194
+ coverage < 100 ? h("div", { className: "dsh-usage-notice" }, "Cost is an estimate. " + (100 - coverage) + "% of tokens use routes without a matching pricing rule. Override pricing in the usage row of cordis.patch.yml for exact internal or negotiated rates.") : null,
195
+ snapshot.errors ? h("div", { className: "dsh-usage-notice" }, snapshot.errors + " session logs could not be read. Their last cached values are shown when available.") : null,
196
+ );
197
+ }
198
+
199
+ function UsageWorkspace({ disclosure }) {
200
+ const titleId = useId();
201
+ const rootRef = useRef(null);
202
+ const [range, setRange] = useState("30d");
203
+ const [metric, setMetric] = useState("totalTokens");
204
+ const [snapshot, setSnapshot] = useState(null);
205
+ const [error, setError] = useState(null);
206
+ const [refreshKey, setRefreshKey] = useState(0);
207
+ const [loading, setLoading] = useState(true);
208
+ const timeZone = useMemo(() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; } catch { return "UTC"; } }, []);
209
+ const refresh = useCallback(() => setRefreshKey((value) => value + 1), []);
210
+
211
+ useEffect(() => {
212
+ const controller = new AbortController();
213
+ setLoading(true); setError(null); setSnapshot(null);
214
+ fetch(API_PREFIX + "?range=" + encodeURIComponent(range) + "&timeZone=" + encodeURIComponent(timeZone), { signal: controller.signal })
215
+ .then(async (response) => {
216
+ const body = await response.json().catch(() => null);
217
+ if (!response.ok) throw new Error(body?.error?.message || "Usage request failed (" + response.status + ")");
218
+ return body;
219
+ })
220
+ .then((body) => { setSnapshot(body); setLoading(false); })
221
+ .catch((cause) => { if (cause.name !== "AbortError") { setError(cause instanceof Error ? cause.message : String(cause)); setLoading(false); } });
222
+ return () => controller.abort();
223
+ }, [range, timeZone, refreshKey]);
224
+
225
+ useEffect(() => {
226
+ const frame = requestAnimationFrame(() => rootRef.current?.querySelector('[data-dsh-usage-exit="true"]')?.focus());
227
+ const keydown = (event) => { if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); disclosure.close(); } };
228
+ window.addEventListener("keydown", keydown);
229
+ return () => { cancelAnimationFrame(frame); window.removeEventListener("keydown", keydown); };
230
+ }, [disclosure]);
231
+
232
+ return h("section", { ref: rootRef, className: "dsh-usage-workspace", "aria-labelledby": titleId },
233
+ h("header", { className: "dsh-usage-toolbar" }, h("span", { className: "dsh-usage-brand" }, h(UsageGlyph, { size: 17 })), h("div", { className: "dsh-usage-title" }, h("strong", { id: titleId }, "Usage"), h("span", null, snapshot ? "Updated " + new Date(snapshot.generatedAt).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) : "Reading durable sessions")), h("button", { type: "button", className: "dsh-usage-button", disabled: loading, onClick: refresh }, h(RefreshGlyph), h("span", { className: "dsh-usage-button-label" }, "Refresh")), h("button", { type: "button", className: "dsh-usage-button", "data-dsh-usage-exit": "true", onClick: disclosure.close }, "Close")),
234
+ h("div", { className: "dsh-usage-scroll" }, loading && !snapshot ? h("div", { className: "dsh-usage-loading" }, h("i", { className: "dsh-usage-spinner" }), h("span", null, "Scanning session usage…")) : error && !snapshot ? h("div", { className: "dsh-usage-error" }, h("strong", null, "Usage unavailable"), h("span", null, error), h("button", { type: "button", className: "dsh-usage-button", onClick: refresh }, "Try again")) : h(Dashboard, { snapshot, range, setRange, metric, setMetric })),
235
+ );
236
+ }
237
+
238
+ function SidebarAction({ wide, disclosure }) {
239
+ const open = useSyncExternalStore(disclosure.subscribe, disclosure.getSnapshot, disclosure.getSnapshot);
240
+ return h("div", { className: "dsh-usage-sidebar" + (wide ? "" : " dsh-usage-sidebar-rail") }, h("button", { type: "button", className: "dsh-usage-sidebar-button", title: wide ? undefined : "Usage", "aria-label": open ? "Close Usage" : "Open Usage", "aria-pressed": open, "data-active": open ? "true" : undefined, onClick: disclosure.toggle }, h(UsageGlyph, { size: wide ? 16 : 18 }), wide ? h("span", { className: "dsh-usage-sidebar-label" }, "Usage") : null));
241
+ }
242
+
243
+ function apply(ctx) {
244
+ const disclosure = createDisclosureStore();
245
+ let centerDeclared = false;
246
+ let disposeCenter = null;
247
+ const unmount = () => { if (disposeCenter) { const dispose = disposeCenter; disposeCenter = null; dispose(); } };
248
+ const mount = () => {
249
+ if (!centerDeclared || !disclosure.getSnapshot() || disposeCenter) return;
250
+ try { disposeCenter = ctx.slots.register({ name: "conversation", priority: -190, inject: () => ({ disclosure }) }, UsageWorkspace); }
251
+ catch (error) { console.error("dsh usage: could not mount center workspace", error); disclosure.close(); }
252
+ };
253
+ ctx.effect(() => { const tag = document.createElement("style"); tag.setAttribute("data-plugin", "@syncended/dsh-usage"); tag.textContent = STYLE_CSS; document.head.appendChild(tag); return () => tag.remove(); }, "@syncended/dsh-usage: client styles");
254
+ ctx.effect(() => ctx.slots.inject("conversation", () => { centerDeclared = true; mount(); return () => { centerDeclared = false; unmount(); }; }), "@syncended/dsh-usage: center workspace");
255
+ ctx.effect(() => { const unsubscribe = disclosure.subscribe(() => disclosure.getSnapshot() ? mount() : unmount()); return () => { unsubscribe(); unmount(); disclosure.dispose(); }; }, "@syncended/dsh-usage: workspace state");
256
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({ name: "sidebar.footer.action", id: "usage", order: 45, label: "Usage", inject: () => ({ disclosure }) }, SidebarAction));
257
+ }
258
+
259
+ exports.inject = inject;
260
+ exports.apply = apply;
261
+ return module.exports;
262
+ },
263
+ });
package/package.json ADDED
@@ -0,0 +1,108 @@
1
+ {
2
+ "name": "@syncended/dsh-usage",
3
+ "version": "0.1.0",
4
+ "description": "Token usage, model cost analytics, trends, and activity heatmaps for DeepSeek Harness",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./client": "./lib/client.js",
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "lib/client.js",
20
+ "cordis.patch.yml",
21
+ "README.md",
22
+ "RELEASING.md",
23
+ "LICENSE"
24
+ ],
25
+ "dsh": {
26
+ "bundle": {
27
+ "patch": "./cordis.patch.yml"
28
+ },
29
+ "client": {
30
+ "inject": [
31
+ "@deepseek-ai/dsh-client-runtime",
32
+ "@deepseek-ai/dsh-client-ui-layout",
33
+ "@deepseek-ai/dsh-client-ui-conversation",
34
+ "@deepseek-ai/dsh-client-ui-sidebar",
35
+ "@deepseek-ai/dsh-client-ui-primitives"
36
+ ],
37
+ "platform": "web"
38
+ }
39
+ },
40
+ "scripts": {
41
+ "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
42
+ "build": "pnpm clean && tsc -p tsconfig.json && node --check lib/client.js",
43
+ "typecheck": "tsc -p tsconfig.json --noEmit",
44
+ "test": "pnpm build && node --test test/*.test.mjs",
45
+ "check": "pnpm typecheck && pnpm test",
46
+ "prepare": "pnpm build"
47
+ },
48
+ "keywords": [
49
+ "deepseek",
50
+ "deepseek-harness",
51
+ "dsh",
52
+ "usage",
53
+ "tokens",
54
+ "cost",
55
+ "analytics",
56
+ "heatmap"
57
+ ],
58
+ "author": "syncended",
59
+ "license": "MIT",
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "git+https://github.com/syncended/deepseek-harness-usage.git"
63
+ },
64
+ "bugs": {
65
+ "url": "https://github.com/syncended/deepseek-harness-usage/issues"
66
+ },
67
+ "homepage": "https://github.com/syncended/deepseek-harness-usage#readme",
68
+ "engines": {
69
+ "node": ">=22"
70
+ },
71
+ "packageManager": "pnpm@11.22.0",
72
+ "publishConfig": {
73
+ "access": "public"
74
+ },
75
+ "dependencies": {
76
+ "@deepseek-ai/schemastery": "^3.18.1"
77
+ },
78
+ "peerDependencies": {
79
+ "@deepseek-ai/cordis": "^4.0.1",
80
+ "@deepseek-ai/dsh-compaction": "^0.1.1-rc.2",
81
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
82
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.1-rc.2",
83
+ "@deepseek-ai/dsh-client-ui-layout": "^0.1.1-rc.2",
84
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.1-rc.2",
85
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.1-rc.2",
86
+ "@deepseek-ai/dsh-host-webserver": "^0.1.1-rc.2",
87
+ "@deepseek-ai/dsh-session": "^0.1.1-rc.2",
88
+ "@deepseek-ai/dsh-session-persistence": "^0.1.1-rc.2",
89
+ "react": "^18.2.0",
90
+ "react-dom": "^18.2.0"
91
+ },
92
+ "devDependencies": {
93
+ "@deepseek-ai/cordis": "4.0.1",
94
+ "@deepseek-ai/dsh-compaction": "0.1.1-rc.2",
95
+ "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
96
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.2",
97
+ "@deepseek-ai/dsh-client-ui-layout": "0.1.1-rc.2",
98
+ "@deepseek-ai/dsh-client-ui-primitives": "0.1.1-rc.2",
99
+ "@deepseek-ai/dsh-client-ui-sidebar": "0.1.1-rc.2",
100
+ "@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
101
+ "@deepseek-ai/dsh-session": "0.1.1-rc.2",
102
+ "@deepseek-ai/dsh-session-persistence": "0.1.1-rc.2",
103
+ "@types/node": "^22.0.0",
104
+ "react": "^18.2.0",
105
+ "react-dom": "^18.2.0",
106
+ "typescript": "^5.9.3"
107
+ }
108
+ }