dsh-balance-widget 0.4.1 → 0.4.3

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/README.en.md CHANGED
@@ -164,7 +164,7 @@ Built-in DeepSeek official peak/off-peak pricing (CNY per 1M tokens), effective
164
164
 
165
165
  - Config tree: `dsh --profile web --dump-config` should show a `balance-widget` entry.
166
166
  - Balance route: after restarting dsh web, `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` should return `{ ok, balance_infos, modelId }`.
167
- - Cost routes: `curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=<sessionId>"` and `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` should return `{ cost, inputTokens, outputTokens, modelId }`.
167
+ - Cost routes: `curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=SESSION_ID"` and `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` should return `{ cost, inputTokens, outputTokens, modelId }`.
168
168
 
169
169
  ## License
170
170
 
package/README.md CHANGED
@@ -162,7 +162,7 @@ DSH 的插件配置统一放在这个文件里:
162
162
 
163
163
  - 配置树:`dsh --profile web --dump-config` 应出现 `balance-widget` 条目
164
164
  - 余额路由:重启 dsh web 后 `curl -s http://127.0.0.1:3080/api/dsh-balance/balance` 应返回 `{ ok, balance_infos, modelId }`
165
- - 成本路由:`curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=<会话id>"` 与 `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` 应返回 `{ cost, inputTokens, outputTokens, modelId }`
165
+ - 成本路由:`curl -s "http://127.0.0.1:3080/api/dsh-balance/last-cost?session=SESSION_ID"` 与 `curl -s http://127.0.0.1:3080/api/dsh-balance/today-cost` 应返回 `{ cost, inputTokens, outputTokens, modelId }`
166
166
 
167
167
  ## License
168
168
 
package/lib/client.js CHANGED
@@ -132,9 +132,24 @@ window.__ModuleLoader__.load({
132
132
  /** Same-origin cost routes. */
133
133
  const LAST_COST_ROUTE = "/api/dsh-balance/last-cost";
134
134
  const TODAY_COST_ROUTE = "/api/dsh-balance/today-cost";
135
+ const ACTIVE_COST_ROUTE = "/api/dsh-balance/active-cost";
135
136
  /** Official DeepSeek platform top-up page (opens in a new tab). */
136
137
  const TOP_UP_URL = "https://platform.deepseek.com/top_up";
137
138
  /**
139
+ * Fetch the active session's costs (last prompt + session total).
140
+ * @returns { ok, lastPrompt?, sessionTotal?, error? }
141
+ */
142
+ async function fetchActiveCost() {
143
+ try {
144
+ const response = await fetch(ACTIVE_COST_ROUTE, { headers: { accept: "application/json" } });
145
+ const payload = await response.json().catch(() => ({}));
146
+ if (!response.ok) return { ok: false, error: payload.error ?? `active-cost responded ${response.status}` };
147
+ return { ok: true, lastPrompt: payload.lastPrompt?.cost ?? 0, sessionTotal: payload.sessionTotal?.cost ?? 0 };
148
+ } catch (error) {
149
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
150
+ }
151
+ }
152
+ /**
138
153
  * Fetch the account balance from the host proxy.
139
154
  * @returns { ok, balanceInfos?, modelId?, error? }
140
155
  */
@@ -193,6 +208,28 @@ window.__ModuleLoader__.load({
193
208
  return { symbol, total, currency: entry.currency };
194
209
  }
195
210
  //#endregion
211
+ //#region lib/types/client/ExplainIcon.js
212
+ /**
213
+ * A small ⓘ marker with a hover tooltip explaining a term. Wraps the
214
+ * official DSH Tooltip so the popover stays visually consistent.
215
+ * @param {Object} props - label (tooltip text), t (locale).
216
+ */
217
+ function ExplainIcon({ label, t }) {
218
+ return (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
219
+ label: label,
220
+ side: "top",
221
+ delayMs: 300,
222
+ children: (0, react_jsx_runtime.jsx)("span", {
223
+ role: "img",
224
+ "aria-label": t("explain.aria"),
225
+ style: { cursor: "help", color: "var(--dsw-alias-label-tertiary)", marginLeft: 3, fontSize: 11, lineHeight: 1 },
226
+ children: (0, react_jsx_runtime.jsx)("span", {
227
+ children: "ⓘ"
228
+ })
229
+ })
230
+ });
231
+ }
232
+ //#endregion
196
233
  //#region lib/types/client/SidebarBalanceCard.js
197
234
  /**
198
235
  * Sidebar footer card: balance + remaining-ratio bar + today cost.
@@ -204,17 +241,32 @@ window.__ModuleLoader__.load({
204
241
  function SidebarBalanceCard({ wide, t }) {
205
242
  const [open, setOpen] = (0, react.useState)(false);
206
243
  const [balance, setBalance] = (0, react.useState)(null);
207
- const [todayCost, setTodayCost] = (0, react.useState)(null);
244
+ const [lastPrompt, setLastPrompt] = (0, react.useState)(null);
245
+ const [sessionTotal, setSessionTotal] = (0, react.useState)(null);
246
+ const [todayWs, setTodayWs] = (0, react.useState)(null);
247
+ const [todayAll, setTodayAll] = (0, react.useState)(null);
208
248
  const [lowThreshold, setLowThreshold] = (0, react.useState)(5);
209
249
  const [criticalThreshold, setCriticalThreshold] = (0, react.useState)(1);
210
250
  const [error, setError] = (0, react.useState)(null);
211
251
  const [loading, setLoading] = (0, react.useState)(false);
252
+ /** Fetch today's costs (workspace + all) from the dual-value route. */
253
+ const fetchTodayCosts = async () => {
254
+ const response = await fetch(TODAY_COST_ROUTE, { headers: { accept: "application/json" } });
255
+ const payload = await response.json().catch(() => ({}));
256
+ if (!response.ok) return { ok: false, error: payload.error ?? `today-cost responded ${response.status}` };
257
+ return {
258
+ ok: true,
259
+ workspace: payload.workspace?.cost ?? 0,
260
+ all: payload.all?.cost ?? 0
261
+ };
262
+ };
212
263
  const refresh = async (showLoading) => {
213
264
  if (showLoading) setLoading(true);
214
265
  setError(null);
215
- const [bResult, tResult] = await Promise.all([
266
+ const [bResult, aResult, tResult] = await Promise.all([
216
267
  fetchBalance(),
217
- fetchCost(TODAY_COST_ROUTE)
268
+ fetchActiveCost(),
269
+ fetchTodayCosts()
218
270
  ]);
219
271
  const failures = [];
220
272
  if (bResult.ok) {
@@ -224,8 +276,18 @@ window.__ModuleLoader__.load({
224
276
  } else {
225
277
  failures.push(`余额: ${bResult.error}`);
226
278
  }
227
- if (tResult.ok) setTodayCost(tResult.cost);
228
- else if (tResult.error) failures.push(`今日: ${tResult.error}`);
279
+ if (aResult.ok) {
280
+ setLastPrompt(aResult.lastPrompt);
281
+ setSessionTotal(aResult.sessionTotal);
282
+ } else {
283
+ failures.push(`会话: ${aResult.error}`);
284
+ }
285
+ if (tResult.ok) {
286
+ setTodayWs(tResult.workspace);
287
+ setTodayAll(tResult.all);
288
+ } else {
289
+ failures.push(`今日: ${tResult.error}`);
290
+ }
229
291
  setError(failures.length > 0 ? failures.join(";") : null);
230
292
  if (showLoading) setLoading(false);
231
293
  };
@@ -291,7 +353,7 @@ window.__ModuleLoader__.load({
291
353
  children: [(0, react_jsx_runtime.jsx)("span", {
292
354
  children: t("todayCostShort")
293
355
  }), (0, react_jsx_runtime.jsx)("span", {
294
- children: todayCost !== null ? formatCny(todayCost) : "—"
356
+ children: todayAll !== null ? formatCny(todayAll) : "—"
295
357
  })]
296
358
  })
297
359
  })]
@@ -318,7 +380,13 @@ window.__ModuleLoader__.load({
318
380
  className: styles.row,
319
381
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
320
382
  children: [(0, react_jsx_runtime.jsx)("span", {
321
- children: t("balance")
383
+ className: styles.label,
384
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
385
+ children: [t("balance"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
386
+ label: t("explain.balance"),
387
+ t
388
+ })]
389
+ })
322
390
  }), (0, react_jsx_runtime.jsx)("span", {
323
391
  className: styles.val,
324
392
  "data-level": level > 0 ? level : undefined,
@@ -333,10 +401,64 @@ window.__ModuleLoader__.load({
333
401
  className: styles.row,
334
402
  children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
335
403
  children: [(0, react_jsx_runtime.jsx)("span", {
336
- children: t("todayCost")
404
+ className: styles.label,
405
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
406
+ children: [t("lastPrompt"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
407
+ label: t("explain.lastPrompt"),
408
+ t
409
+ })]
410
+ })
411
+ }), (0, react_jsx_runtime.jsx)("span", {
412
+ className: styles.val,
413
+ children: loading ? "…" : lastPrompt !== null ? formatCny(lastPrompt) : "—"
414
+ })]
415
+ })
416
+ }), (0, react_jsx_runtime.jsx)("div", {
417
+ className: styles.row,
418
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
419
+ children: [(0, react_jsx_runtime.jsx)("span", {
420
+ className: styles.label,
421
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
422
+ children: [t("sessionTotal"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
423
+ label: t("explain.sessionTotal"),
424
+ t
425
+ })]
426
+ })
427
+ }), (0, react_jsx_runtime.jsx)("span", {
428
+ className: styles.val,
429
+ children: loading ? "…" : sessionTotal !== null ? formatCny(sessionTotal) : "—"
430
+ })]
431
+ })
432
+ }), (0, react_jsx_runtime.jsx)("div", {
433
+ className: styles.row,
434
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
435
+ children: [(0, react_jsx_runtime.jsx)("span", {
436
+ className: styles.label,
437
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
438
+ children: [t("todayWs"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
439
+ label: t("explain.todayWs"),
440
+ t
441
+ })]
442
+ })
443
+ }), (0, react_jsx_runtime.jsx)("span", {
444
+ className: styles.val,
445
+ children: loading ? "…" : todayWs !== null ? formatCny(todayWs) : "—"
446
+ })]
447
+ })
448
+ }), (0, react_jsx_runtime.jsx)("div", {
449
+ className: styles.row,
450
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
451
+ children: [(0, react_jsx_runtime.jsx)("span", {
452
+ className: styles.label,
453
+ children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
454
+ children: [t("todayAll"), (0, react_jsx_runtime.jsx)(ExplainIcon, {
455
+ label: t("explain.todayAll"),
456
+ t
457
+ })]
458
+ })
337
459
  }), (0, react_jsx_runtime.jsx)("span", {
338
460
  className: styles.val,
339
- children: loading ? "…" : todayCost !== null ? formatCny(todayCost) : "—"
461
+ children: loading ? "…" : todayAll !== null ? formatCny(todayAll) : "—"
340
462
  })]
341
463
  })
342
464
  }), error !== null && (0, react_jsx_runtime.jsx)("div", {
@@ -378,7 +500,16 @@ window.__ModuleLoader__.load({
378
500
  "refresh.aria": "刷新数据",
379
501
  "refresh.title": "刷新余额与成本",
380
502
  "balance": "账户余额",
381
- "todayCost": "今天总成本",
503
+ "lastPrompt": "最近一次提问",
504
+ "sessionTotal": "本会话成本",
505
+ "todayWs": "今日·本项目",
506
+ "todayAll": "今日·全部",
507
+ "explain.aria": "解释",
508
+ "explain.balance": "DeepSeek 账户可用余额,实时查询官方接口。",
509
+ "explain.lastPrompt": "最近活跃会话的最后一个问题(含 AI 回复)的费用估算。",
510
+ "explain.sessionTotal": "最近活跃会话从开始到现在的累计费用估算。",
511
+ "explain.todayWs": "当前工作区今天所有会话的费用合计估算。",
512
+ "explain.todayAll": "所有工作区今天所有会话的费用合计估算(含其他项目)。",
382
513
  "warn.title.1": "余额不足 ¥5,建议及时充值",
383
514
  "warn.title.2": "余额低于 ¥1,即将耗尽!",
384
515
  "warn.text.1": "⚠️ 余额不足 ¥5,建议及时充值。",
@@ -396,7 +527,16 @@ window.__ModuleLoader__.load({
396
527
  "refresh.aria": "Refresh data",
397
528
  "refresh.title": "Refresh balance and costs",
398
529
  "balance": "Account balance",
399
- "todayCost": "Today total",
530
+ "lastPrompt": "Last prompt",
531
+ "sessionTotal": "This session",
532
+ "todayWs": "Today · this project",
533
+ "todayAll": "Today · all",
534
+ "explain.aria": "Explain",
535
+ "explain.balance": "DeepSeek account balance, fetched live from the official API.",
536
+ "explain.lastPrompt": "Estimated cost of the latest question (incl. the AI reply) in the most recent session.",
537
+ "explain.sessionTotal": "Estimated total cost of the most recent session since it started.",
538
+ "explain.todayWs": "Estimated total cost of today's sessions in the current workspace.",
539
+ "explain.todayAll": "Estimated total cost of today's sessions across all workspaces.",
400
540
  "warn.title.1": "Balance below ¥5, consider topping up",
401
541
  "warn.title.2": "Balance below ¥1, almost exhausted!",
402
542
  "warn.text.1": "⚠️ Balance below ¥5, consider topping up.",
package/lib/index.js CHANGED
@@ -24,7 +24,8 @@ export const API = {
24
24
  balance: "/api/dsh-balance/balance",
25
25
  cost: "/api/dsh-balance/cost",
26
26
  lastCost: "/api/dsh-balance/last-cost",
27
- todayCost: "/api/dsh-balance/today-cost"
27
+ todayCost: "/api/dsh-balance/today-cost",
28
+ activeCost: "/api/dsh-balance/active-cost"
28
29
  };
29
30
 
30
31
  /** Default DeepSeek API base (mirrors dsh-llm-deepseek's PUBLIC_BASE_URL). */
@@ -459,6 +460,7 @@ async function decompressSession(path) {
459
460
  function parseSessionUsages(text) {
460
461
  const samples = [];
461
462
  let currentModel = void 0;
463
+ let cwd = void 0;
462
464
  for (const line of text.split("\n")) {
463
465
  if (line === "") continue;
464
466
  let event;
@@ -468,7 +470,9 @@ function parseSessionUsages(text) {
468
470
  continue;
469
471
  }
470
472
  const type = event.type;
471
- if (type === "request/header") {
473
+ if (type === "session") {
474
+ cwd = event.cwd;
475
+ } else if (type === "request/header") {
472
476
  currentModel = event.data?.header?.config?.model ?? currentModel;
473
477
  } else if (type === "assistant/message") {
474
478
  const usage = event.data?.usage;
@@ -482,7 +486,7 @@ function parseSessionUsages(text) {
482
486
  }
483
487
  }
484
488
  }
485
- return samples;
489
+ return { samples, cwd };
486
490
  }
487
491
 
488
492
  /** Cost of the last turn (the most recent user prompt) in one session file. */
@@ -502,19 +506,71 @@ function lastTurnCost(samples) {
502
506
  return { cost, inputTokens: input, outputTokens: output };
503
507
  }
504
508
 
509
+ /** Total cost across every sample of one session (all turns). */
510
+ function sessionTotalCost(samples) {
511
+ let cost = 0;
512
+ let input = 0;
513
+ let output = 0;
514
+ for (const s of samples) {
515
+ cost += priceUsage(s.usage, s.model, s.time);
516
+ input += (s.usage.inputTokens ?? s.usage.uncachedInputTokens ?? 0) + (s.usage.cacheReadTokens ?? 0);
517
+ output += s.usage.outputTokens ?? 0;
518
+ }
519
+ return { cost, inputTokens: input, outputTokens: output };
520
+ }
521
+
505
522
  /**
506
- * Sum today's usage across every session file. "Today" = events whose time
523
+ * Find the session file modified most recently (the "active" session).
524
+ * Walks the sessions tree and picks by mtime; returns the path or undefined.
525
+ */
526
+ async function findMostRecentSessionFile() {
527
+ const root = sessionsRoot();
528
+ try {
529
+ const fs = await awaitImportFs();
530
+ const { statSync } = fs;
531
+ let best = void 0;
532
+ let bestMtime = -1;
533
+ const walk = (dir) => {
534
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
535
+ const full = `${dir}/${entry.name}`;
536
+ if (entry.isDirectory()) {
537
+ walk(full);
538
+ } else if (entry.name === "session.jsonl.zstd") {
539
+ try {
540
+ const mtime = statSync(full).mtimeMs;
541
+ if (mtime > bestMtime) {
542
+ bestMtime = mtime;
543
+ best = full;
544
+ }
545
+ } catch {
546
+ /* ignore unreadable files */
547
+ }
548
+ }
549
+ }
550
+ };
551
+ walk(root);
552
+ return best;
553
+ } catch {
554
+ return void 0;
555
+ }
556
+ }
557
+
558
+ /**
559
+ * Sum today's usage across session files. "Today" = events whose time
507
560
  * falls on the same calendar day (local time) as now.
561
+ * @param {Array<{samples:Array,cwd?:string}>} sessionList - parsed sessions.
562
+ * @param {string} [cwdFilter] - if given, only sessions with this cwd count.
508
563
  */
509
- function todayCost(samplesBySession) {
564
+ function todayCost(sessionList, cwdFilter) {
510
565
  const now = new Date();
511
566
  const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
512
567
  const dayEnd = dayStart + 24 * 60 * 60 * 1000;
513
568
  let cost = 0;
514
569
  let input = 0;
515
570
  let output = 0;
516
- for (const samples of samplesBySession) {
517
- for (const s of samples) {
571
+ for (const session of sessionList) {
572
+ if (cwdFilter !== void 0 && session.cwd !== cwdFilter) continue;
573
+ for (const s of session.samples) {
518
574
  if (s.time < dayStart || s.time >= dayEnd) continue;
519
575
  cost += priceUsage(s.usage, s.model, s.time);
520
576
  input += (s.usage.inputTokens ?? s.usage.uncachedInputTokens ?? 0) + (s.usage.cacheReadTokens ?? 0);
@@ -545,7 +601,8 @@ async function collectAllSessionSamples() {
545
601
  for (const file of files) {
546
602
  const result = await decompressSession(file);
547
603
  if (!result.ok) continue;
548
- perSession.push(parseSessionUsages(result.text));
604
+ const parsed = parseSessionUsages(result.text);
605
+ perSession.push(parsed);
549
606
  }
550
607
  return perSession;
551
608
  }
@@ -622,8 +679,8 @@ function makeRoutes(ctx, config) {
622
679
  const compute = async () => {
623
680
  const result = await decompressSession(file);
624
681
  if (!result.ok) return { error: result.error };
625
- const samples = parseSessionUsages(result.text);
626
- return { value: lastTurnCost(samples) };
682
+ const parsed = parseSessionUsages(result.text);
683
+ return { value: lastTurnCost(parsed.samples) };
627
684
  };
628
685
  const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
629
686
  if (outcome.error !== void 0) {
@@ -640,12 +697,63 @@ function makeRoutes(ctx, config) {
640
697
  if (!guard(req, res, "GET")) return;
641
698
  const key = "today";
642
699
  const compute = async () => {
643
- const perSession = await collectAllSessionSamples();
644
- return { value: todayCost(perSession) };
700
+ const sessionList = await collectAllSessionSamples();
701
+ const all = todayCost(sessionList);
702
+ // "Current workspace": the cwd of the session with the most
703
+ // samples today (best proxy for where the user is working).
704
+ let activeCwd = void 0;
705
+ let activeCount = 0;
706
+ for (const session of sessionList) {
707
+ if (session.cwd === void 0) continue;
708
+ const todaySamples = session.samples.filter((s) => {
709
+ const now = new Date();
710
+ const dayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
711
+ return s.time >= dayStart && s.time < dayStart + 24 * 60 * 60 * 1000;
712
+ }).length;
713
+ if (todaySamples > activeCount) {
714
+ activeCount = todaySamples;
715
+ activeCwd = session.cwd;
716
+ }
717
+ }
718
+ const workspace = todayCost(sessionList, activeCwd);
719
+ return {
720
+ value: {
721
+ workspace: { ...workspace, cwd: activeCwd },
722
+ all
723
+ }
724
+ };
645
725
  };
646
726
  const outcome = await dedupe(key, () => cachedOrCompute(key, compute));
647
727
  writeJson(res, 200, { ...outcome.value, modelId: config.modelId, pricingSource, pricingSyncedAt });
648
728
  }
729
+ },
730
+ {
731
+ kind: "exact",
732
+ path: API.activeCost,
733
+ handler: async (req, res) => {
734
+ if (!guard(req, res, "GET")) return;
735
+ const file = await findMostRecentSessionFile();
736
+ if (file === void 0) {
737
+ writeJson(res, 404, { error: "no session files found", modelId: config.modelId });
738
+ return;
739
+ }
740
+ const result = await decompressSession(file);
741
+ if (!result.ok) {
742
+ writeJson(res, 502, { error: result.error, modelId: config.modelId });
743
+ return;
744
+ }
745
+ const parsed = parseSessionUsages(result.text);
746
+ const last = lastTurnCost(parsed.samples);
747
+ const total = sessionTotalCost(parsed.samples);
748
+ writeJson(res, 200, {
749
+ lastPrompt: last,
750
+ sessionTotal: total,
751
+ cwd: parsed.cwd,
752
+ modelId: config.modelId,
753
+ pricingSource,
754
+ pricingSyncedAt
755
+ });
756
+ }
649
757
  }
650
758
  ];
651
759
  }
@@ -689,8 +797,8 @@ function buildBillingTool(ctx, config) {
689
797
  }
690
798
  if (query === "cost" || query === "both") {
691
799
  try {
692
- const perSession = await collectAllSessionSamples();
693
- const today = todayCost(perSession);
800
+ const sessionList = await collectAllSessionSamples();
801
+ const today = todayCost(sessionList);
694
802
  parts.push(`Today's estimated cost: CNY ${today.cost.toFixed(4)} (${today.inputTokens} input tokens, ${today.outputTokens} output tokens)`);
695
803
  } catch (error) {
696
804
  parts.push(`Cost query failed: ${error instanceof Error ? error.message : String(error)}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-balance-widget",
3
3
  "description": "Balance & cost widget for the dsh web GUI: a corner icon in the conversation session header that shows the DeepSeek account balance and the current session's estimated spend on click. Host half proxies the official /user/balance API over ctx.webServer (loopback-only); client half renders the icon and popover. Zero external dependencies — works on Node 24.",
4
- "version": "0.4.1",
4
+ "version": "0.4.3",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "files": [