@lyk308/dsh-token-dashboard 0.1.3 → 0.1.4

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/lib/index.js CHANGED
@@ -139,8 +139,11 @@ function zstdDecompressToText(buf) {
139
139
  return Buffer.concat(parts).toString("utf8");
140
140
  }
141
141
 
142
- /** Normalize a token-usage event into our source-neutral shape. */
143
- function usagesFromEvent(obj, acc, currentModel, timeMs) {
142
+ /** Normalize a token-usage event into our source-neutral shape.
143
+ * @param {boolean} inRange - false skips aggregation (used for window filtering).
144
+ */
145
+ function usagesFromEvent(obj, acc, currentModel, timeMs, inRange) {
146
+ if (inRange === false) return;
144
147
  if (!obj || typeof obj !== "object") return;
145
148
  const data = obj.data;
146
149
  if (!data) return;
@@ -199,24 +202,37 @@ function findSessionLogs(root) {
199
202
  return out;
200
203
  }
201
204
 
202
- /** Aggregate token stats across all session logs under `root`. */
203
- function aggregateTokens(root) {
205
+ /** Aggregate token stats across all session logs under `root`.
206
+ * @param {string} root - sessions directory.
207
+ * @param {number|null} days - if set, byModel/byWorkspace/byDay/bySession are
208
+ * filtered to events within the last `days` days, while total/cost/today/
209
+ * thisMonth remain full-window (those are cardinal period facts).
210
+ */
211
+ function aggregateTokens(root, days) {
204
212
  const acc = {
205
213
  total: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, cost: 0,
206
214
  events: 0, req: 0, cacheHits: 0, sessions: 0, files: 0,
207
- byModel: {}, byDay: {}, byWorkspace: {}
215
+ byModel: {}, byDay: {}, byWorkspace: {}, bySession: {}
208
216
  };
209
217
  const files = findSessionLogs(root);
210
218
  acc.files = files.length;
211
219
  const now = new Date();
212
220
  const todayKey = now.toISOString().slice(0, 10);
213
221
  const monthKey = todayKey.slice(0, 7);
222
+ // Range cutoff (epoch ms): events older than this are excluded from the
223
+ // per-group aggregates when `days` is provided. null = no filtering.
224
+ const cutoff = (days && days > 0) ? Date.now() - days * 86400 * 1000 : null;
214
225
  for (const file of files) {
215
226
  let bytes;
216
227
  try { bytes = readFileSync(file); } catch { continue; }
217
228
  const text = file.endsWith(".zstd") ? zstdDecompressToText(bytes) : bytes.toString("utf8");
218
229
  if (!text) continue;
219
230
  acc.sessions += 1;
231
+ // Session id: the parent directory name of each session.jsonl[.zstd].
232
+ // Path shape: .../sessions/<workspace>/<session-id>/session.jsonl[.zstd]
233
+ let sessionId = basename(dirname(file));
234
+ let sRec = acc.bySession[sessionId];
235
+ if (sRec === undefined) { sRec = acc.bySession[sessionId] = { id: sessionId, total: 0, cost: 0, req: 0, first: null, last: null, model: null, workspace: "unknown" }; }
220
236
  const lines = text.split("\n");
221
237
  let ws = "unknown";
222
238
  let currentModel = null;
@@ -224,7 +240,7 @@ function aggregateTokens(root) {
224
240
  if (!line) continue;
225
241
  let o;
226
242
  try { o = JSON.parse(line); } catch { continue; }
227
- if (o.type === "session" && o.cwd) ws = o.cwd;
243
+ if (o.type === "session" && o.cwd) { ws = o.cwd; sRec.workspace = ws; const t0 = o.createdAt; if (t0 && (sRec.first === null || t0 < sRec.first)) sRec.first = t0; }
228
244
  // Track the active model. DSH records it on:
229
245
  // - request/header: data.header.config.model (authoritative)
230
246
  // - request/context: data.model
@@ -232,6 +248,7 @@ function aggregateTokens(root) {
232
248
  if (o.type === "request/header") {
233
249
  const m = o.data?.header?.config?.model || o.data?.model || null;
234
250
  if (m) currentModel = m;
251
+ if (m && (sRec.model === null || currentModel)) sRec.model = m;
235
252
  } else if (o.type === "assistant/message") {
236
253
  const m = o.data?.usage?.model || o.data?.model || null;
237
254
  if (m) currentModel = m;
@@ -239,6 +256,10 @@ function aggregateTokens(root) {
239
256
  const m = o.data?.model || null;
240
257
  if (m) currentModel = m;
241
258
  }
259
+ // Whether this event falls within the requested window (computed once so
260
+ // both the usageBlock branch and usagesFromEvent can use it).
261
+ const evT = o.time ?? o.createdAt;
262
+ const inRange = cutoff === null || (evT && evT >= cutoff);
242
263
  // Per-day + per-workspace accumulation must happen per event BEFORE the
243
264
  // generic usagesFromEvent bump, so we mirror the same broken-down shape.
244
265
  const usageBlock = o.data?.chunk?.usage;
@@ -252,8 +273,9 @@ function aggregateTokens(root) {
252
273
  const day = t ? new Date(t).toISOString().slice(0, 10) : null;
253
274
  // cost of this single event (peak/off-peak aware, model-aware)
254
275
  const cost = costOf({ input: inTok, output: outTok, cacheRead: cTok, reasoning: rTok }, t, currentModel || "unknown");
255
- acc.cost += cost;
256
- if (day !== null) {
276
+ // Whether this event is within the requested window (reuse outer inRange).
277
+ if (inRange) acc.cost += cost;
278
+ if (day !== null && inRange) {
257
279
  let d = acc.byDay[day];
258
280
  if (d === undefined) { d = acc.byDay[day] = { total: 0, input: 0, output: 0, cacheRead: 0, cost: 0 }; }
259
281
  d.total += sum;
@@ -261,21 +283,33 @@ function aggregateTokens(root) {
261
283
  d.output += outTok;
262
284
  d.cacheRead += cTok;
263
285
  d.cost += cost;
286
+ }
287
+ // today / thisMonth are cardinal period facts (全量, independent of window)
288
+ if (day !== null) {
264
289
  if (day === todayKey) { acc.today = (acc.today || 0) + sum; acc.todayCost = (acc.todayCost || 0) + cost; }
265
290
  if (day.slice(0, 7) === monthKey) { acc.thisMonth = (acc.thisMonth || 0) + sum; acc.thisMonthCost = (acc.thisMonthCost || 0) + cost; }
266
291
  }
267
292
  // per-workspace broken-down counters
268
- let w = acc.byWorkspace[ws];
269
- if (w === undefined) { w = acc.byWorkspace[ws] = { req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0, cost: 0 }; }
270
- w.req += 1;
271
- w.input += inTok;
272
- w.output += outTok;
273
- w.cacheRead += cTok;
274
- w.reasoning += rTok;
275
- w.total += sum;
276
- w.cost += cost;
293
+ if (inRange) {
294
+ let w = acc.byWorkspace[ws];
295
+ if (w === undefined) { w = acc.byWorkspace[ws] = { req: 0, input: 0, output: 0, cacheRead: 0, reasoning: 0, total: 0, cost: 0 }; }
296
+ w.req += 1;
297
+ w.input += inTok;
298
+ w.output += outTok;
299
+ w.cacheRead += cTok;
300
+ w.reasoning += rTok;
301
+ w.total += sum;
302
+ w.cost += cost;
303
+ // per-session accumulation
304
+ sRec.total += sum;
305
+ sRec.cost += cost;
306
+ sRec.req += 1;
307
+ sRec.model = currentModel || sRec.model;
308
+ const evTime = o.time ?? o.createdAt;
309
+ if (evTime) { if (sRec.first === null || evTime < sRec.first) sRec.first = evTime; if (sRec.last === null || evTime > sRec.last) sRec.last = evTime; }
310
+ }
277
311
  }
278
- usagesFromEvent(o, acc, currentModel, o.time ?? o.createdAt);
312
+ usagesFromEvent(o, acc, currentModel, o.time ?? o.createdAt, inRange);
279
313
  }
280
314
  }
281
315
  return acc;
@@ -328,24 +362,10 @@ export function apply(ctx, config) {
328
362
  // blank" symptom. Caching makes the request path O(1) and defers the costly
329
363
  // scan to a background refresh that never blocks a response.
330
364
  const STATS_TTL_MS = 60_000;
331
- let statsCache = { at: 0, payload: null, computing: false };
365
+ // Cache keyed by window (days): { [days]: { at, payload, computing } }.
366
+ let statsCache = {};
332
367
 
333
- function scheduleStatsRefresh() {
334
- if (statsCache.computing) return; // already running; back off
335
- statsCache.computing = true;
336
- // Defer to the next tick so the current handler / event loop is not held.
337
- setImmediate(() => {
338
- try {
339
- const s = aggregateTokens(sessionsRoot);
340
- statsCache = { at: Date.now(), payload: s, computing: false };
341
- } catch (error) {
342
- ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
343
- statsCache = { ...statsCache, computing: false };
344
- }
345
- });
346
- }
347
- // Warm the cache once on boot so the first open returns data immediately.
348
- scheduleStatsRefresh();
368
+ // (stats cache is filled lazily on the first /stats request, synchronously.)
349
369
 
350
370
  // --- route: token stats (served from cache; never blocks the event loop) ---
351
371
  ctx.webServer.register({
@@ -355,16 +375,26 @@ export function apply(ctx, config) {
355
375
  try {
356
376
  const url = new URL(req.url ?? "/", "http://x");
357
377
  const force = url.searchParams.get("refresh") === "1";
358
- if (force) scheduleStatsRefresh();
359
- else if (!statsCache.computing && Date.now() - statsCache.at > STATS_TTL_MS) scheduleStatsRefresh();
360
- const cached = statsCache.payload;
361
- if (cached) {
362
- sendJson(res, 200, { ...cached, cached: true });
378
+ const days = Number(url.searchParams.get("days"));
379
+ const winDays = (days > 0) ? days : null;
380
+ const key = winDays || 0;
381
+ let cell = statsCache[key];
382
+ // Serve from cache if fresh; otherwise compute synchronously (first call
383
+ // after a window switch blocks ~1.5s to fill the cache, then it's instant).
384
+ if (force || !cell || (Date.now() - cell.at > STATS_TTL_MS && !cell.computing)) {
385
+ const s = aggregateTokens(sessionsRoot, winDays);
386
+ statsCache[key] = { at: Date.now(), payload: s, computing: false };
387
+ cell = statsCache[key];
388
+ }
389
+ if (cell && cell.payload) {
390
+ sendJson(res, 200, { ...cell.payload, cached: true, windowDays: winDays });
391
+ return;
392
+ }
393
+ if (cell && cell.error) {
394
+ sendJson(res, 500, { status: "error", message: cell.error, windowDays: winDays });
363
395
  return;
364
396
  }
365
- // No cache yet (first request after boot, scan still warming). Return
366
- // immediately with a "computing" marker instead of blocking on the scan.
367
- sendJson(res, 200, { status: "computing", cached: false });
397
+ sendJson(res, 200, { status: "computing", cached: false, windowDays: winDays });
368
398
  } catch (error) {
369
399
  ctx.logger.warn(error instanceof Error ? error : new Error(String(error)));
370
400
  sendJson(res, 500, { status: "error", message: String(error) });
package/package.json CHANGED
@@ -1,45 +1,45 @@
1
- {
2
- "name": "@lyk308/dsh-token-dashboard",
3
- "version": "0.1.3",
4
- "description": "DeepSeek Harness 实时看板:账户余额 + Token 使用量(按模型/工作区/缓存命中),本地读取会话日志 + DeepSeek /user/balance。",
5
- "type": "module",
6
- "main": "lib/index.js",
7
- "exports": {
8
- ".": "./lib/index.js",
9
- "./client": "./lib/client.js",
10
- "./package.json": "./package.json"
11
- },
12
- "dsh": {
13
- "bundle": {
14
- "patch": "./cordis.patch.yml"
15
- },
16
- "client": {
17
- "inject": [
18
- "@deepseek-ai/dsh-client-runtime",
19
- "@deepseek-ai/dsh-client-locale",
20
- "@deepseek-ai/dsh-client-ui-slots",
21
- "@deepseek-ai/dsh-client-ui-primitives"
22
- ],
23
- "platform": "web"
24
- }
25
- },
26
- "files": [
27
- "lib/index.js",
28
- "lib/client.js",
29
- "cordis.patch.yml",
30
- "README.md",
31
- "README.zh.md",
32
- "LICENSE",
33
- "docs"
34
- ],
35
- "keywords": [
36
- "deepseek-harness",
37
- "dsh",
38
- "plugin",
39
- "token",
40
- "balance",
41
- "usage",
42
- "dashboard"
43
- ],
44
- "license": "MIT"
45
- }
1
+ {
2
+ "name": "@lyk308/dsh-token-dashboard",
3
+ "version": "0.1.4",
4
+ "description": "DeepSeek Harness 实时看板:账户余额 + Token 使用量(按模型/工作区/缓存命中),本地读取会话日志 + DeepSeek /user/balance。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "inject": [
18
+ "@deepseek-ai/dsh-client-runtime",
19
+ "@deepseek-ai/dsh-client-locale",
20
+ "@deepseek-ai/dsh-client-ui-slots",
21
+ "@deepseek-ai/dsh-client-ui-primitives"
22
+ ],
23
+ "platform": "web"
24
+ }
25
+ },
26
+ "files": [
27
+ "lib/index.js",
28
+ "lib/client.js",
29
+ "cordis.patch.yml",
30
+ "README.md",
31
+ "README.zh.md",
32
+ "LICENSE",
33
+ "docs"
34
+ ],
35
+ "keywords": [
36
+ "deepseek-harness",
37
+ "dsh",
38
+ "plugin",
39
+ "token",
40
+ "balance",
41
+ "usage",
42
+ "dashboard"
43
+ ],
44
+ "license": "MIT"
45
+ }