dsh-tokenlens 1.2.2 → 1.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  本项目遵循 [Semantic Versioning](https://semver.org/):修复 = PATCH,新功能 = MINOR,不兼容变更 = MAJOR。发布时同步更新 `package.json` 的 `version`、`index.js` 的 `PLUGIN_VERSION` 与本文件(`node scripts/bump-version.mjs <version> "<note>"`);已发布的条目保持原样。
4
4
 
5
+ ## 1.3.0 — 2026-11-01
6
+
7
+ - feat(update): 可选的**启动时检查更新**(默认关闭):开启后仅向 npm registry 查询一次 `dsh-tokenlens` 的 latest 版本号,不携带凭据/账号/用量信息;主机离线或接口失败时记录错误并说明,不阻塞启动
8
+ - feat(update): 供应商管理页「插件设置」新增「更新检查」块——开关、隐私提示、状态行(尚未检查 / 正在检查 / 已是最新 / 发现新版本 / 检查失败可重试)与「立即检查」;详情弹窗底部在发现新版本时显示一行「发现新版本 vX · 打开 npm」(链接到 npm 包页)
9
+ - feat(wire): `hello` 与 `providers` 载荷新增 `update`(`UpdateInfo`)与 `updatePackageUrl`;`settingsUpdate` 新增 `settings.updateCheck`;新增 bridge action `updateCheck`(Agent 工具同步支持)
10
+ - fix(update): 并发检查单飞(共享同一次在途请求);仅在开关从关→开时触发检查,已开启时保存其它显示设置不会重复请求 npm
11
+ - 测试:277 个自动化用例(新增 Host 版本比较/检查/设置/并发与客户端 UI 回归)
12
+
5
13
  ## 1.2.2 — 2026-11-01
6
14
 
7
15
  - fix(workspaces): 修复「工作区悬浮卡片不显示 Token 明细」——Host 在裁剪本地路径时把 `path` 字段误删成空串,客户端只剩标题精确匹配;默认工作区等「界面标题 ≠ 统计标签」的场景因此匹配不到统计(影响 v1.2.1 及更早版本)
package/PROTOCOL.md CHANGED
@@ -32,12 +32,16 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
32
32
 
33
33
  ```jsonc
34
34
  {
35
- "action": "snapshot" | "refresh" | "hello" | "stats" | "diagnostics",
35
+ "action": "snapshot" | "refresh" | "hello" | "stats" | "diagnostics" |
36
+ "providers" | "providerUpdate" | "providerTest" | "settingsUpdate" |
37
+ "customProviderSave" | "customProviderDelete" | "updateCheck",
36
38
  "providerId": "opencode-go", // optional; defaults to the active provider
37
39
  "fresh": false, // stats only: bypass the scan TTL
38
40
  "origin": "dsh-app://app", // hello only, optional
39
41
  "primitives": 290, // hello only, optional (seeded primitives count)
40
- "locale": "zh-CN" // hello only, optional
42
+ "locale": "zh-CN", // hello only, optional
43
+ "settings": { /* settingsUpdate only */ },
44
+ "update": { /* providerUpdate only */ }
41
45
  }
42
46
  ```
43
47
 
@@ -47,6 +51,9 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
47
51
  answers with a snapshot so the first paint needs one round trip.
48
52
  - `stats` — today/all-time token statistics folded from the local session logs.
49
53
  - `diagnostics` — config/cache/error state (verification aid, no secrets).
54
+ - `updateCheck` — v1.3.0: run the optional npm-registry update check right now and
55
+ answer `{ ok: true, update: <UpdateInfo> }`. It is also run once at activation
56
+ when `settings.updateCheck` is enabled; disabled by default.
50
57
  - Unknown action → HTTP 400 `{ "ok": false, "error": { "code": "unknown-action", … } }`.
51
58
 
52
59
  ### Response envelope
@@ -54,10 +61,11 @@ from `dsh-app://app/` and forwards non-asset paths to the Host).
54
61
  ```jsonc
55
62
  { "ok": true, "snapshot": <Snapshot> } // snapshot / refresh / hello
56
63
  { "ok": true, "stats": <Stats> } // stats
64
+ { "ok": true, "update": <UpdateInfo> } // updateCheck
57
65
  { "ok": false, "error": <Error>, "snapshot": <Snapshot|null> }
58
66
  ```
59
67
 
60
- `hello` answers `{ ok: true, plugin, version, bridge, pid, …snapshotResult }`.
68
+ `hello` answers `{ ok: true, plugin, version, bridge, pid, update: <UpdateInfo|null>, …snapshotResult }`.
61
69
 
62
70
  ## Snapshot (wire shape, v3)
63
71
 
@@ -319,7 +327,7 @@ needs no provider catalog of its own.
319
327
 
320
328
  The dialog 「供应商管理」 page talks to three additional actions:
321
329
 
322
- - `{action:"providers"}` → `{ok:true, providers:[{providerId,label,shortLabel,kind,enabled,hasKey,keySource,apiKeyRef,baseURL,consoleUrl,builtin,dshManaged,dshProviderId}], dshProviders:[{providerId,label,supported,usageProviderId}], settings:{labelStyle,defaultView,defaultScope,detailsExpanded,detailsWindow}, storeFile, storeFileDisplay}`. `storeFile` is the real path (local tooling); `storeFileDisplay` replaces the user's home directory with `~` and is what the management page renders.
330
+ - `{action:"providers"}` → `{ok:true, providers:[{providerId,label,shortLabel,kind,enabled,hasKey,keySource,apiKeyRef,baseURL,consoleUrl,builtin,dshManaged,dshProviderId}], dshProviders:[{providerId,label,supported,usageProviderId}], settings:{labelStyle,defaultView,defaultScope,detailsExpanded,detailsWindow,updateCheck}, update:<UpdateInfo|null>, updatePackageUrl, storeFile, storeFileDisplay}`. `storeFile` is the real path (local tooling); `storeFileDisplay` replaces the user's home directory with `~` and is what the management page renders. `update` (v1.3.0) is the last optional update-check result and `updatePackageUrl` the npm page the management UI links to.
323
331
  - `{action:"providerUpdate", providerId, update:{enabled?,apiKey?,apiKeyRef?,baseURL?,consoleUrl?,label?,shortLabel?}}` → same list payload;
324
332
  empty strings clear a stored value (falling back to the profile/default). `label` (全名, ≤40 chars) and
325
333
  `shortLabel` (短标签, ≤12 chars) let the management page rename a provider. The shipped management page no
@@ -327,7 +335,32 @@ The dialog 「供应商管理」 page talks to three additional actions:
327
335
  saves enabled/consoleUrl/label/shortLabel. Overrides are persisted atomically to
328
336
  `$DSH_HOME/dsh-tokenlens/providers.json` (plugin-owned, not the profile patch); the same file also
329
337
  - `{action:"providerTest", providerId}` → the normal snapshot envelope (`{ok,snapshot}` or `{ok:false,error}`), forcing one upstream read.
330
- - `{action:"settingsUpdate", settings:{labelStyle?:"short"|"full", defaultView?:"chart"|"data", defaultScope?:"provider"|"all", detailsExpanded?:"auto"|"expanded"|"collapsed", detailsWindow?:"today"|"last7Days"|"second"|"total"}}` → the same providers payload; every field is optional and persisted in the store (`settings.*`). `labelStyle` controls whether the composer chip shows each provider's `shortLabel` or full `label`; `defaultView` / `defaultScope` / `detailsExpanded` are the usage-dialog defaults; `detailsWindow` (v1.2.1) is the remembered 明细 donut window (falls back visually to `total` when that window is unavailable on the current provider/host). Invalid values answer `unsupported-provider`.
338
+ - `{action:"settingsUpdate", settings:{labelStyle?:"short"|"full", defaultView?:"chart"|"data", defaultScope?:"provider"|"all", detailsExpanded?:"auto"|"expanded"|"collapsed", detailsWindow?:"today"|"last7Days"|"second"|"total", updateCheck?:boolean}}` → the same providers payload; every field is optional and persisted in the store (`settings.*`). `labelStyle` controls whether the composer chip shows each provider's `shortLabel` or full `label`; `defaultView` / `defaultScope` / `detailsExpanded` are the usage-dialog defaults; `detailsWindow` (v1.2.1) is the remembered 明细 donut window (falls back visually to `total` when that window is unavailable on the current provider/host); `updateCheck` (v1.3.0, default `false`) enables the optional startup update check — enabling it runs one check immediately. Invalid values answer `unsupported-provider`.
339
+
340
+ ### Optional update check (v1.3.0)
341
+
342
+ Off by default: the plugin stays local-only unless the user enables
343
+ `settings.updateCheck`. When enabled, one HTTPS `GET` to the fixed endpoint
344
+ `https://registry.npmjs.org/dsh-tokenlens/latest` runs at activation, on the
345
+ off→on transition of the setting, and on the `updateCheck` action. Concurrent
346
+ checks are single-flight (one registry request shared by every caller). Nothing
347
+ but the request itself leaves the machine — no credentials, identifiers or
348
+ usage data.
349
+
350
+ ```jsonc
351
+ // UpdateInfo (host → client)
352
+ {
353
+ "current": "1.3.0",
354
+ "latest": "1.3.1" | null, // null when the answer was unusable/failed
355
+ "available": true, // strictly newer published version, error === null
356
+ "checkedAt": "2026-11-02T00:00:00.000Z",
357
+ "error": null | { "code": "network"|"timeout"|"http"|"parse", "message": "…", "retryable": true }
358
+ }
359
+ ```
360
+
361
+ A failed check never throws and never blocks activation; the client explains the
362
+ error instead of inventing a version. The result lives in memory only and is
363
+ surfaced through `hello`, `providers` and `diagnostics`.
331
364
 
332
365
  `dshProviders` lists every route registered in DSH model settings (in order) with a `supported` flag (whether a usage adapter exists); the shipped management page renders those routes as the primary list and keeps unconfigured preset adapters in a collapsed secondary section. `dshManaged`/`dshProviderId` tell the management page that the route already exists in DSH model settings
333
366
  (opencode-go / commandcode / deepseek), so the page can inherit the key and base URL instead of asking for them
@@ -438,6 +471,11 @@ configure the `opencode-go` provider.
438
471
  - **chart mode**: the daily bar chart, a click on a day bar drills into that
439
472
  day's 24 local-hour bars (when the Host delivers `daily[].hours`), and the
440
473
  明细 donuts switch between 今日 / 最近7天 / 最近X天 / 总计;
441
- - refresh (usage + stats), last-updated/stale line, error banners, console link.
442
- 3. Everything client-side must work with only the shell's seeded modules
474
+ - refresh (usage + stats), last-updated/stale line, error banners, console link;
475
+ - when `update.available` is true (v1.3.0) the footer shows one muted line
476
+ 「发现新版本 vX · 打开 npm」 linking to `updatePackageUrl`.
477
+ 3. **Provider management page** (same modal, 管理 tab) — when the optional update
478
+ check is enabled the 插件设置 tab shows its toggle, the last result (up to date /
479
+ new version / failed) and a 「立即检查」 action that sends `updateCheck`.
480
+ 4. Everything client-side must work with only the shell's seeded modules
443
481
  (`react`, `react-dom`, `@deepseek-ai/dsh-client-ui-primitives`) — no build step.
package/README.en.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  > One glance at your usage: plan capacity for the selected provider plus local token statistics across harnesses, right below the DSH composer.
11
11
 
12
- **dsh-TokenLens** is a usage plugin for DSH (DeepSeek Harness), version v1.2.2. It keeps two kinds of numbers separate:
12
+ **dsh-TokenLens** is a usage plugin for DSH (DeepSeek Harness), version v1.3.0. It keeps two kinds of numbers separate:
13
13
 
14
14
  - **Plan capacity** — the provider's authoritative server-side values (percentage windows, used / cap, balance or remaining credit);
15
15
  - **Local token stats** — tokens consumed on this machine across every connected harness, for the selected provider or for all providers combined.
@@ -21,7 +21,7 @@ Everything is computed locally: zero runtime dependencies, no external database,
21
21
  - **Usage chip below the composer** — follows the selected model provider and shows its plan capacity; providers without an adapter or credentials stay hidden.
22
22
  - **Details dialog** — plan capacity windows with reset times (plus an optional "consumed · estimated consumable" hint) and local token stats for today / last 7 days / last 30 days / all-time, scoped to the current provider or all harnesses.
23
23
  - **Data and chart modes** — tables, a daily trend chart and ring charts with breakdowns by harness, provider, top model and unmapped providers. In chart mode, clicking a day's bar drills into that day's 24 hourly bars; the 明细 rings switch between today / last 7 days / last X days (or last 30 days) / all time.
24
- - **Provider management page** — enabled / built-in / all filters, enable switches, credential status, console links, label editing, connection tests, subscription badges and plugin settings (label style, default view/scope, details expansion, default 明细 donut window).
24
+ - **Provider management page** — enabled / built-in / all filters, enable switches, credential status, console links, label editing, connection tests, subscription badges and plugin settings (label style, default view/scope, details expansion, default 明细 donut window, optional startup update check with a "check now" action).
25
25
  - **Auto-discovery and agent tool** — providers configured in DSH Settings → Model Services are detected by route id or base-URL host and reuse their DSH credentials; `tokenlens_usage` lets the model query snapshots, local stats and diagnostics.
26
26
  - **Local-only** — 5 harnesses (DSH, ZCode, OpenCode CLI, WorkBuddy / CodeBuddy CLI, Qoder CN CLI), 25 built-in providers (16 with plan / balance adapters), read-only local data, no telemetry.
27
27
 
@@ -66,7 +66,7 @@ dsh plugin --profile <profile> add dsh-tokenlens
66
66
  Then make sure `dsh.profile.bundles` in `$DSH_HOME/profiles/<profile>/package.json` contains `"dsh-tokenlens"`:
67
67
 
68
68
  ```jsonc
69
- { "dependencies": { "dsh-tokenlens": "^1.2.2" },
69
+ { "dependencies": { "dsh-tokenlens": "^1.3.0" },
70
70
  "dsh": { "profile": { "bundles": ["…", "dsh-tokenlens"] } } }
71
71
  ```
72
72
 
@@ -123,7 +123,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
123
123
 
124
124
  - **Read-only**: only local session logs and harness databases are read; SQLite is opened read-only and only token counters, timestamps and provider / model fields are queried — never message contents.
125
125
  - **Keys stay local**: API keys are resolved in the Host process from DSH credentials, environment variables or the DSH credentials file, and are sent only to the matching provider's official HTTPS endpoint. They are never exposed to the browser, logged or included in diagnostics.
126
- - **No telemetry**: nothing is collected, uploaded, or fetched from remote config / CDN sources (icons are inline SVG).
126
+ - **No telemetry**: nothing is collected, uploaded, or fetched from remote config / CDN sources (icons are inline SVG). The only optional outbound request is the startup update check (v1.3.0, off by default): when enabled it asks the npm registry once for the published version number, without credentials, identifiers or usage data.
127
127
  - **Owner-only data**: management overrides are written atomically to `$DSH_HOME/dsh-tokenlens/providers.json` with owner-only permissions; DSH model settings are never modified.
128
128
 
129
129
  ## Limitations
@@ -131,12 +131,13 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
131
131
  - Provider usage endpoints are mostly private / undocumented and may change at any time; the plugin degrades gracefully (retryable errors, hidden chips, per-row fallbacks) but availability is not guaranteed.
132
132
  - Plan capacity shows native server values only: no token → money conversion; Command Code monthly percentage is not guessed when no grant is reported.
133
133
  - "Estimated consumable" is derived from local tokens and the plan percentage; it is only a hint, not a provider commitment.
134
+ - The startup update check compares the **latest npm release** only (off by default); commits on the GitHub repository are not detected — a git-installed plugin must be uninstalled and reinstalled to update.
134
135
  - Local stats cover connected harnesses on this machine only; uninstalled harnesses are hidden by default (`stats.showUninstalled: true` shows them). Workspace token badges are experimental.
135
136
  - **Trademarks**: OpenCode, Command Code, DeepSeek, ZCode, WorkBuddy, CodeBuddy, Qoder and other names are trademarks of their respective owners. This is an independent third-party project, not affiliated with or endorsed by them.
136
137
 
137
138
  ## Development
138
139
 
139
- Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 256 tests.
140
+ Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 277 tests.
140
141
 
141
142
  Release: `node scripts/bump-version.mjs <version> "<note>"` updates `package.json`, `PLUGIN_VERSION` and `CHANGELOG.md`; see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
142
143
 
package/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  > **一眼看清你的用量**:在 DSH 输入框下方显示当前供应商的套餐容量,并汇总本机跨 Harness 的 Token 消耗。
11
11
 
12
- **dsh-TokenLens(词元透镜)** 是 DSH(DeepSeek Harness)的用量插件(v1.2.2,公开版本)。它把两类数字**按不同口径**分开呈现:
12
+ **dsh-TokenLens(词元透镜)** 是 DSH(DeepSeek Harness)的用量插件(v1.3.0,公开版本)。它把两类数字**按不同口径**分开呈现:
13
13
 
14
14
  - **套餐容量**(供应商服务端权威值):百分比窗口、`已用 / 上限`、账户余额或剩余额度;
15
15
  - **本机 Token 统计**(本机记录):当前供应商在本机所有已接入 Harness 上的合计用量,也可切到「全部 Harness」看总账。
@@ -41,7 +41,7 @@
41
41
  - **本机 Token 统计**:今日 / 最近 7 天 / 最近 30 天 / 总计(订阅类供应商显示「最近 X 天」生效窗口),可按「当前供应商 · 全部 Harness」与「全部 Harness」两个视角查看。
42
42
  - **明细与图表**:按 Harness、按供应商、Top 模型、未映射供应商分组;支持「数据」(表格)与「图表」两种模式切换。图表模式里点击某一天的柱子可下钻查看**当天 24 小时**的 Token 消耗,底部「明细」环形图支持 **今日 / 最近 7 天 / 最近 X 天(或最近 30 天)/ 总计** 窗口切换。
43
43
  - **工作区徽标**(实验性):左侧工作区列表的名称旁显示该工作区的本机 Token 合计,悬浮卡片显示今日 / 7 天 / 30 天 / 总计;纯展示,找不到对应 UI 时自动降级。
44
- - **供应商管理页**:「已配置 / 内置 / 全部」筛选、启用开关、凭据状态、控制台链接、短标签与全名编辑、测试连接、已订阅徽章;另有「插件设置」可配置标签样式、默认展示模式、默认统计范围、明细展开状态与图表「明细」环形图的默认窗口(今日 / 最近 7 天 / 最近 X 天 / 总计,记住上次选择)。
44
+ - **供应商管理页**:「已配置 / 内置 / 全部」筛选、启用开关、凭据状态、控制台链接、短标签与全名编辑、测试连接、已订阅徽章;另有「插件设置」可配置标签样式、默认展示模式、默认统计范围、明细展开状态、图表「明细」环形图的默认窗口(今日 / 最近 7 天 / 最近 X 天 / 总计,记住上次选择)与「启动时检查更新」(默认关闭,可手动立即检查)。
45
45
  - **自动识别**:在 DSH「设置 → 模型服务」中配置过的供应商会自动出现在管理页,并按路由 id 或 baseURL 主机匹配内置用量适配器,自动继承 DSH 中的凭据,无需重复填写。
46
46
  - **Agent 工具**:注册 `tokenlens_usage`,模型可以自行查询套餐容量与本机用量。
47
47
 
@@ -106,7 +106,7 @@ dsh plugin --profile <profile> add dsh-tokenlens
106
106
  ```jsonc
107
107
  {
108
108
  "dependencies": {
109
- "dsh-tokenlens": "^1.2.2"
109
+ "dsh-tokenlens": "^1.3.0"
110
110
  },
111
111
  "dsh": {
112
112
  "profile": {
@@ -371,7 +371,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
371
371
 
372
372
  - **只读**:插件只读取本机会话日志与各 harness 的本地数据库 / JSONL;SQLite 一律以只读方式打开,只取 token 计数、时间、provider / model 等字段,不读取对话正文。
373
373
  - **凭据不出本机**:API Key 只在 Host 进程内从 DSH 凭据服务 / 环境变量 / `.credentials.yaml` / OpenCode CLI 登录文件解析,只发往对应供应商的官方 HTTPS 接口;不下发浏览器、不写日志、不进诊断输出。
374
- - **无遥测**:插件不收集、不上传任何使用数据,不请求远程配置或 CDN 资源(图标均为内联 SVG)。
374
+ - **无遥测**:插件不收集、不上传任何使用数据,不请求远程配置或 CDN 资源(图标均为内联 SVG)。唯一的可选外网请求是「启动时检查更新」(v1.3.0,默认关闭):开启后仅向 npm registry 查询一次最新版本号,不携带任何账号、凭据或用量信息。
375
375
  - **覆盖文件仅本人可读**:供应商管理页的覆盖写入 `$DSH_HOME/dsh-tokenlens/providers.json`(原子写入,权限仅当前用户),不会改动 DSH 模型设置或 profile patch。
376
376
  - **可控降级**:上游接口不可用时只影响对应 chip / 行,不影响 DSH 本身;`enabled: false` 可整体停用插件。
377
377
 
@@ -380,6 +380,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
380
380
  - 各供应商的用量接口多为**未公开的私有接口**,可能随时变更;插件对此做了优雅降级(可重试错误、静默隐藏、行级降级),但不保证长期可用。
381
381
  - 套餐容量只显示服务端原生数字:不做 token → 金额换算;Command Code 未报告 grant 时不计算月度百分比。
382
382
  - 「预计可消耗」是基于本机 Token 与套餐百分比的**估算**,仅供参考,不代表供应商承诺的额度。
383
+ - 「启动时检查更新」只对比 **npm 上的最新发布版本**(默认关闭);GitHub 仓库的提交更新不会被感知,需要卸载重装 git 安装的版本才会更新。
383
384
  - 本机 Token 统计只覆盖**已接入 harness**(当前 DSH / ZCode / OpenCode CLI / WorkBuddy / Qoder CN),只统计本机的本地记录。
384
385
  - Harness 卸载后其数据目录消失即判定为未安装,默认从弹窗隐藏(可用 `stats.showUninstalled: true` 显示「未安装」行);数据目录仍在磁盘时按历史记录继续计入。
385
386
  - ZCode 数据库通常很大且持续写入:插件只做聚合查询并缓存结果,数据库被锁时会短暂显示不可用。
@@ -391,7 +392,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
391
392
 
392
393
  ## 验证
393
394
 
394
- - `node --test`:**256 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、插件集成与浏览器端渲染 / 交互。
395
+ - `node --test`:**277 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、插件集成与浏览器端渲染 / 交互。
395
396
  - 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
396
397
  - 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
397
398
 
@@ -402,7 +403,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
402
403
  ```powershell
403
404
  git clone https://github.com/daigahou/dsh-tokenlens.git
404
405
  cd dsh-tokenlens
405
- node --test # 256 个用例
406
+ node --test # 277 个用例
406
407
  ```
407
408
 
408
409
  本地联调安装方式见[方式 C](#方式-c本地开发从源码-link)。
package/client.js CHANGED
@@ -68,6 +68,14 @@ window.__ModuleLoader__.load({
68
68
  const MANAGEMENT_SYNC_MS = 15000
69
69
  /** Retry delay of a 明细-window save blocked by an in-flight settings request (v1.2.1). */
70
70
  const DETAILS_WINDOW_SAVE_RETRY_MS = 200
71
+ /**
72
+ * Delays of the network-free providers re-reads while 「启动时检查更新」 is on
73
+ * and no result exists yet: the Host's own startup / toggle check runs in
74
+ * the background, so hello or a settings answer can win the race. Two
75
+ * attempts cover a normal registry round-trip without ever asking the
76
+ * registry twice; the management page's silent check covers the rest.
77
+ */
78
+ const UPDATE_RESULT_REFRESH_MS = [1200, 4000]
71
79
  /** How many per-provider / per-model stats rows the dialog lists. */
72
80
  const MAX_PROVIDER_ROWS = 5
73
81
  const MAX_MODEL_ROWS = 3
@@ -137,7 +145,12 @@ window.__ModuleLoader__.load({
137
145
  defaultScope: 'provider',
138
146
  detailsExpanded: 'auto',
139
147
  detailsWindow: 'total',
148
+ // 「启动时检查更新」 is persisted too; off by default so an old Host and a
149
+ // fresh install never contact the npm registry (privacy first).
150
+ updateCheck: false,
140
151
  }
152
+ /** The npm package page the 「打开 npm」 update links point at. */
153
+ const NPM_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
141
154
 
142
155
  const zh = {
143
156
  title: '词元透镜(dsh-TokenLens)',
@@ -340,6 +353,18 @@ window.__ModuleLoader__.load({
340
353
  settingsDetailsCollapsed: '收起',
341
354
  settingsResetDefaults: '恢复默认设置',
342
355
  settingsResetDefaultsHint: '恢复为插件默认的显示设置',
356
+ updateCheckLabel: '启动时检查更新',
357
+ updateCheckHint: '仅当开启时才访问 npm registry 检查新版本;默认关闭',
358
+ updateCheckOff: '关闭',
359
+ updateCheckOn: '开启',
360
+ updateChecking: '正在检查…',
361
+ updateLatest: '已是最新版本 v{version}',
362
+ updateAvailable: '发现新版本 v{latest}(当前 v{current})',
363
+ updateCheckFailed: '检查失败',
364
+ updateCheckNow: '立即检查',
365
+ updateCheckNever: '尚未检查',
366
+ updateOpenNpm: '打开 npm',
367
+ updateDialogLine: '发现新版本 v{latest} · 打开 npm',
343
368
  providerBothLabels: '短标签:{short} · 全名:{full}',
344
369
  providerShortLabel: '短标签',
345
370
  providerShortLabelHint: '1–12 字符',
@@ -568,6 +593,18 @@ window.__ModuleLoader__.load({
568
593
  settingsDetailsCollapsed: 'Collapsed',
569
594
  settingsResetDefaults: 'Restore defaults',
570
595
  settingsResetDefaultsHint: 'Restore the plugin default display settings',
596
+ updateCheckLabel: 'Check for updates at startup',
597
+ updateCheckHint: 'Contacts the npm registry for a new version only when enabled; off by default',
598
+ updateCheckOff: 'Off',
599
+ updateCheckOn: 'On',
600
+ updateChecking: 'Checking…',
601
+ updateLatest: 'Up to date: v{version}',
602
+ updateAvailable: 'New version v{latest} available (current v{current})',
603
+ updateCheckFailed: 'Update check failed',
604
+ updateCheckNow: 'Check now',
605
+ updateCheckNever: 'Not checked yet',
606
+ updateOpenNpm: 'Open on npm',
607
+ updateDialogLine: 'New version v{latest} · Open on npm',
571
608
  providerBothLabels: 'Short label: {short} · Full name: {full}',
572
609
  providerShortLabel: 'Short label',
573
610
  providerShortLabelHint: '1–12 characters',
@@ -1111,9 +1148,9 @@ window.__ModuleLoader__.load({
1111
1148
  }
1112
1149
 
1113
1150
  /**
1114
- * The five display settings of the `providers` payload (v0.29.0; the 明细
1115
- * donut window joined them in v1.2.1), every field normalized to a usable
1116
- * value so old Hosts keep the previous defaults.
1151
+ * The five display settings plus 「启动时检查更新」 of the `providers`
1152
+ * payload, every field normalized to a usable value so old Hosts keep the
1153
+ * previous defaults.
1117
1154
  */
1118
1155
  function normalizePluginSettings(raw) {
1119
1156
  const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
@@ -1123,9 +1160,50 @@ window.__ModuleLoader__.load({
1123
1160
  defaultScope: normalizeDisplayScope(source.defaultScope),
1124
1161
  detailsExpanded: normalizeDetailsExpanded(source.detailsExpanded),
1125
1162
  detailsWindow: normalizeDetailsWindow(source.detailsWindow),
1163
+ // The startup update check is opt-in: a value other than `true` (an old
1164
+ // Host omits the field entirely) means off.
1165
+ updateCheck: source.updateCheck === true,
1166
+ }
1167
+ }
1168
+
1169
+ /**
1170
+ * The optional `update` object of the hello / providers envelope: the last
1171
+ * known update check result, or null when the Host never checked.
1172
+ *
1173
+ * Defensive by design — the update UI must never throw on hostile wire
1174
+ * data. String fields collapse to `''`/null, `available` is only claimed
1175
+ * when it is literally `true` *and* names a version, and a wire error keeps
1176
+ * the shared normalized error shape.
1177
+ */
1178
+ function normalizeUpdate(raw) {
1179
+ if (raw === null || raw === undefined || typeof raw !== 'object' || Array.isArray(raw)) return null
1180
+ const current = stringOrEmpty(raw.current).trim()
1181
+ const latestText = stringOrEmpty(raw.latest).trim()
1182
+ const latest = latestText === '' ? null : latestText
1183
+ return {
1184
+ current,
1185
+ latest,
1186
+ available: raw.available === true && latest !== null,
1187
+ checkedAt: stringOrEmpty(raw.checkedAt).trim(),
1188
+ error: raw.error === null || raw.error === undefined ? null : normalizeError(raw.error),
1126
1189
  }
1127
1190
  }
1128
1191
 
1192
+ /** Whether two normalized update infos are render-identical (avoids store churn). */
1193
+ function sameUpdate(a, b) {
1194
+ if (a === b) return true
1195
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
1196
+ if (a.current !== b.current || a.latest !== b.latest || a.available !== b.available || a.checkedAt !== b.checkedAt) {
1197
+ return false
1198
+ }
1199
+ if (a.error === null || b.error === null) return a.error === b.error
1200
+ return (
1201
+ a.error.code === b.error.code &&
1202
+ a.error.message === b.error.message &&
1203
+ a.error.retryable === b.error.retryable
1204
+ )
1205
+ }
1206
+
1129
1207
  /**
1130
1208
  * One route discovered in the DSH model settings (v0.15.0), or null when it
1131
1209
  * has no usable id. `supported` plus a `usageProviderId` means the plugin has
@@ -1184,6 +1262,10 @@ window.__ModuleLoader__.load({
1184
1262
  ? raw.dshProviders.map((entry) => normalizeDshProviderEntry(entry)).filter((entry) => entry !== null)
1185
1263
  : []
1186
1264
  const settings = raw.settings !== null && typeof raw.settings === 'object' ? raw.settings : {}
1265
+ // The update check result travels with every providers payload; `hasUpdate`
1266
+ // distinguishes "the Host said null" from "an old Host omitted the field",
1267
+ // so a hello-provided result is never wiped by a payload without it.
1268
+ const hasUpdate = Object.prototype.hasOwnProperty.call(raw, 'update')
1187
1269
  return {
1188
1270
  // v0.17.0 identity: dedupe by providerId, last-wins, so the filter tabs
1189
1271
  // never list the same provider twice even if the Host sends a duplicate.
@@ -1196,6 +1278,10 @@ window.__ModuleLoader__.load({
1196
1278
  storeFile: stringOrEmpty(raw.storeFile).trim(),
1197
1279
  storeFileDisplay: stringOrEmpty(raw.storeFileDisplay).trim(),
1198
1280
  settings: normalizePluginSettings(settings),
1281
+ update: hasUpdate ? normalizeUpdate(raw.update) : null,
1282
+ hasUpdate,
1283
+ // Optional: the Host may advertise the npm page its links should open.
1284
+ updatePackageUrl: stringOrEmpty(raw.updatePackageUrl).trim(),
1199
1285
  labelCatalog,
1200
1286
  }
1201
1287
  }
@@ -1773,6 +1859,16 @@ window.__ModuleLoader__.load({
1773
1859
  labelCatalog: {},
1774
1860
  // Plugin version from the hello envelope (dialog footnote).
1775
1861
  pluginVersion: '',
1862
+ // 「启动时检查更新」 (persisted setting) and the last check result the
1863
+ // hello / providers envelope reported, plus the in-flight flag of an
1864
+ // explicit 「立即检查」. The flag is deliberately independent from
1865
+ // `management.settingsBusy`: a check never blocks a settings save.
1866
+ updateCheck: DEFAULT_PLUGIN_SETTINGS.updateCheck,
1867
+ update: null,
1868
+ updateChecking: false,
1869
+ // The canonical npm page the update links open; the Host may advertise
1870
+ // its own (newer bundles may move the package), this is the fallback.
1871
+ updatePackageUrl: NPM_PACKAGE_URL,
1776
1872
  // Extra DSH-only detail blocks (provider split / top models). Collapsed by
1777
1873
  // default on short screens so the dialog fits without a scrollbar.
1778
1874
  showDetails:
@@ -1808,8 +1904,8 @@ window.__ModuleLoader__.load({
1808
1904
  let usageEpoch = 0
1809
1905
 
1810
1906
  /**
1811
- * The five display settings currently in effect (v0.29.0; the 明细 donut
1812
- * window joined them in v1.2.1).
1907
+ * The display settings currently in effect: the five v0.29.0 defaults (the
1908
+ * v1.2.1 明细 donut window included) plus 「启动时检查更新」.
1813
1909
  */
1814
1910
  function currentPluginSettings() {
1815
1911
  const state = store.get()
@@ -1819,6 +1915,7 @@ window.__ModuleLoader__.load({
1819
1915
  defaultScope: normalizeDisplayScope(state.defaultScope),
1820
1916
  detailsExpanded: normalizeDetailsExpanded(state.detailsExpanded),
1821
1917
  detailsWindow: normalizeDetailsWindow(state.detailsWindow),
1918
+ updateCheck: state.updateCheck === true,
1822
1919
  }
1823
1920
  }
1824
1921
 
@@ -2065,6 +2162,18 @@ window.__ModuleLoader__.load({
2065
2162
  if (options.adoptDetailsWindow === true && current.detailsWindow !== settings.detailsWindow) {
2066
2163
  patch.detailsWindow = settings.detailsWindow
2067
2164
  }
2165
+ // 「启动时检查更新」 is adopted on every answer (it is the persisted value
2166
+ // echoing back); the answered checkbox state must win after a toggle.
2167
+ if (current.updateCheck !== settings.updateCheck) patch.updateCheck = settings.updateCheck
2168
+ // A payload that carries `update` (even as null) is authoritative for the
2169
+ // last check result; one that omits it (old Host) keeps what hello said.
2170
+ if (parsed.hasUpdate === true && !sameUpdate(current.update, parsed.update)) {
2171
+ patch.update = parsed.update
2172
+ }
2173
+ // The Host-advertised npm page travels with the same payload.
2174
+ if (parsed.updatePackageUrl !== '' && current.updatePackageUrl !== parsed.updatePackageUrl) {
2175
+ patch.updatePackageUrl = parsed.updatePackageUrl
2176
+ }
2068
2177
  if (parsed.labelCatalog !== null && typeof parsed.labelCatalog === 'object') {
2069
2178
  const catalog = { ...current.labelCatalog, ...parsed.labelCatalog }
2070
2179
  if (Object.keys(catalog).length > 0) patch.labelCatalog = catalog
@@ -2123,6 +2232,9 @@ window.__ModuleLoader__.load({
2123
2232
  storeFileDisplay: parsed !== null ? parsed.storeFileDisplay : '',
2124
2233
  loadedAt: Date.now(),
2125
2234
  })
2235
+ // 「启动时检查更新」: the first management-page load runs one silent
2236
+ // check when the setting is on and no result is known yet.
2237
+ maybeAutoUpdateCheck()
2126
2238
  return { kind: 'ok' }
2127
2239
  }
2128
2240
  patchManagement({ inFlight: false, phase: 'error', error: normalizeError(data.error) })
@@ -2273,6 +2385,10 @@ window.__ModuleLoader__.load({
2273
2385
  }
2274
2386
  : {}),
2275
2387
  })
2388
+ // Turning 「启动时检查更新」 on makes the Host run one check right away;
2389
+ // its answer may still be in flight, so read the local result shortly
2390
+ // after (no extra registry request).
2391
+ if (store.get().updateCheck === true && store.get().update === null) scheduleUpdateResultRefresh()
2276
2392
  return { kind: 'ok' }
2277
2393
  }
2278
2394
  patchManagement({ settingsBusy: false, settingsError: normalizeError(data.error) })
@@ -2331,6 +2447,128 @@ window.__ModuleLoader__.load({
2331
2447
  void requestSettingsUpdate({ ...DEFAULT_PLUGIN_SETTINGS })
2332
2448
  }
2333
2449
 
2450
+ /** Guards the one silent check the management page may start after opening. */
2451
+ let autoUpdateCheckStarted = false
2452
+ /** Whether the settings error banner currently shows an update-check failure. */
2453
+ let updateCheckErrorShown = false
2454
+ /** One delayed retry when the Host says "a check is already running". */
2455
+ const UPDATE_CHECK_RETRY_MS = 1200
2456
+ let updateCheckRetryTimer = null
2457
+
2458
+ /**
2459
+ * Run one update check (「立即检查」, and the management page's one silent
2460
+ * startup check when 「启动时检查更新」 is on).
2461
+ *
2462
+ * `updateChecking` is an independent flag, never `settingsBusy`: a check may
2463
+ * not block a settings save (and vice versa). A Host-level failure travels
2464
+ * inside `update.error` (the status row renders 检查失败); a transport or
2465
+ * envelope failure uses the management page's existing error banner. Never
2466
+ * throws.
2467
+ */
2468
+ async function requestUpdateCheck(options = {}) {
2469
+ const retried = options.retried === true
2470
+ if (store.get().updateChecking === true) return { kind: 'busy' }
2471
+ store.set({ updateChecking: true })
2472
+ let data
2473
+ try {
2474
+ data = await call({ action: 'updateCheck' })
2475
+ } catch (error) {
2476
+ store.set({ updateChecking: false })
2477
+ updateCheckErrorShown = true
2478
+ patchManagement({ settingsError: managementErrorOf(error) })
2479
+ return { kind: 'transport' }
2480
+ }
2481
+ if (data !== null && typeof data === 'object' && typeof data.ok === 'boolean') {
2482
+ if (data.ok === true) {
2483
+ const update = normalizeUpdate(data.update)
2484
+ if (update !== null) {
2485
+ store.set({ updateChecking: false, update })
2486
+ // A successful retry retires the banner of the failed one instead of
2487
+ // leaving a stale check failure above a fresh result.
2488
+ if (updateCheckErrorShown === true) {
2489
+ updateCheckErrorShown = false
2490
+ patchManagement({ settingsError: null })
2491
+ }
2492
+ return { kind: 'ok' }
2493
+ }
2494
+ // A first-ever check that is already in flight comes back as
2495
+ // `{ok:true, update:null}` (the Host's single-flight returns the last
2496
+ // result). Retry once after a short delay instead of reporting a bad
2497
+ // response; a result that arrived meanwhile (management sync) retires
2498
+ // the retry.
2499
+ if (!retried) {
2500
+ if (updateCheckRetryTimer !== null) window.clearTimeout(updateCheckRetryTimer)
2501
+ updateCheckRetryTimer = window.setTimeout(() => {
2502
+ updateCheckRetryTimer = null
2503
+ store.set({ updateChecking: false })
2504
+ if (store.get().update !== null) return
2505
+ void requestUpdateCheck({ retried: true })
2506
+ }, UPDATE_CHECK_RETRY_MS)
2507
+ return { kind: 'pending' }
2508
+ }
2509
+ store.set({ updateChecking: false })
2510
+ return { kind: 'pending' }
2511
+ }
2512
+ store.set({ updateChecking: false })
2513
+ updateCheckErrorShown = true
2514
+ patchManagement({ settingsError: normalizeError(data.error) })
2515
+ return { kind: 'envelope' }
2516
+ }
2517
+ store.set({ updateChecking: false })
2518
+ updateCheckErrorShown = true
2519
+ patchManagement({
2520
+ settingsError: { code: 'internal', message: 'unexpected updateCheck response', retryable: true },
2521
+ })
2522
+ return { kind: 'transport' }
2523
+ }
2524
+
2525
+ /**
2526
+ * The management page's ONE silent check: only while the setting is on and
2527
+ * no result is known yet. The module-level flag makes a re-open, a tab
2528
+ * switch or a provider-list refresh impossible to turn into a second check.
2529
+ */
2530
+ function maybeAutoUpdateCheck() {
2531
+ if (autoUpdateCheckStarted === true) return
2532
+ const state = store.get()
2533
+ if (state.updateCheck !== true || state.update !== null) return
2534
+ autoUpdateCheckStarted = true
2535
+ void requestUpdateCheck()
2536
+ }
2537
+
2538
+ /** The pending network-free providers re-read (see UPDATE_RESULT_REFRESH_MS). */
2539
+ let updateResultRefreshTimer = null
2540
+ let updateResultRefreshAttempt = 0
2541
+
2542
+ /**
2543
+ * Read the local providers payload once more after the Host's background
2544
+ * update check had time to finish (`updateCheck` on, no result known yet).
2545
+ *
2546
+ * This never asks the npm registry itself — it only re-reads the Host state
2547
+ * that the Host's own startup / toggle check fills in. No-op once a result
2548
+ * exists or the setting is off, so it can be armed from several places.
2549
+ */
2550
+ function scheduleUpdateResultRefresh() {
2551
+ if (updateResultRefreshTimer !== null) return
2552
+ const delay = UPDATE_RESULT_REFRESH_MS[updateResultRefreshAttempt]
2553
+ if (delay === undefined) return
2554
+ updateResultRefreshAttempt += 1
2555
+ updateResultRefreshTimer = window.setTimeout(() => {
2556
+ updateResultRefreshTimer = null
2557
+ const state = store.get()
2558
+ if (state.updateCheck !== true || state.update !== null) {
2559
+ updateResultRefreshAttempt = 0
2560
+ return
2561
+ }
2562
+ void bootstrapProviders().then(() => {
2563
+ if (store.get().update !== null) {
2564
+ updateResultRefreshAttempt = 0
2565
+ return
2566
+ }
2567
+ scheduleUpdateResultRefresh()
2568
+ })
2569
+ }, delay)
2570
+ }
2571
+
2334
2572
  /** Force one upstream read for a provider and keep the row outcome. Never throws. */
2335
2573
  async function requestProviderTest(providerId) {
2336
2574
  const before = store.get().management
@@ -2519,6 +2757,9 @@ window.__ModuleLoader__.load({
2519
2757
  const current = store.get().management
2520
2758
  const stale = current.loadedAt === 0 || Date.now() - current.loadedAt >= PROVIDERS_TTL_MS
2521
2759
  if (current.inFlight !== true && stale) void requestProviders()
2760
+ // A fresh check when the setting is already known and no result exists
2761
+ // (the requestProviders answer triggers the same one-shot path otherwise).
2762
+ maybeAutoUpdateCheck()
2522
2763
  }
2523
2764
 
2524
2765
  /** Switch back to the usage page (the modal chrome stays). */
@@ -2633,12 +2874,20 @@ window.__ModuleLoader__.load({
2633
2874
  void requestProviderUpdate(provider.providerId, { shortLabel: '', label: '' })
2634
2875
  }
2635
2876
 
2636
- /** Store the plugin version reported by the hello envelope (dialog footnote). */
2877
+ /** Store the plugin version and the last update check result of the hello envelope. */
2637
2878
  function applyHelloInfo(data) {
2638
2879
  if (data === null || typeof data !== 'object') return
2880
+ const patch = {}
2639
2881
  if (typeof data.version === 'string' && data.version !== '') {
2640
- if (store.get().pluginVersion !== data.version) store.set({ pluginVersion: data.version })
2882
+ if (store.get().pluginVersion !== data.version) patch.pluginVersion = data.version
2883
+ }
2884
+ // `update` is optional on the wire: only an explicit field (null included)
2885
+ // replaces what the store already knows.
2886
+ if (Object.prototype.hasOwnProperty.call(data, 'update')) {
2887
+ const update = normalizeUpdate(data.update)
2888
+ if (!sameUpdate(store.get().update, update)) patch.update = update
2641
2889
  }
2890
+ if (Object.keys(patch).length > 0) store.set(patch)
2642
2891
  }
2643
2892
 
2644
2893
  /** Announce this browser half to the Host (also records the current provider). */
@@ -2772,6 +3021,11 @@ window.__ModuleLoader__.load({
2772
3021
  // v0.10.0: learn the persisted chip label style + wire labels once, without
2773
3022
  // touching the management page state (silent when an old Host answers nothing).
2774
3023
  void bootstrapProviders()
3024
+ // The Host runs its optional startup update check in the background; the
3025
+ // hello / bootstrap answers above can win the race and carry no result yet.
3026
+ // Re-read the (local, no-network) providers payload shortly after so the
3027
+ // dialog footer can show the result without opening the management page.
3028
+ scheduleUpdateResultRefresh()
2775
3029
  pollTimer = window.setInterval(() => {
2776
3030
  void poll()
2777
3031
  }, POLL_INTERVAL_MS)
@@ -2783,6 +3037,16 @@ window.__ModuleLoader__.load({
2783
3037
  disposed = true
2784
3038
  if (pollTimer !== null) window.clearInterval(pollTimer)
2785
3039
  if (retryTimer !== null) window.clearTimeout(retryTimer)
3040
+ // Pending update-check timers must not fire into a disposed controller.
3041
+ updateResultRefreshAttempt = 0
3042
+ if (updateResultRefreshTimer !== null) {
3043
+ window.clearTimeout(updateResultRefreshTimer)
3044
+ updateResultRefreshTimer = null
3045
+ }
3046
+ if (updateCheckRetryTimer !== null) {
3047
+ window.clearTimeout(updateCheckRetryTimer)
3048
+ updateCheckRetryTimer = null
3049
+ }
2786
3050
  if (typeof document !== 'undefined' && typeof document.removeEventListener === 'function') {
2787
3051
  document.removeEventListener('visibilitychange', onVisibilityChange)
2788
3052
  }
@@ -3118,6 +3382,20 @@ window.__ModuleLoader__.load({
3118
3382
  .dsh-tokenlens-settings-options .dsh-tokenlens-settings-option{display:inline-flex;align-items:center;gap:4px}
3119
3383
  .dsh-tokenlens-settings-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:8px;min-width:0}
3120
3384
  @media (max-width:560px){.dsh-tokenlens-settings-row{grid-template-columns:minmax(0,1fr)}}
3385
+ /* Update check block (update UI): toggle row, privacy hint, status line + 立即检查. */
3386
+ .dsh-tokenlens-update-section{display:flex;flex-direction:column;gap:6px;min-width:0;margin-top:2px;padding-top:8px;border-top:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.18))}
3387
+ .dsh-tokenlens-update-hint{opacity:.8;font-size:11px}
3388
+ .dsh-tokenlens-update-status{align-items:center;flex-wrap:wrap;display:flex;gap:5px;min-width:0;color:var(--dsw-alias-label-secondary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4;overflow-wrap:anywhere}
3389
+ .dsh-tokenlens-update-status[data-kind=error]{color:var(--dsw-alias-label-error,#d9534f)}
3390
+ .dsh-tokenlens-update-status[data-kind=available]{color:var(--dsw-alias-label-primary)}
3391
+ .dsh-tokenlens-update-error-detail{opacity:.85;min-width:0;overflow-wrap:anywhere}
3392
+ .dsh-tokenlens-update-retry{font:inherit;font-size:11px;line-height:inherit;padding:0;border:0;background:0 0;color:inherit;cursor:pointer;text-decoration:underline;text-underline-offset:2px}
3393
+ .dsh-tokenlens-update-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;min-width:0}
3394
+ .dsh-tokenlens-update-link{display:inline-flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary);font-size:inherit;text-decoration:underline;text-underline-offset:2px;min-width:0}
3395
+ .dsh-tokenlens-update-link:hover,.dsh-tokenlens-update-retry:hover{color:var(--dsw-alias-label-primary)}
3396
+ .dsh-tokenlens-update-link:focus-visible,.dsh-tokenlens-update-retry:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-strong,rgba(127,127,127,.5));border-radius:2px}
3397
+ .dsh-tokenlens-update-line{display:inline-flex;flex-wrap:wrap;align-items:center;gap:4px 6px;min-width:0;margin-right:auto;color:var(--dsw-alias-label-tertiary);font-size:var(--dsw-alias-font-size-sm,12px);line-height:1.4}
3398
+ .dsh-tokenlens-update-line .dsh-tokenlens-update-link{color:inherit}
3121
3399
  .dsh-tokenlens-provider-labels{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;overflow-wrap:anywhere;display:flex;flex-wrap:wrap;align-items:flex-start;gap:4px}
3122
3400
  .dsh-tokenlens-provider-labels .dsh-tokenlens-icon{margin-top:1px}
3123
3401
  /* Inline icons: currentColor stroke, never a text node (keeps textOf stable). */
@@ -5891,11 +6169,13 @@ window.__ModuleLoader__.load({
5891
6169
  h(
5892
6170
  'button',
5893
6171
  {
5894
- key: option.value,
6172
+ key: String(option.value),
5895
6173
  type: 'button',
5896
6174
  className: `dsh-tokenlens-scope-option dsh-tokenlens-settings-option${field === 'labelStyle' ? ' dsh-tokenlens-label-style-option' : ''}`,
5897
6175
  'data-setting': field,
5898
- 'data-value': option.value,
6176
+ // A boolean row (updateCheck) is stringified so `data-*` matches
6177
+ // the real DOM; the string rows keep their wire value verbatim.
6178
+ 'data-value': typeof option.value === 'string' ? option.value : String(option.value),
5899
6179
  'data-label-style': field === 'labelStyle' ? option.value : undefined,
5900
6180
  'data-active': active === option.value ? 'true' : 'false',
5901
6181
  'aria-pressed': active === option.value ? 'true' : 'false',
@@ -5913,6 +6193,127 @@ window.__ModuleLoader__.load({
5913
6193
  )
5914
6194
  }
5915
6195
 
6196
+ /** The npm page the update links open (the Host-advertised one when present). */
6197
+ function updatePackageUrlOf(state) {
6198
+ return typeof state?.updatePackageUrl === 'string' && state.updatePackageUrl !== ''
6199
+ ? state.updatePackageUrl
6200
+ : NPM_PACKAGE_URL
6201
+ }
6202
+
6203
+ /**
6204
+ * The 「更新检查」 status line: checking → failed → update available → up to
6205
+ * date → never checked. `data-update-status` names the branch for tests and
6206
+ * styling; the error branch keeps a 重试 action only when retryable.
6207
+ */
6208
+ function buildUpdateStatusLine(state, t) {
6209
+ const update = state.update
6210
+ const packageUrl = updatePackageUrlOf(state)
6211
+ const line = (kind, children) =>
6212
+ h(
6213
+ 'div',
6214
+ {
6215
+ key: 'update-status',
6216
+ className: 'dsh-tokenlens-update-status',
6217
+ 'data-update-status': kind,
6218
+ 'data-kind': kind,
6219
+ role: 'status',
6220
+ },
6221
+ Icon(kind === 'error' ? 'alert' : kind === 'available' ? 'bolt' : kind === 'latest' ? 'check' : 'info', 12),
6222
+ children,
6223
+ )
6224
+ if (state.updateChecking === true) {
6225
+ return line('checking', h('span', { key: 'text' }, t('updateChecking')))
6226
+ }
6227
+ if (update !== null && update.error !== null) {
6228
+ return line('error', [
6229
+ h('span', { key: 'text' }, t('updateCheckFailed')),
6230
+ h('span', { key: 'detail', className: 'dsh-tokenlens-update-error-detail', title: update.error.message }, managementErrorText(update.error, t)),
6231
+ update.error.retryable === true
6232
+ ? h(
6233
+ 'button',
6234
+ {
6235
+ key: 'retry',
6236
+ type: 'button',
6237
+ className: 'dsh-tokenlens-update-retry',
6238
+ 'data-update-retry': 'true',
6239
+ onClick: () => {
6240
+ void requestUpdateCheck()
6241
+ },
6242
+ },
6243
+ t('retry'),
6244
+ )
6245
+ : null,
6246
+ ])
6247
+ }
6248
+ if (update !== null && update.available === true) {
6249
+ return line('available', [
6250
+ h('span', { key: 'text' }, t('updateAvailable', { latest: update.latest, current: update.current })),
6251
+ h(
6252
+ 'a',
6253
+ {
6254
+ key: 'link',
6255
+ className: 'dsh-tokenlens-update-link',
6256
+ 'data-update-link': 'true',
6257
+ href: packageUrl,
6258
+ target: '_blank',
6259
+ rel: 'noreferrer noopener',
6260
+ title: packageUrl,
6261
+ onClick: (event) => openConsole(packageUrl, event),
6262
+ },
6263
+ Icon('external', 12),
6264
+ t('updateOpenNpm'),
6265
+ ),
6266
+ ])
6267
+ }
6268
+ if (update !== null && update.latest !== null && update.available === false) {
6269
+ return line('latest', h('span', { key: 'text' }, t('updateLatest', { version: update.latest })))
6270
+ }
6271
+ return line('never', h('span', { key: 'text' }, t('updateCheckNever')))
6272
+ }
6273
+
6274
+ /**
6275
+ * The 「更新检查」 block of 插件设置: the startup toggle (the shared settings
6276
+ * row), the registry-privacy hint, the last check result and 「立即检查」.
6277
+ * The check button works whether the startup toggle is on or off, and its
6278
+ * `updateChecking` flag is independent from `settingsBusy`.
6279
+ */
6280
+ function buildUpdateSection(state, t) {
6281
+ const checking = state.updateChecking === true
6282
+ return h(
6283
+ 'div',
6284
+ { key: 'update-section', className: 'dsh-tokenlens-update-section', 'data-update-section': 'true' },
6285
+ buildSettingsRow(state, t, 'updateCheck', t('updateCheckLabel'), [
6286
+ { value: false, label: t('updateCheckOff'), icon: 'ban' },
6287
+ { value: true, label: t('updateCheckOn'), icon: 'check' },
6288
+ ]),
6289
+ h(
6290
+ 'div',
6291
+ { key: 'hint', className: 'dsh-tokenlens-subtitle dsh-tokenlens-update-hint', 'data-update-hint': 'true' },
6292
+ t('updateCheckHint'),
6293
+ ),
6294
+ buildUpdateStatusLine(state, t),
6295
+ h(
6296
+ 'div',
6297
+ { key: 'actions', className: 'dsh-tokenlens-update-actions' },
6298
+ h(
6299
+ Button,
6300
+ {
6301
+ variant: 'outline',
6302
+ className: 'dsh-tokenlens-update-check',
6303
+ 'data-update-check-now': 'true',
6304
+ title: t('updateCheckNow'),
6305
+ disabled: checking,
6306
+ onClick: () => {
6307
+ void requestUpdateCheck()
6308
+ },
6309
+ },
6310
+ Icon('spinner', 12),
6311
+ checking ? t('updateChecking') : t('updateCheckNow'),
6312
+ ),
6313
+ ),
6314
+ )
6315
+ }
6316
+
5916
6317
  /** The 插件设置 tab (v0.29.0): display defaults, reset button and store path. */
5917
6318
  function buildSettingsPanel(state, t) {
5918
6319
  const management = state.management
@@ -5937,6 +6338,9 @@ window.__ModuleLoader__.load({
5937
6338
  { value: 'collapsed', label: t('settingsDetailsCollapsed'), icon: 'list' },
5938
6339
  ]),
5939
6340
  ]
6341
+ // 「更新检查」 is its own block (own status + own in-flight flag), kept
6342
+ // apart from the display-defaults rows and their reset button.
6343
+ children.push(buildUpdateSection(state, t))
5940
6344
  const actions = h(
5941
6345
  'div',
5942
6346
  { key: 'actions', className: 'dsh-tokenlens-settings-actions' },
@@ -6381,6 +6785,46 @@ window.__ModuleLoader__.load({
6381
6785
  })
6382
6786
  }
6383
6787
 
6788
+ /**
6789
+ * The usage dialog's 「发现新版本」 footer line, or null when the last check
6790
+ * found no update (or never ran). The i18n sentence names 「打开 npm」 as its
6791
+ * tail; that tail becomes the anchor that opens the npm package page, so the
6792
+ * line itself stays a single sentence with one real link inside it.
6793
+ */
6794
+ function buildUpdateFooterLine(state, t) {
6795
+ const update = state.update
6796
+ if (update === null || update.available !== true) return null
6797
+ const text = t('updateDialogLine', { latest: update.latest })
6798
+ const linkLabel = t('updateOpenNpm')
6799
+ const packageUrl = updatePackageUrlOf(state)
6800
+ const link = h(
6801
+ 'a',
6802
+ {
6803
+ key: 'link',
6804
+ className: 'dsh-tokenlens-update-link dsh-tokenlens-update-footer-link',
6805
+ 'data-update-link': 'true',
6806
+ href: packageUrl,
6807
+ target: '_blank',
6808
+ rel: 'noreferrer noopener',
6809
+ title: packageUrl,
6810
+ onClick: (event) => openConsole(packageUrl, event),
6811
+ },
6812
+ linkLabel,
6813
+ Icon('external', 12),
6814
+ )
6815
+ const index = text.indexOf(linkLabel)
6816
+ const children =
6817
+ index < 0
6818
+ ? [h('span', { key: 'text' }, text), h('span', { key: 'sep' }, ' · '), link]
6819
+ : [h('span', { key: 'text' }, text.slice(0, index)), link]
6820
+ return h(
6821
+ 'div',
6822
+ { key: 'update-line', className: 'dsh-tokenlens-update-line', 'data-update-line': 'true' },
6823
+ Icon('bolt', 12),
6824
+ children,
6825
+ )
6826
+ }
6827
+
6384
6828
  /** The dialog contents (body + footer); may throw, the caller guards it. */
6385
6829
  function buildDialog(state, now, t) {
6386
6830
  if (state.view === 'management') return buildManagementDialog(state, t)
@@ -6440,6 +6884,9 @@ window.__ModuleLoader__.load({
6440
6884
  Icon('external', 12),
6441
6885
  consoleLabel,
6442
6886
  ),
6887
+ // 「发现新版本」 line (only while an update is available): its 「打开 npm」
6888
+ // tail is the real npm-package link, next to the refresh/close buttons.
6889
+ buildUpdateFooterLine(state, t),
6443
6890
  h(
6444
6891
  Button,
6445
6892
  {
package/index.js CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  } from './lib/provider-store.mjs'
38
38
  import { makeCustomAdapter, sanitizeUsageBaseURL, validateCustomProvider } from './lib/custom-provider.mjs'
39
39
  import { redactEndpoint, redactPaths, redactTextPaths } from './lib/redact.mjs'
40
+ import { UPDATE_CHECK_URL, UPDATE_PACKAGE_URL, buildUpdateInfo, parseLatestVersion } from './lib/update-check.mjs'
40
41
 
41
42
  /**
42
43
  * The one browser-facing route this plugin owns.
@@ -60,10 +61,11 @@ export const ACTIONS = [
60
61
  'settingsUpdate',
61
62
  'customProviderSave',
62
63
  'customProviderDelete',
64
+ 'updateCheck',
63
65
  ]
64
66
 
65
67
  /** The tool-facing subset (a model has no reason to announce a browser). */
66
- export const TOOL_ACTIONS = ['snapshot', 'refresh', 'stats', 'diagnostics', 'status']
68
+ export const TOOL_ACTIONS = ['snapshot', 'refresh', 'stats', 'diagnostics', 'status', 'updateCheck']
67
69
 
68
70
  /** Optional services; every one is probed with `ctx.get` at use time. */
69
71
  export const inject = []
@@ -72,7 +74,7 @@ export const inject = []
72
74
  export const PLUGIN_NAME = 'dsh-tokenlens'
73
75
 
74
76
  /** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
75
- export const PLUGIN_VERSION = '1.2.2'
77
+ export const PLUGIN_VERSION = '1.3.0'
76
78
 
77
79
  /** Documented defaults; every field of the raw row config is optional. */
78
80
  const DEFAULTS = {
@@ -282,6 +284,11 @@ export function apply(ctx, rawConfig) {
282
284
  requestCounts: new Map(), // providerId 鈫?successful reads
283
285
  subscribers: new Set(),
284
286
  client: null, // last browser `hello`
287
+ // Optional startup update check (settings.updateCheck): the last result
288
+ // `{current, latest, available, checkedAt, error}` or null before the first
289
+ // check. Never fetched unless the user enables the setting.
290
+ update: null,
291
+ updateChecking: false,
285
292
  bridge: { found: false, registered: false, path: BRIDGE, error: null },
286
293
  poll: { timer: null },
287
294
  status: { timer: null },
@@ -729,6 +736,11 @@ export function apply(ctx, rawConfig) {
729
736
  providers,
730
737
  dshProviders,
731
738
  settings: sanitizeSettings(providerStore.settings),
739
+ // Optional startup update check (settings.updateCheck): null until the
740
+ // first check; never fetched unless the setting is enabled or the user
741
+ // presses "check now".
742
+ update: state.update,
743
+ updatePackageUrl: UPDATE_PACKAGE_URL,
732
744
  storeFile: redactPaths(providerStoreFile),
733
745
  storeFileDisplay: redactPaths(providerStoreFile),
734
746
  }
@@ -876,6 +888,53 @@ export function apply(ctx, rawConfig) {
876
888
  return requestJson(url, { bearer: keyValue })
877
889
  }
878
890
 
891
+ // Single-flight guard for the optional update check (see runUpdateCheck).
892
+ let updateFlight = null
893
+
894
+ /**
895
+ * Ask the npm registry for the published `latest` version and remember the
896
+ * comparison result. This is the plugin's only non-provider outbound request
897
+ * and it only ever happens when the user enabled `settings.updateCheck`
898
+ * (startup / toggling it on) or presses "check now" on the management page.
899
+ *
900
+ * Single-flight: a concurrent call awaits the same in-flight check instead of
901
+ * answering `null`, and the function never throws — an offline machine or an
902
+ * upstream failure is stored as an error state so the UI can explain it.
903
+ */
904
+ async function runUpdateCheck() {
905
+ if (updateFlight !== null) return updateFlight
906
+ updateFlight = (async () => {
907
+ state.updateChecking = true
908
+ let latest = null
909
+ let failure = null
910
+ try {
911
+ const payload = await requestJson(UPDATE_CHECK_URL)
912
+ latest = parseLatestVersion(payload)
913
+ if (latest === null) {
914
+ throw new UsageError('parse', 'the npm registry response has no usable version', { retryable: false })
915
+ }
916
+ } catch (error) {
917
+ failure = errorToWire(error)
918
+ } finally {
919
+ state.updateChecking = false
920
+ }
921
+ state.update = buildUpdateInfo(PLUGIN_VERSION, latest, new Date().toISOString(), failure)
922
+ noteStatus('update-check')
923
+ announce()
924
+ if (failure !== null) {
925
+ warn(`update check failed: ${failure.code}: ${failure.message}`)
926
+ } else if (state.update.available === true) {
927
+ log('info', `update available: ${state.update.latest} (running ${PLUGIN_VERSION})`)
928
+ }
929
+ return state.update
930
+ })()
931
+ try {
932
+ return await updateFlight
933
+ } finally {
934
+ updateFlight = null
935
+ }
936
+ }
937
+
879
938
  /** Read one provider's usage: resolve its key (when it has one), call its adapter. */
880
939
  async function readProvider(providerId, now) {
881
940
  const id = canonicalProviderId(providerId)
@@ -1391,6 +1450,7 @@ export function apply(ctx, rawConfig) {
1391
1450
  activeProvider: activeProviderId(),
1392
1451
  client: state.client,
1393
1452
  bridge: { ...state.bridge },
1453
+ update: state.update,
1394
1454
  providers,
1395
1455
  stats: statsService?.diagnostics?.() ?? null,
1396
1456
  subscribers: state.subscribers.size,
@@ -1586,7 +1646,15 @@ export function apply(ctx, rawConfig) {
1586
1646
  log('info', `browser half connected from ${String(state.client.origin ?? 'unknown origin')} (${providerId ?? 'default provider'})`)
1587
1647
  // Answer with a snapshot too, so the first paint needs one round trip.
1588
1648
  const first = await get(requestProvider(body))
1589
- return json({ ok: true, plugin: PLUGIN_NAME, version: PLUGIN_VERSION, bridge: BRIDGE, pid: process.pid, ...first })
1649
+ return json({
1650
+ ok: true,
1651
+ plugin: PLUGIN_NAME,
1652
+ version: PLUGIN_VERSION,
1653
+ bridge: BRIDGE,
1654
+ pid: process.pid,
1655
+ update: state.update,
1656
+ ...first,
1657
+ })
1590
1658
  }
1591
1659
  if (action === 'snapshot' || action === 'status') {
1592
1660
  rememberRequestedProvider(body)
@@ -1598,6 +1666,7 @@ export function apply(ctx, rawConfig) {
1598
1666
  }
1599
1667
  if (action === 'stats') return json(await getStats({ fresh: body?.fresh === true }))
1600
1668
  if (action === 'providers') return json(await providersPayload())
1669
+ if (action === 'updateCheck') return json({ ok: true, update: await runUpdateCheck() })
1601
1670
  if (action === 'customProviderSave') {
1602
1671
  const def = validateCustomProvider(body?.provider ?? body)
1603
1672
  if (PROVIDER_DEFAULTS[def.providerId] !== undefined || ADAPTERS[def.providerId] !== undefined) {
@@ -1668,11 +1737,23 @@ export function apply(ctx, rawConfig) {
1668
1737
  }
1669
1738
  next.detailsWindow = incoming.detailsWindow
1670
1739
  }
1740
+ if (Object.prototype.hasOwnProperty.call(incoming, 'updateCheck')) {
1741
+ if (typeof incoming.updateCheck !== 'boolean') {
1742
+ throw new UsageError('unsupported-provider', 'settings.updateCheck must be a boolean', {
1743
+ retryable: false,
1744
+ })
1745
+ }
1746
+ next.updateCheck = incoming.updateCheck
1747
+ }
1671
1748
  providerStore = saveProviderStore(providerStoreFile, {
1672
1749
  ...providerStore,
1673
1750
  settings: next,
1674
1751
  })
1675
1752
  noteStatus('settings-update', { providerStoreFile })
1753
+ // Turning the optional update check ON runs one check right away, so the
1754
+ // management page can show a result instead of waiting for a restart. A
1755
+ // full-settings save while it is already on must not re-request npm.
1756
+ if (incoming.updateCheck === true && current.updateCheck !== true) void runUpdateCheck()
1676
1757
  return json(await providersPayload())
1677
1758
  }
1678
1759
  if (action === 'providerUpdate') {
@@ -1765,6 +1846,12 @@ export function apply(ctx, rawConfig) {
1765
1846
  }
1766
1847
 
1767
1848
  noteStatus('activate', { pollIntervalMs: config.pollIntervalMs })
1849
+ // Optional startup update check: one plain npm-registry request, only when the
1850
+ // user enabled `settings.updateCheck`. Off by default to keep the plugin
1851
+ // local-only; failures are recorded and never break activation.
1852
+ if (sanitizeSettings(providerStore.settings).updateCheck === true) {
1853
+ void runUpdateCheck()
1854
+ }
1768
1855
  log(
1769
1856
  'info',
1770
1857
  `ready (providers ${Object.keys(config.providers).filter((id) => config.providers[id].enabled).join(', ') || 'none'}, cache ${config.minRefreshIntervalMs} ms, poll ${config.pollIntervalMs} ms)`,
@@ -40,6 +40,9 @@ export const DEFAULT_SETTINGS = {
40
40
  detailsExpanded: 'auto',
41
41
  // v1.2.1 图表明细窗口默认值: 总计 (the last choice is persisted there).
42
42
  detailsWindow: 'total',
43
+ // Optional startup update check (npm registry); off by default so the plugin
44
+ // stays local-only unless the user opts in.
45
+ updateCheck: false,
43
46
  }
44
47
 
45
48
  /** The default override file: `$DSH_HOME/dsh-tokenlens/providers.json`. */
@@ -126,6 +129,7 @@ export function sanitizeSettings(value) {
126
129
  detailsWindow: DETAILS_WINDOWS.includes(source.detailsWindow)
127
130
  ? source.detailsWindow
128
131
  : DEFAULT_SETTINGS.detailsWindow,
132
+ updateCheck: source.updateCheck === true,
129
133
  }
130
134
  }
131
135
 
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Optional startup update check.
3
+ *
4
+ * The plugin stays local-only by default: nothing is sent anywhere unless the
5
+ * user enables `settings.updateCheck` in the management page. When enabled, one
6
+ * HTTPS GET asks the npm registry for the published `latest` version and the
7
+ * result is surfaced on the management page (and as a line in the detail dialog
8
+ * footer). No identifiers, credentials or usage data are sent — only a plain
9
+ * version-metadata request with the plugin's user agent.
10
+ */
11
+
12
+ /** The npm registry endpoint that answers the published `latest` version. */
13
+ export const UPDATE_CHECK_URL = 'https://registry.npmjs.org/dsh-tokenlens/latest'
14
+
15
+ /** The canonical npm page of the package (for the "open on npm" link). */
16
+ export const UPDATE_PACKAGE_URL = 'https://www.npmjs.com/package/dsh-tokenlens'
17
+
18
+ /**
19
+ * Parse one version string into comparable parts.
20
+ *
21
+ * Accepts `1.2.3`, `v1.2.3` and an optional prerelease (`1.3.0-beta.1`). Junk,
22
+ * empty strings and absurdly long input return null.
23
+ */
24
+ export function parseVersion(raw) {
25
+ if (typeof raw !== 'string') return null
26
+ const text = raw.trim()
27
+ if (text === '' || text.length > 64) return null
28
+ const match = /^v?(\d{1,9})\.(\d{1,9})\.(\d{1,9})(?:-([0-9A-Za-z.-]{1,40}))?$/.exec(text)
29
+ if (match === null) return null
30
+ return {
31
+ major: Number(match[1]),
32
+ minor: Number(match[2]),
33
+ patch: Number(match[3]),
34
+ pre: match[4] ?? '',
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Compare two versions (`> 0` when `left` is newer, `< 0` when older, `0` when
40
+ * equal). A release always outranks its prereleases; prereleases compare as
41
+ * strings, which is enough for this plugin's plain `x.y.z` history.
42
+ */
43
+ export function compareVersions(left, right) {
44
+ const a = parseVersion(left)
45
+ const b = parseVersion(right)
46
+ if (a === null || b === null) return null
47
+ for (const key of ['major', 'minor', 'patch']) {
48
+ if (a[key] !== b[key]) return a[key] - b[key]
49
+ }
50
+ if (a.pre === b.pre) return 0
51
+ if (a.pre === '') return 1
52
+ if (b.pre === '') return -1
53
+ return a.pre < b.pre ? -1 : 1
54
+ }
55
+
56
+ /** Whether `latest` is strictly newer than `current` (false for junk input). */
57
+ export function isNewerVersion(latest, current) {
58
+ const compared = compareVersions(latest, current)
59
+ return compared !== null && compared > 0
60
+ }
61
+
62
+ /** The `version` field of the npm `/latest` document, or null. */
63
+ export function parseLatestVersion(payload) {
64
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
65
+ const version = payload.version
66
+ if (typeof version !== 'string') return null
67
+ const trimmed = version.trim()
68
+ return parseVersion(trimmed) === null ? null : trimmed
69
+ }
70
+
71
+ /**
72
+ * The wire shape the client renders:
73
+ * `{ current, latest, available, checkedAt, error }`.
74
+ *
75
+ * `available` is only true for a well-formed, strictly newer non-errored answer;
76
+ * the check never invents a version and an upstream failure keeps `latest: null`.
77
+ */
78
+ export function buildUpdateInfo(current, latest, checkedAt, error = null) {
79
+ const latestVersion = typeof latest === 'string' && parseVersion(latest) !== null ? latest.trim() : null
80
+ return {
81
+ current: typeof current === 'string' ? current : '',
82
+ latest: latestVersion,
83
+ available: error === null && latestVersion !== null && isNewerVersion(latestVersion, current),
84
+ checkedAt: typeof checkedAt === 'string' && checkedAt !== '' ? checkedAt : new Date().toISOString(),
85
+ error: error === null || error === undefined ? null : error,
86
+ }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tokenlens",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
6
6
  "license": "MIT",