dsh-token-chart 0.0.0-stage → 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 +21 -0
- package/README.md +200 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/buckets.js +115 -0
- package/lib/chunk/echarts.entry.js +25 -0
- package/lib/client.echarts.js +58 -0
- package/lib/client.js +1743 -0
- package/lib/constants.js +22 -0
- package/lib/index.js +640 -0
- package/lib/paths.js +24 -0
- package/lib/prefs.js +54 -0
- package/lib/store.js +273 -0
- package/lib/token-chart.css +568 -0
- package/lib/types/buckets.d.ts +24 -0
- package/lib/types/chunk/echarts.entry.d.ts +11 -0
- package/lib/types/constants.d.ts +22 -0
- package/lib/types/index.d.ts +8 -0
- package/lib/types/paths.d.ts +3 -0
- package/lib/types/prefs.d.ts +8 -0
- package/lib/types/store.d.ts +72 -0
- package/lib/types/types.d.ts +230 -0
- package/lib/types/update.d.ts +24 -0
- package/lib/types.js +10 -0
- package/lib/update.js +110 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +110 -4
- package/src/buckets.ts +120 -0
- package/src/chunk/echarts.entry.ts +27 -0
- package/src/client/api.ts +126 -0
- package/src/client/charts.tsx +255 -0
- package/src/client/chrome.tsx +127 -0
- package/src/client/contract.ts +62 -0
- package/src/client/drawer.tsx +113 -0
- package/src/client/format.ts +36 -0
- package/src/client/globals.d.ts +21 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +164 -0
- package/src/client/models.ts +76 -0
- package/src/client/mount.tsx +312 -0
- package/src/client/panel.tsx +152 -0
- package/src/client/pill.ts +21 -0
- package/src/client/react.ts +12 -0
- package/src/client/series.tsx +140 -0
- package/src/client/settings.tsx +347 -0
- package/src/client/styles/components.less +191 -0
- package/src/client/styles/index.less +7 -0
- package/src/client/styles/tailwind.css +21 -0
- package/src/client/styles/tokens.less +25 -0
- package/src/client/table.tsx +153 -0
- package/src/client/ui.tsx +87 -0
- package/src/constants.ts +26 -0
- package/src/index.ts +673 -0
- package/src/paths.ts +28 -0
- package/src/prefs.ts +61 -0
- package/src/store.ts +295 -0
- package/src/types.ts +216 -0
- package/src/update.ts +106 -0
package/lib/constants.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Values both halves and every Host module agree on.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-token-chart/constants
|
|
5
|
+
*/
|
|
6
|
+
/** The plugin's package name: the client entry id and the artifact key. */
|
|
7
|
+
export const PACKAGE_NAME = 'dsh-token-chart';
|
|
8
|
+
/** Retention window: only the newest 7 calendar days are kept on disk. */
|
|
9
|
+
export const RETENTION_DAYS = 7;
|
|
10
|
+
/** Store schema version; a document from another version is rebuilt from logs. */
|
|
11
|
+
export const STORE_VERSION = 2;
|
|
12
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
/** Browser half and Host half agree on this prefix; it is part of the package contract. */
|
|
14
|
+
export const API_PREFIX = '/token-chart/api';
|
|
15
|
+
export const SAVE_DEBOUNCE_MS = 1200;
|
|
16
|
+
export const BACKFILL_SESSION_LIMIT = 40;
|
|
17
|
+
/** Fallback registry for the update check when the profile names none. */
|
|
18
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmmirror.com/';
|
|
19
|
+
export const UPDATE_CHECK_TTL_MS = 30 * 60 * 1000;
|
|
20
|
+
export const UPDATE_FETCH_TIMEOUT_MS = 12000;
|
|
21
|
+
export const METRIC_IDS = ['input', 'output', 'cache', 'total'];
|
|
22
|
+
export const CHART_IDS = ['bar', 'line', 'pie', 'table', 'multi-bar', 'multi-line', 'bar-line'];
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-chart — Host half entry.
|
|
3
|
+
*
|
|
4
|
+
* Collects durable provider-reported token usage per Session and Turn from the
|
|
5
|
+
* committed session-event feed, keeps a rolling 7-day store on disk, and serves
|
|
6
|
+
* the browser half's JSON API over the Harness web server.
|
|
7
|
+
*
|
|
8
|
+
* The fold mirrors `@deepseek-ai/dsh-token-meter`'s `tokenUsage` projection but
|
|
9
|
+
* stores one slot per settled `(step, attempt)`, which is what makes replaying a
|
|
10
|
+
* session log idempotent (see `store.ts`).
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-token-chart
|
|
13
|
+
*/
|
|
14
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { API_PREFIX, BACKFILL_SESSION_LIMIT, PACKAGE_NAME, RETENTION_DAYS, SAVE_DEBOUNCE_MS, UPDATE_CHECK_TTL_MS, } from './constants.js';
|
|
20
|
+
import { addInto, dayKey, derived, startOfLocalDay, timeLabel, zeroBuckets } from './buckets.js';
|
|
21
|
+
import { DEFAULT_PREFS, normalizePrefs } from './prefs.js';
|
|
22
|
+
import { dataDir, prefsFile, usageFile } from './paths.js';
|
|
23
|
+
import { UsageStore } from './store.js';
|
|
24
|
+
import { compareVersions, fetchLatestVersion, profileRegistry, readOwnManifest } from './update.js';
|
|
25
|
+
export const name = PACKAGE_NAME;
|
|
26
|
+
// Kept on the entry so the published surface stays stable for callers and for
|
|
27
|
+
// the Host test harness, which exercises the comparison directly.
|
|
28
|
+
export { compareVersions, fetchLatestVersion, readOwnManifest } from './update.js';
|
|
29
|
+
function sendJson(res, status, payload) {
|
|
30
|
+
const body = JSON.stringify(payload);
|
|
31
|
+
res.statusCode = status;
|
|
32
|
+
res.setHeader('content-type', 'application/json; charset=utf-8');
|
|
33
|
+
res.setHeader('cache-control', 'no-store');
|
|
34
|
+
res.end(body);
|
|
35
|
+
}
|
|
36
|
+
async function readJsonBody(req) {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
let size = 0;
|
|
39
|
+
for await (const chunk of req) {
|
|
40
|
+
size += chunk.length;
|
|
41
|
+
if (size > 256 * 1024)
|
|
42
|
+
throw new Error('request body too large');
|
|
43
|
+
chunks.push(chunk);
|
|
44
|
+
}
|
|
45
|
+
if (chunks.length === 0)
|
|
46
|
+
return {};
|
|
47
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
48
|
+
if (text.trim() === '')
|
|
49
|
+
return {};
|
|
50
|
+
const parsed = JSON.parse(text);
|
|
51
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {};
|
|
52
|
+
}
|
|
53
|
+
/* -------------------------------------------------------------------- apply */
|
|
54
|
+
/**
|
|
55
|
+
* Mount the collector, the store, and the browser API.
|
|
56
|
+
* @param ctx - the plugin's Cordis context.
|
|
57
|
+
*/
|
|
58
|
+
export function apply(ctx) {
|
|
59
|
+
const store = new UsageStore();
|
|
60
|
+
let prefs = { ...DEFAULT_PREFS };
|
|
61
|
+
let prefsLoaded = false;
|
|
62
|
+
let saveTimer = null;
|
|
63
|
+
let saving = null;
|
|
64
|
+
/** Everything the API needs from optional services. */
|
|
65
|
+
const deps = { query: undefined, sessions: undefined, projections: undefined };
|
|
66
|
+
/** Plugin-manager face, present only when the profile ships that plugin. */
|
|
67
|
+
let manager;
|
|
68
|
+
const log = (...args) => {
|
|
69
|
+
try {
|
|
70
|
+
ctx.logger?.info?.(...args);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
/* a logger that refuses is not a failure of this plugin */
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const warn = (...args) => {
|
|
77
|
+
try {
|
|
78
|
+
ctx.logger?.warn?.(...args);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
/* ignore */
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const persist = async () => {
|
|
85
|
+
if (saveTimer !== null) {
|
|
86
|
+
clearTimeout(saveTimer);
|
|
87
|
+
saveTimer = null;
|
|
88
|
+
}
|
|
89
|
+
if (saving !== null)
|
|
90
|
+
return saving;
|
|
91
|
+
saving = (async () => {
|
|
92
|
+
try {
|
|
93
|
+
store.prune();
|
|
94
|
+
await mkdir(dataDir(), { recursive: true });
|
|
95
|
+
const target = usageFile();
|
|
96
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
97
|
+
await writeFile(temporary, JSON.stringify(store.toJSON()), 'utf8');
|
|
98
|
+
await rename(temporary, target);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
warn('dsh-token-chart: could not persist usage store (%s)', String(error));
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
saving = null;
|
|
105
|
+
}
|
|
106
|
+
})();
|
|
107
|
+
return saving;
|
|
108
|
+
};
|
|
109
|
+
const scheduleSave = () => {
|
|
110
|
+
if (saveTimer !== null)
|
|
111
|
+
return;
|
|
112
|
+
saveTimer = setTimeout(() => {
|
|
113
|
+
saveTimer = null;
|
|
114
|
+
void persist();
|
|
115
|
+
}, SAVE_DEBOUNCE_MS);
|
|
116
|
+
};
|
|
117
|
+
/* ---- load state ------------------------------------------------------ */
|
|
118
|
+
/**
|
|
119
|
+
* Resolves once the persisted document has been read.
|
|
120
|
+
*
|
|
121
|
+
* Every route that touches preferences waits on it: reading and writing the
|
|
122
|
+
* file is asynchronous, so without this a request arriving right after a
|
|
123
|
+
* reload would answer with defaults and could overwrite the user's choices.
|
|
124
|
+
*/
|
|
125
|
+
let markPrefsReady = () => { };
|
|
126
|
+
const prefsReady = new Promise((resolve) => {
|
|
127
|
+
markPrefsReady = resolve;
|
|
128
|
+
});
|
|
129
|
+
ctx.effect(() => {
|
|
130
|
+
let cancelled = false;
|
|
131
|
+
void (async () => {
|
|
132
|
+
try {
|
|
133
|
+
const raw = await readFile(usageFile(), 'utf8');
|
|
134
|
+
if (cancelled)
|
|
135
|
+
return;
|
|
136
|
+
const accepted = store.load(JSON.parse(raw));
|
|
137
|
+
if (!accepted)
|
|
138
|
+
log('dsh-token-chart: existing store is from another schema — rebuilding from session logs');
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* first run, or a store we refuse to trust: start empty */
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const raw = await readFile(prefsFile(), 'utf8');
|
|
145
|
+
if (cancelled)
|
|
146
|
+
return;
|
|
147
|
+
prefs = normalizePrefs(JSON.parse(raw));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* defaults */
|
|
151
|
+
}
|
|
152
|
+
prefsLoaded = true;
|
|
153
|
+
markPrefsReady();
|
|
154
|
+
log('dsh-token-chart: store ready (%d session(s))', store.sessions.size);
|
|
155
|
+
})();
|
|
156
|
+
return () => {
|
|
157
|
+
cancelled = true;
|
|
158
|
+
};
|
|
159
|
+
}, 'dsh-token-chart: load store');
|
|
160
|
+
/* ---- live feed ------------------------------------------------------- */
|
|
161
|
+
ctx.effect(() => ctx.on('session/event', (session, event) => {
|
|
162
|
+
try {
|
|
163
|
+
const sessionId = session?.id;
|
|
164
|
+
if (typeof sessionId !== 'string' || sessionId === '')
|
|
165
|
+
return;
|
|
166
|
+
if (store.apply(sessionId, event))
|
|
167
|
+
scheduleSave();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
warn('dsh-token-chart: usage fold failed (%s)', String(error));
|
|
171
|
+
}
|
|
172
|
+
}), 'dsh-token-chart: session usage feed');
|
|
173
|
+
/* ---- optional services ---------------------------------------------- */
|
|
174
|
+
ctx.inject(['sessionQuery'], (injected) => {
|
|
175
|
+
deps.query = injected.sessionQuery;
|
|
176
|
+
void backfill();
|
|
177
|
+
return () => {
|
|
178
|
+
deps.query = undefined;
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
// Used only to cross-check our fold against the product's own projection.
|
|
182
|
+
ctx.inject(['sessions', 'sessionProjections'], (injected) => {
|
|
183
|
+
deps.sessions = injected.sessions;
|
|
184
|
+
deps.projections = injected.sessionProjections;
|
|
185
|
+
return () => {
|
|
186
|
+
deps.sessions = undefined;
|
|
187
|
+
deps.projections = undefined;
|
|
188
|
+
};
|
|
189
|
+
});
|
|
190
|
+
// The plugin manager is only needed by the "更新版本" action; a profile
|
|
191
|
+
// without it simply never offers an automatic update.
|
|
192
|
+
ctx.inject(['pluginManager'], (injected) => {
|
|
193
|
+
manager = injected.pluginManager;
|
|
194
|
+
return () => {
|
|
195
|
+
manager = undefined;
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
/**
|
|
199
|
+
* Seed the store from session logs written before this plugin was mounted,
|
|
200
|
+
* so "today" is complete on first sight. Bounded to the newest sessions and
|
|
201
|
+
* run in the background: a slow corpus never delays activation.
|
|
202
|
+
*/
|
|
203
|
+
async function backfill() {
|
|
204
|
+
const query = deps.query;
|
|
205
|
+
if (query === undefined)
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
const records = await query.listSessions();
|
|
209
|
+
const floor = startOfLocalDay(RETENTION_DAYS - 1);
|
|
210
|
+
let scanned = 0;
|
|
211
|
+
let seeded = 0;
|
|
212
|
+
for (const record of records) {
|
|
213
|
+
if (scanned >= BACKFILL_SESSION_LIMIT)
|
|
214
|
+
break;
|
|
215
|
+
const id = record?.header?.id;
|
|
216
|
+
if (typeof id !== 'string' || id === '')
|
|
217
|
+
continue;
|
|
218
|
+
scanned += 1;
|
|
219
|
+
try {
|
|
220
|
+
const snapshot = await query.readSession(id);
|
|
221
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
222
|
+
let newest = 0;
|
|
223
|
+
let touched = false;
|
|
224
|
+
for (const event of events) {
|
|
225
|
+
if (typeof event?.time === 'number' && event.time > newest)
|
|
226
|
+
newest = event.time;
|
|
227
|
+
if (store.apply(id, event))
|
|
228
|
+
touched = true;
|
|
229
|
+
}
|
|
230
|
+
if (touched && newest >= floor)
|
|
231
|
+
seeded += 1;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* one unreadable log must not stop the sweep */
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const pruned = store.prune();
|
|
238
|
+
if (seeded > 0 || pruned)
|
|
239
|
+
await persist();
|
|
240
|
+
log('dsh-token-chart: backfilled %d session(s) from %d scanned', seeded, scanned);
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
warn('dsh-token-chart: backfill skipped (%s)', String(error));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/* ---- cross-session reads -------------------------------------------- */
|
|
247
|
+
/**
|
|
248
|
+
* Read one session's turns, falling back to its durable log when the session
|
|
249
|
+
* predates this plugin's mount.
|
|
250
|
+
* @param sessionId - the session to describe.
|
|
251
|
+
*/
|
|
252
|
+
async function sessionReport(sessionId) {
|
|
253
|
+
let turnRows = store.sessionTurns(sessionId);
|
|
254
|
+
if (turnRows.length === 0 && deps.query !== undefined) {
|
|
255
|
+
try {
|
|
256
|
+
const snapshot = await deps.query.readSession(sessionId);
|
|
257
|
+
const events = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
258
|
+
for (const event of events)
|
|
259
|
+
store.apply(sessionId, event);
|
|
260
|
+
store.prune();
|
|
261
|
+
turnRows = store.sessionTurns(sessionId);
|
|
262
|
+
if (turnRows.length > 0)
|
|
263
|
+
scheduleSave();
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
warn('dsh-token-chart: session read failed (%s)', String(error));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// The session total is one fold over every settled (turn, step, attempt) of
|
|
270
|
+
// the session — deliberately not the sum of the per-turn rows below, so a
|
|
271
|
+
// presentation-level change can never redefine what the total means.
|
|
272
|
+
const totals = store.sessionTotals(sessionId);
|
|
273
|
+
let first = 0;
|
|
274
|
+
let last = 0;
|
|
275
|
+
const routes = new Set();
|
|
276
|
+
const turns = turnRows.map((row) => {
|
|
277
|
+
if (first === 0 || row.time < first)
|
|
278
|
+
first = row.time;
|
|
279
|
+
if (row.time > last)
|
|
280
|
+
last = row.time;
|
|
281
|
+
for (const route of row.routes)
|
|
282
|
+
routes.add(route);
|
|
283
|
+
return {
|
|
284
|
+
turn: row.turn,
|
|
285
|
+
time: row.time,
|
|
286
|
+
label: timeLabel(row.time),
|
|
287
|
+
routes: row.routes,
|
|
288
|
+
...derived(row.totals),
|
|
289
|
+
};
|
|
290
|
+
});
|
|
291
|
+
const sessionTotal = derived(totals, {
|
|
292
|
+
turns: turns.length,
|
|
293
|
+
firstTime: first,
|
|
294
|
+
lastTime: last,
|
|
295
|
+
routes: [...routes],
|
|
296
|
+
});
|
|
297
|
+
return {
|
|
298
|
+
sessionId,
|
|
299
|
+
turns,
|
|
300
|
+
totals: sessionTotal,
|
|
301
|
+
crossCheck: productCrossCheck(sessionId, sessionTotal.total),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Compare our total with the product's own `tokenUsage` projection.
|
|
306
|
+
*
|
|
307
|
+
* The two are independent folds of the same log, so a mismatch means one of
|
|
308
|
+
* them is wrong — worth surfacing instead of hiding. Only live sessions have a
|
|
309
|
+
* projection to read; historical ones report `unavailable`.
|
|
310
|
+
* @param sessionId - the session being reported.
|
|
311
|
+
* @param total - our own total for it.
|
|
312
|
+
*/
|
|
313
|
+
function productCrossCheck(sessionId, total) {
|
|
314
|
+
const projections = deps.projections;
|
|
315
|
+
const sessions = deps.sessions;
|
|
316
|
+
if (projections === undefined || sessions === undefined)
|
|
317
|
+
return { source: 'unavailable' };
|
|
318
|
+
try {
|
|
319
|
+
const session = sessions.get(sessionId);
|
|
320
|
+
if (session === undefined)
|
|
321
|
+
return { source: 'unavailable' };
|
|
322
|
+
const state = projections.stateOf(session, 'tokenUsage');
|
|
323
|
+
if (state === undefined || state.totals === undefined)
|
|
324
|
+
return { source: 'unavailable' };
|
|
325
|
+
const { uncachedInputTokens, outputTokens, cacheReadTokens, cacheWriteTokens } = state.totals;
|
|
326
|
+
const productTotal = uncachedInputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
|
|
327
|
+
return { source: 'sessionProjection', total: productTotal, matches: productTotal === total };
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
warn('dsh-token-chart: projection cross-check skipped (%s)', String(error));
|
|
331
|
+
return { source: 'unavailable' };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* One report over a calendar window.
|
|
336
|
+
* @param range - `today`, `3d`, or `7d`.
|
|
337
|
+
*/
|
|
338
|
+
function overviewReport(range) {
|
|
339
|
+
const days = range === '3d' ? 3 : range === '7d' ? RETENTION_DAYS : 1;
|
|
340
|
+
const floor = startOfLocalDay(days - 1);
|
|
341
|
+
const rows = store.turns().filter((row) => row.lastTime >= floor);
|
|
342
|
+
const totals = zeroBuckets();
|
|
343
|
+
const perDay = new Map();
|
|
344
|
+
for (let offset = days - 1; offset >= 0; offset -= 1) {
|
|
345
|
+
const key = dayKey(startOfLocalDay(offset));
|
|
346
|
+
perDay.set(key, { key, buckets: zeroBuckets(), turns: 0, sessions: new Set() });
|
|
347
|
+
}
|
|
348
|
+
const points = [];
|
|
349
|
+
const sessions = new Set();
|
|
350
|
+
for (const row of rows) {
|
|
351
|
+
addInto(totals, row.totals);
|
|
352
|
+
sessions.add(row.sessionId);
|
|
353
|
+
const key = dayKey(row.time);
|
|
354
|
+
const bucket = perDay.get(key);
|
|
355
|
+
if (bucket !== undefined) {
|
|
356
|
+
addInto(bucket.buckets, row.totals);
|
|
357
|
+
bucket.turns += 1;
|
|
358
|
+
bucket.sessions.add(row.sessionId);
|
|
359
|
+
}
|
|
360
|
+
points.push({
|
|
361
|
+
time: row.time,
|
|
362
|
+
label: days === 1 ? timeLabel(row.time) : key.slice(5),
|
|
363
|
+
day: key,
|
|
364
|
+
sessionId: row.sessionId,
|
|
365
|
+
turn: row.turn,
|
|
366
|
+
...derived(row.totals),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
range,
|
|
371
|
+
days,
|
|
372
|
+
floor,
|
|
373
|
+
generatedAt: Date.now(),
|
|
374
|
+
retentionDays: RETENTION_DAYS,
|
|
375
|
+
totals: derived(totals, { turns: rows.length, sessions: sessions.size }),
|
|
376
|
+
buckets: [...perDay.values()].map((entry) => ({
|
|
377
|
+
key: entry.key,
|
|
378
|
+
label: entry.key.slice(5),
|
|
379
|
+
turns: entry.turns,
|
|
380
|
+
sessions: entry.sessions.size,
|
|
381
|
+
...derived(entry.buckets),
|
|
382
|
+
})),
|
|
383
|
+
points,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
/* ---- prefs ----------------------------------------------------------- */
|
|
387
|
+
async function savePrefs() {
|
|
388
|
+
try {
|
|
389
|
+
await mkdir(dataDir(), { recursive: true });
|
|
390
|
+
const target = prefsFile();
|
|
391
|
+
const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
392
|
+
await writeFile(temporary, JSON.stringify(prefs, null, 2), 'utf8');
|
|
393
|
+
await rename(temporary, target);
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
warn('dsh-token-chart: could not persist preferences (%s)', String(error));
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/* ---- version and update check ---------------------------------------- */
|
|
400
|
+
/** Cached registry answer plus the state of a running update. */
|
|
401
|
+
const updateCheck = { at: 0, latest: undefined, status: 'unknown', registry: '' };
|
|
402
|
+
const updateRun = { phase: 'idle', startedAt: 0, finishedAt: 0, message: '', error: '' };
|
|
403
|
+
let manifest = { name, version: '0.0.0' };
|
|
404
|
+
void (async () => {
|
|
405
|
+
try {
|
|
406
|
+
manifest = await readOwnManifest();
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
warn('dsh-token-chart: could not read own manifest (%s)', String(error));
|
|
410
|
+
}
|
|
411
|
+
})();
|
|
412
|
+
/**
|
|
413
|
+
* How this profile installed the plugin. Only a registry install can update
|
|
414
|
+
* itself: a `link:`/`file:` dependency and an unmounted checkout (the row is
|
|
415
|
+
* mounted from a patch layer, which is how this plugin usually runs) must be
|
|
416
|
+
* updated where they are developed.
|
|
417
|
+
*/
|
|
418
|
+
function installKind() {
|
|
419
|
+
const dir = process.env.DSH_PROFILE_DIR;
|
|
420
|
+
if (typeof dir === 'string' && dir.trim() !== '') {
|
|
421
|
+
try {
|
|
422
|
+
const profile = JSON.parse(readFileSync(path.join(dir.trim(), 'package.json'), 'utf8'));
|
|
423
|
+
const spec = String(profile?.dependencies?.[manifest.name] ?? '');
|
|
424
|
+
if (spec.startsWith('link:') || spec.startsWith('file:'))
|
|
425
|
+
return { kind: 'local', spec };
|
|
426
|
+
if (spec !== '')
|
|
427
|
+
return { kind: 'registry', spec };
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
/* fall through to the checkout probe */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
// No profile dependency: distinguish a checkout from an unknown install.
|
|
434
|
+
try {
|
|
435
|
+
const own = fileURLToPath(import.meta.url);
|
|
436
|
+
return own.includes(`${path.sep}node_modules${path.sep}`)
|
|
437
|
+
? { kind: 'unknown', spec: '' }
|
|
438
|
+
: { kind: 'local', spec: own };
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return { kind: 'unknown', spec: '' };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** Refresh the registry answer at most once per TTL. */
|
|
445
|
+
async function checkForUpdate(force = false) {
|
|
446
|
+
const registry = profileRegistry();
|
|
447
|
+
if (!force && updateCheck.at !== 0 && Date.now() - updateCheck.at < UPDATE_CHECK_TTL_MS)
|
|
448
|
+
return updateCheck;
|
|
449
|
+
updateCheck.at = Date.now();
|
|
450
|
+
updateCheck.registry = registry;
|
|
451
|
+
try {
|
|
452
|
+
const latest = await fetchLatestVersion(manifest.name, registry);
|
|
453
|
+
updateCheck.latest = latest;
|
|
454
|
+
updateCheck.status = latest === undefined ? 'unpublished' : 'ok';
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
updateCheck.latest = undefined;
|
|
458
|
+
updateCheck.status = 'error';
|
|
459
|
+
warn('dsh-token-chart: update check failed (%s)', String(error));
|
|
460
|
+
}
|
|
461
|
+
return updateCheck;
|
|
462
|
+
}
|
|
463
|
+
function versionReport() {
|
|
464
|
+
const latest = updateCheck.latest;
|
|
465
|
+
return {
|
|
466
|
+
name: manifest.name,
|
|
467
|
+
version: manifest.version,
|
|
468
|
+
registry: updateCheck.registry || profileRegistry(),
|
|
469
|
+
status: updateCheck.status,
|
|
470
|
+
latest: latest ?? null,
|
|
471
|
+
updateAvailable: latest !== undefined && compareVersions(manifest.version, latest) < 0,
|
|
472
|
+
checkedAt: updateCheck.at,
|
|
473
|
+
install: installKind(),
|
|
474
|
+
run: updateRun,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** Every response goes through the shared `{ ok, value }` envelope. */
|
|
478
|
+
const routes = {
|
|
479
|
+
'GET /health': () => ({
|
|
480
|
+
ok: true,
|
|
481
|
+
package: name,
|
|
482
|
+
retentionDays: RETENTION_DAYS,
|
|
483
|
+
sessions: store.sessions.size,
|
|
484
|
+
prefsLoaded,
|
|
485
|
+
backfill: deps.query !== undefined,
|
|
486
|
+
}),
|
|
487
|
+
'GET /prefs': async () => {
|
|
488
|
+
await prefsReady;
|
|
489
|
+
return { ok: true, value: prefs, defaults: DEFAULT_PREFS };
|
|
490
|
+
},
|
|
491
|
+
'GET /overview': (params) => ({ ok: true, value: overviewReport(params.get('range') ?? 'today') }),
|
|
492
|
+
'GET /session': async (params) => {
|
|
493
|
+
const sessionId = params.get('sessionId') ?? params.get('id') ?? '';
|
|
494
|
+
if (sessionId === '') {
|
|
495
|
+
return { ok: false, error: { code: 'bad-request', message: 'sessionId is required' } };
|
|
496
|
+
}
|
|
497
|
+
return { ok: true, value: await sessionReport(sessionId) };
|
|
498
|
+
},
|
|
499
|
+
'POST /prefs': async (_params, body) => {
|
|
500
|
+
await prefsReady;
|
|
501
|
+
const patch = body?.patch ?? {};
|
|
502
|
+
prefs = normalizePrefs({ ...prefs, ...patch });
|
|
503
|
+
await savePrefs();
|
|
504
|
+
return { ok: true, value: prefs };
|
|
505
|
+
},
|
|
506
|
+
'POST /reset': async () => {
|
|
507
|
+
store.sessions.clear();
|
|
508
|
+
await persist();
|
|
509
|
+
return { ok: true };
|
|
510
|
+
},
|
|
511
|
+
'GET /version': async (params) => {
|
|
512
|
+
await checkForUpdate(params.get('refresh') === '1');
|
|
513
|
+
return { ok: true, value: versionReport() };
|
|
514
|
+
},
|
|
515
|
+
'POST /update': async () => {
|
|
516
|
+
await checkForUpdate();
|
|
517
|
+
const report = versionReport();
|
|
518
|
+
if (!report.updateAvailable) {
|
|
519
|
+
return { ok: false, error: { code: 'up-to-date', message: `${report.name} is already at ${report.version}` } };
|
|
520
|
+
}
|
|
521
|
+
if (report.install.kind !== 'registry') {
|
|
522
|
+
updateRun.phase = 'manual';
|
|
523
|
+
updateRun.message = `installed from ${report.install.spec || 'a local path'}; update it where it is developed`;
|
|
524
|
+
return {
|
|
525
|
+
ok: false,
|
|
526
|
+
error: {
|
|
527
|
+
code: 'local-install',
|
|
528
|
+
message: updateRun.message,
|
|
529
|
+
command: `dsh plugin --profile ${process.env.DSH_PROFILE ?? '<profile>'} add ${report.name}@${report.latest}`,
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (manager === undefined) {
|
|
534
|
+
return {
|
|
535
|
+
ok: false,
|
|
536
|
+
error: { code: 'no-plugin-manager', message: 'the plugin manager is not available in this profile' },
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
if (updateRun.phase === 'running') {
|
|
540
|
+
return { ok: false, error: { code: 'already-running', message: 'an update is already running' } };
|
|
541
|
+
}
|
|
542
|
+
updateRun.phase = 'running';
|
|
543
|
+
updateRun.startedAt = Date.now();
|
|
544
|
+
updateRun.finishedAt = 0;
|
|
545
|
+
updateRun.message = `installing ${report.name}@${report.latest}`;
|
|
546
|
+
updateRun.error = '';
|
|
547
|
+
void Promise.resolve()
|
|
548
|
+
.then(() => manager?.installBundle(`${report.name}@${report.latest}`))
|
|
549
|
+
.then((result) => {
|
|
550
|
+
updateRun.phase = 'done';
|
|
551
|
+
updateRun.finishedAt = Date.now();
|
|
552
|
+
updateRun.message = `application: ${String(result?.application ?? 'unknown')}`;
|
|
553
|
+
updateCheck.at = 0;
|
|
554
|
+
})
|
|
555
|
+
.catch((error) => {
|
|
556
|
+
updateRun.phase = 'failed';
|
|
557
|
+
updateRun.finishedAt = Date.now();
|
|
558
|
+
updateRun.error = String(error?.message ?? error);
|
|
559
|
+
updateRun.message = 'update failed';
|
|
560
|
+
});
|
|
561
|
+
return { ok: true, value: { started: true, target: `${report.name}@${report.latest}` } };
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
/* ---- stylesheet ------------------------------------------------------ */
|
|
565
|
+
/**
|
|
566
|
+
* Serve the compiled stylesheet as a real file.
|
|
567
|
+
*
|
|
568
|
+
* The browser half links it instead of carrying CSS text, so a style change
|
|
569
|
+
* never invalidates the client bundle. The path is resolved next to this
|
|
570
|
+
* module (`lib/index.js` → `lib/token-chart.css`), with `ETag`-based
|
|
571
|
+
* revalidation so a rebuild is picked up without hard-coding a version.
|
|
572
|
+
*/
|
|
573
|
+
async function readStylesheet() {
|
|
574
|
+
try {
|
|
575
|
+
return await readFile(new URL('./token-chart.css', import.meta.url), 'utf8');
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
warn('dsh-token-chart: stylesheet missing (%s)', String(error));
|
|
579
|
+
return undefined;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
ctx.inject(['webServer'], (injected) => {
|
|
583
|
+
return injected.webServer.register({
|
|
584
|
+
kind: 'exact',
|
|
585
|
+
path: '/token-chart/assets/token-chart.css',
|
|
586
|
+
handler: async (_req, res) => {
|
|
587
|
+
const css = await readStylesheet();
|
|
588
|
+
if (css === undefined) {
|
|
589
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'stylesheet is not built' } });
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const etag = `"${createHash('sha1').update(css).digest('hex').slice(0, 12)}"`;
|
|
593
|
+
res.statusCode = 200;
|
|
594
|
+
res.setHeader('content-type', 'text/css; charset=utf-8');
|
|
595
|
+
res.setHeader('cache-control', 'no-cache');
|
|
596
|
+
res.setHeader('etag', etag);
|
|
597
|
+
res.end(css);
|
|
598
|
+
},
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
ctx.inject(['webServer'], (injected) => {
|
|
602
|
+
const webServer = injected.webServer;
|
|
603
|
+
return webServer.register({
|
|
604
|
+
kind: 'prefix',
|
|
605
|
+
path: API_PREFIX,
|
|
606
|
+
handler: async (req, res) => {
|
|
607
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
608
|
+
let pathname = '/';
|
|
609
|
+
try {
|
|
610
|
+
pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname;
|
|
611
|
+
}
|
|
612
|
+
catch {
|
|
613
|
+
sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'malformed url' } });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const suffix = pathname.startsWith(API_PREFIX) ? pathname.slice(API_PREFIX.length) : pathname;
|
|
617
|
+
const search = new URL(req.url ?? '/', 'http://dsh.internal').searchParams;
|
|
618
|
+
const resolve = (m) => routes[`${m} ${suffix}`] ?? routes[`${m} ${suffix}/`];
|
|
619
|
+
const handler = resolve(method) ?? (method === 'HEAD' ? resolve('GET') : undefined);
|
|
620
|
+
if (handler === undefined) {
|
|
621
|
+
sendJson(res, 404, { ok: false, error: { code: 'not-found', message: `no route for ${method} ${suffix}` } });
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const body = method === 'POST' ? await readJsonBody(req) : undefined;
|
|
626
|
+
const payload = (await handler(search, body));
|
|
627
|
+
sendJson(res, payload.ok === false ? 400 : 200, payload);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
warn('dsh-token-chart: api failure (%s)', String(error));
|
|
631
|
+
sendJson(res, 500, { ok: false, error: { code: 'internal', message: String(error) } });
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
ctx.effect(() => () => {
|
|
637
|
+
void persist();
|
|
638
|
+
}, 'dsh-token-chart: final flush');
|
|
639
|
+
log('dsh-token-chart: host half active');
|
|
640
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk locations, resolved the way `@deepseek-ai/dsh-home-paths` does.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-token-chart/paths
|
|
5
|
+
*/
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
/** Resolve the Harness home exactly like `@deepseek-ai/dsh-home-paths`. */
|
|
9
|
+
function resolveDshHome() {
|
|
10
|
+
const configured = process.env.DSH_HOME;
|
|
11
|
+
if (typeof configured === 'string' && configured.trim() !== '')
|
|
12
|
+
return configured.trim();
|
|
13
|
+
return path.join(homedir(), '.dsh');
|
|
14
|
+
}
|
|
15
|
+
export function dataDir() {
|
|
16
|
+
return path.join(resolveDshHome(), 'storages', 'dsh-token-chart');
|
|
17
|
+
}
|
|
18
|
+
export function usageFile() {
|
|
19
|
+
return path.join(dataDir(), 'usage.json');
|
|
20
|
+
}
|
|
21
|
+
export function prefsFile() {
|
|
22
|
+
return path.join(dataDir(), 'prefs.json');
|
|
23
|
+
}
|
|
24
|
+
/* ------------------------------------------------------------ usage model */
|