dsh-cost-meter 1.5.8 → 1.5.10

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
@@ -6,7 +6,7 @@
6
6
 
7
7
  Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.5.8-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.10-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -191,22 +191,22 @@ Real captures from an actual DSH sidebar of the period strip and collapsed verti
191
191
  dsh plugin --profile web add dsh-cost-meter
192
192
  ```
193
193
 
194
- **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.5.8`** — review the script before running):
194
+ **PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.5.10`** — review the script before running):
195
195
 
196
196
  ```powershell
197
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.8/install.ps1 | iex
197
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.10/install.ps1 | iex
198
198
  ```
199
199
 
200
200
  **Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
201
201
 
202
202
  ```sh
203
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.8
203
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.10
204
204
  ```
205
205
 
206
206
  Without git, use the GitHub tag archive:
207
207
 
208
208
  ```sh
209
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.8.tar.gz
209
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.10.tar.gz
210
210
  ```
211
211
 
212
212
  After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow 六家)
8
8
 
9
- [![version](https://img.shields.io/badge/version-1.5.8-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
9
+ [![version](https://img.shields.io/badge/version-1.5.10-4176E6)](https://github.com/Han-1413141/dsh-cost-meter)
10
10
  [![npm](https://img.shields.io/npm/v/dsh-cost-meter?label=npm)](https://www.npmjs.com/package/dsh-cost-meter)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE)
12
12
  [![dsh](https://img.shields.io/badge/DeepSeek%20Harness-dsh--plugin-4176E6)](https://github.com/deepseek-ai/deepseek-harness)
@@ -193,22 +193,22 @@
193
193
  dsh plugin --profile web add dsh-cost-meter
194
194
  ```
195
195
 
196
- **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.8`**,建议先下载审阅再运行):
196
+ **PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.5.10`**,建议先下载审阅再运行):
197
197
 
198
198
  ```powershell
199
- irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.8/install.ps1 | iex
199
+ irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.5.10/install.ps1 | iex
200
200
  ```
201
201
 
202
202
  **或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
203
203
 
204
204
  ```sh
205
- dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.8
205
+ dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.5.10
206
206
  ```
207
207
 
208
208
  没有 git 时可用 GitHub tag 打包直链:
209
209
 
210
210
  ```sh
211
- dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.8.tar.gz
211
+ dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.5.10.tar.gz
212
212
  ```
213
213
 
214
214
  安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
package/lib/backfill.js CHANGED
@@ -141,7 +141,7 @@ export function listSessionLogs(root) {
141
141
  * @param records - readSessionRecords 的输出。
142
142
  * @param config - 账本配置(prices / peak* / priceMatch / priceOverrides)。
143
143
  * @param wantDates - 只统计这些日期键(YYYY-MM-DD)内的调用;null = 全部。
144
- * @returns { sessionId, days: { date: { providerKey: 桶 } } }。
144
+ * @returns { sessionId, title, createdAt, days: { date: { providerKey: 桶 } } }。
145
145
  */
146
146
  export function replaySessionRecords(records, config, wantDates = null) {
147
147
  const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0 })
@@ -150,6 +150,8 @@ export function replaySessionRecords(records, config, wantDates = null) {
150
150
  return Number.isFinite(n) && n > 0 ? n : 0
151
151
  }
152
152
  let sessionId = ''
153
+ let title = ''
154
+ let createdAt = 0
153
155
  let provider = 'deepseek'
154
156
  let model = 'default'
155
157
  let last = null
@@ -171,6 +173,14 @@ export function replaySessionRecords(records, config, wantDates = null) {
171
173
  if (event === null || typeof event !== 'object') continue
172
174
  if (event.type === 'session' && typeof event.id === 'string') {
173
175
  sessionId = event.id
176
+ const created = Number(event.createdAt)
177
+ if (Number.isFinite(created) && created > 0) createdAt = created
178
+ continue
179
+ }
180
+ // 会话标题(宿主生成的 session/title 事件;同名多次时取最后一次)。
181
+ if (event.type === 'session/title') {
182
+ const nextTitle = event.data?.title
183
+ if (typeof nextTitle === 'string' && nextTitle.trim().length > 0) title = nextTitle.trim()
174
184
  continue
175
185
  }
176
186
  if (PACKED_ROW_TYPES.has(event.type)) continue
@@ -231,7 +241,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
231
241
  shift(sample, 1)
232
242
  last = sample
233
243
  }
234
- return { sessionId, days }
244
+ return { sessionId, title, createdAt, days }
235
245
  }
236
246
 
237
247
  /**
@@ -241,10 +251,10 @@ export function replaySessionRecords(records, config, wantDates = null) {
241
251
  * 写入该会话当日的回放拆分。
242
252
  * @param ledger - 已打开的账本。
243
253
  * @param sessionsRoot - 宿主会话根目录($DSH_HOME/sessions)。
244
- * @returns { days, sessions, scanned } 实际填补的日期/会话数与扫描文件数。
254
+ * @returns { days, sessions, scanned, titles } 实际填补的日期/会话数、扫描文件数与补齐的会话标题数。
245
255
  */
246
256
  export async function backfillLegacyLedger(ledger, sessionsRoot) {
247
- const result = { days: 0, sessions: 0, scanned: 0 }
257
+ const result = { days: 0, sessions: 0, scanned: 0, titles: 0 }
248
258
  const needDates = new Set()
249
259
  for (const [date, day] of Object.entries(ledger.days ?? {})) {
250
260
  if ((day?.calls ?? 0) > 0 && Object.keys(day?.byProviderModel ?? {}).length === 0) needDates.add(date)
@@ -260,8 +270,21 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
260
270
  }
261
271
  }
262
272
  }
263
- if (needDates.size === 0) return result
273
+ // 会话标题/时间戳补齐:任一字段缺失则需扫描(日志里的 session/title / createdAt 是权威来源)。
274
+ let needTitles = false
275
+ for (const day of Object.values(ledger.days ?? {})) {
276
+ for (const session of day?.sessions ?? []) {
277
+ if (typeof session?.title !== 'string' || session.title.length === 0 || !Number.isFinite(Number(session?.at))) {
278
+ needTitles = true
279
+ break
280
+ }
281
+ }
282
+ if (needTitles) break
283
+ }
284
+ if (needDates.size === 0 && !needTitles) return result
264
285
  const bySession = new Map()
286
+ const titles = new Map()
287
+ const createdAts = new Map()
265
288
  let scannedCount = 0
266
289
  for (const path of listSessionLogs(sessionsRoot)) {
267
290
  // 会话日志多时逐份解压会长时间占住事件循环:每 8 份让出一次,不卡宿主 UI。
@@ -269,11 +292,14 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
269
292
  result.scanned += 1
270
293
  let replayed
271
294
  try {
272
- replayed = replaySessionRecords(readSessionRecords(path), ledger.config, needDates)
295
+ replayed = replaySessionRecords(readSessionRecords(path), ledger.config, needDates.size > 0 ? needDates : new Set(['-']))
273
296
  } catch {
274
297
  continue // 单文件损坏不阻断整体回填。
275
298
  }
276
299
  if (replayed.sessionId.length === 0) continue
300
+ if (replayed.title.length > 0 && !titles.has(replayed.sessionId)) titles.set(replayed.sessionId, replayed.title)
301
+ if (replayed.createdAt > 0 && !createdAts.has(replayed.sessionId)) createdAts.set(replayed.sessionId, replayed.createdAt)
302
+ if (needDates.size === 0) continue
277
303
  const existing = bySession.get(replayed.sessionId)
278
304
  if (existing === undefined) bySession.set(replayed.sessionId, replayed.days)
279
305
  else mergeDayMaps(existing, replayed.days)
@@ -351,7 +377,26 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
351
377
  }
352
378
  }
353
379
  }
354
- if (result.days > 0 || result.sessions > 0) ledger.scheduleWrite()
380
+ // 会话标题/时间戳补齐:只填缺失字段,不覆盖已有(幂等;实时新建的会话下次启动补齐)
381
+ if (titles.size > 0 || createdAts.size > 0) {
382
+ for (const day of Object.values(ledger.days ?? {})) {
383
+ for (const session of day?.sessions ?? []) {
384
+ if (session === null || typeof session !== 'object') continue
385
+ if ((typeof session.title !== 'string' || session.title.length === 0) && titles.size > 0) {
386
+ const found = titles.get(session.id)
387
+ if (found !== undefined) {
388
+ session.title = found
389
+ result.titles += 1
390
+ }
391
+ }
392
+ if (!Number.isFinite(Number(session.at)) && createdAts.size > 0) {
393
+ const foundAt = createdAts.get(session.id)
394
+ if (foundAt !== undefined) session.at = foundAt
395
+ }
396
+ }
397
+ }
398
+ }
399
+ if (result.days > 0 || result.sessions > 0 || result.titles > 0) ledger.scheduleWrite()
355
400
  return result
356
401
  }
357
402
 
package/lib/client.js CHANGED
@@ -79,6 +79,9 @@ window.__ModuleLoader__.load({
79
79
  '.cm-table tr:last-child td{border-bottom:none}',
80
80
  '.cm-table tr.cm-row-click{cursor:pointer}',
81
81
  '.cm-table tr.cm-row-click:hover td{background:var(--dsw-alias-bg-hover,rgba(127,127,127,.08))}',
82
+ // 会话单元格:标题为主行(超出省略),会话 ID 为辅行(可选显示,等宽淡化)。
83
+ '.cm-sess-title{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
84
+ '.cm-sess-id{font-size:11px;font-family:ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-tertiary)}',
82
85
  '.cm-empty{font-size:12px;color:var(--dsw-alias-label-tertiary);padding:8px 0}',
83
86
  '.cm-scroll{max-height:320px;overflow:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:10px}',
84
87
  '.cm-grid{display:grid;grid-template-columns:repeat(2,minmax(220px,1fr));gap:12px}',
@@ -488,11 +491,18 @@ window.__ModuleLoader__.load({
488
491
  historyNoSessions: '该日无会话明细(早期数据或会话日志已清理)',
489
492
  historySessionsError: '会话明细加载失败',
490
493
  sessionRankTitle: '按会话统计(全部历史)',
491
- sessionRankHint: '不按日期分组,全部历史会话按费用降序',
494
+ sessionRankHint: '不按日期分组,全部历史会话;排序与条数可切换',
492
495
  sessionRankEmpty: '暂无会话数据',
493
496
  sessionRankLoading: '会话排行加载中…',
494
497
  sessionRankError: '会话排行加载失败',
495
498
  sessionRankLimit: '显示条数',
499
+ showSessionIdLabel: '会话列表附显会话 ID',
500
+ sessionRankSort: '排序',
501
+ sessionSortCostDesc: '费用 高→低',
502
+ sessionSortCostAsc: '费用 低→高',
503
+ sessionSortTimeDesc: '时间 新→旧',
504
+ sessionSortTimeAsc: '时间 旧→新',
505
+ sessionSortRecent: '实时顺序',
496
506
  history: '历史记录',
497
507
  usageTitle: 'Token 用量统计',
498
508
  usageTotal: '累计 {tokens} tokens · 输入 {input} · 缓存 {cache} · 输出 {output} · {calls} 次调用',
@@ -774,11 +784,18 @@ window.__ModuleLoader__.load({
774
784
  historyNoSessions: 'No session details for this day (early data or session logs cleaned)',
775
785
  historySessionsError: 'Failed to load session details',
776
786
  sessionRankTitle: 'By session (all history)',
777
- sessionRankHint: 'All historical sessions sorted by cost, not grouped by date',
787
+ sessionRankHint: 'All historical sessions, not grouped by date; sort and row count are switchable',
778
788
  sessionRankEmpty: 'No session data yet',
779
789
  sessionRankLoading: 'Loading session ranking…',
780
790
  sessionRankError: 'Failed to load session ranking',
781
791
  sessionRankLimit: 'Rows',
792
+ showSessionIdLabel: 'Show session IDs in session lists',
793
+ sessionRankSort: 'Sort',
794
+ sessionSortCostDesc: 'Cost high→low',
795
+ sessionSortCostAsc: 'Cost low→high',
796
+ sessionSortTimeDesc: 'Time new→old',
797
+ sessionSortTimeAsc: 'Time old→new',
798
+ sessionSortRecent: 'Recent order',
782
799
  history: 'History',
783
800
  usageTitle: 'Token usage stats',
784
801
  usageTotal: 'All-time {tokens} tokens · input {input} · cache {cache} · output {output} · {calls} calls',
@@ -1177,6 +1194,10 @@ window.__ModuleLoader__.load({
1177
1194
  if (!Number.isFinite(Number(v))) fail('limit', 'number')
1178
1195
  return Number(v)
1179
1196
  })
1197
+ const sortCodec = codecOf(v => {
1198
+ if (typeof v !== 'string') fail('sort', 'string')
1199
+ return v
1200
+ })
1180
1201
  const topSessionsCodec = codecOf(v => {
1181
1202
  if (v === null || typeof v !== 'object' || Array.isArray(v)) fail('topSessions', 'object')
1182
1203
  return v
@@ -1238,7 +1259,11 @@ window.__ModuleLoader__.load({
1238
1259
  {
1239
1260
  id: 'dsh-cost-meter#costMeter/getTopSessions', service: 'costMeter', namespace: 'costMeter', method: 'getTopSessions',
1240
1261
  invocation: { kind: 'direct' },
1241
- parameters: [{ name: 'limit', wire: 'limit', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionLimit', schema: limitCodec } }],
1262
+ parameters: [
1263
+ { name: 'limit', wire: 'limit', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionLimit', schema: limitCodec } },
1264
+ { name: 'sort', wire: 'sort', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSort', schema: sortCodec } },
1265
+ { name: 'dir', wire: 'dir', source: 'json', codec: { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSortDir', schema: sortCodec } },
1266
+ ],
1242
1267
  result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#TopSessions', schema: topSessionsCodec },
1243
1268
  },
1244
1269
  ],
@@ -2330,6 +2355,19 @@ window.__ModuleLoader__.load({
2330
2355
  el('p', { className: 'cm-card-sub' }, props.sub))
2331
2356
  }
2332
2357
 
2358
+ // 历史记录折叠面板(issue #22):三角展开/收起,内部为按天表格(日期行再展开会话明细)。
2359
+ function HistoryPanel(props) {
2360
+ const { state, api } = props
2361
+ const t = makeT(resolveLocale(state.config?.locale))
2362
+ const [open, setOpen] = useState(false)
2363
+ return el('div', { className: 'cm-budget' },
2364
+ el('div', { className: 'cm-budget-head' },
2365
+ el('button', { type: 'button', className: 'cm-collapse-h', 'aria-expanded': String(open), onClick: () => setOpen(!open) },
2366
+ el('span', { className: 'cm-caret' + (open ? ' open' : '') }),
2367
+ el('h3', { className: 'cm-h' }, t('history')))),
2368
+ open ? el('div', { className: 'cm-collapse-body' }, el(HistoryTable, { state, api })) : null)
2369
+ }
2370
+
2333
2371
  function HistoryTable(props) {
2334
2372
  const { state, api } = props
2335
2373
  const t = makeT(resolveLocale(state.config?.locale))
@@ -2350,7 +2388,7 @@ window.__ModuleLoader__.load({
2350
2388
  .finally(() => setBusyDate(null))
2351
2389
  }
2352
2390
  const sessionRows = sessions => sessions.map(session => el('tr', { key: session.id },
2353
- el('td', null, session.id.slice(0, 14) + '…'),
2391
+ sessionCell(session, state.config?.showSessionId === true, t),
2354
2392
  el('td', { className: 'num' }, String(session.calls)),
2355
2393
  el('td', { className: 'num' }, formatTokens(session.input)),
2356
2394
  el('td', { className: 'num' }, formatTokens(session.cacheRead + session.cacheWrite)),
@@ -2389,19 +2427,22 @@ window.__ModuleLoader__.load({
2389
2427
  }))))
2390
2428
  }
2391
2429
 
2392
- // 按会话统计(issue #22 不分日期视角):全部历史会话按费用降序,默认收起、展开时按需拉取。
2430
+ // 按会话统计(issue #22 不分日期视角):全部历史会话排行,默认收起、展开时按需拉取;排序可切换。
2393
2431
  function SessionRankPanel(props) {
2394
2432
  const { state, api } = props
2395
2433
  const t = makeT(resolveLocale(state.config?.locale))
2396
2434
  const [open, setOpen] = useState(false)
2397
2435
  const [limit, setLimit] = useState(100)
2436
+ // 排序模式:cost-desc / cost-asc / time-desc / time-asc / recent(实时顺序)。
2437
+ const [sortMode, setSortMode] = useState('cost-desc')
2398
2438
  const [rows, setRows] = useState(null)
2399
2439
  const [busy, setBusy] = useState(false)
2400
2440
  const [err, setErr] = useState(false)
2401
- const load = n => {
2441
+ const load = (n, mode) => {
2442
+ const [sort, dir] = mode === 'recent' ? ['recent', 'desc'] : mode.split('-')
2402
2443
  setBusy(true)
2403
2444
  setErr(false)
2404
- api.getTopSessions(n)
2445
+ api.getTopSessions(n, sort, dir)
2405
2446
  .then(res => setRows(Array.isArray(res?.sessions) ? res.sessions : []))
2406
2447
  .catch(() => setErr(true))
2407
2448
  .finally(() => setBusy(false))
@@ -2409,13 +2450,17 @@ window.__ModuleLoader__.load({
2409
2450
  const toggle = () => {
2410
2451
  setOpen(o => {
2411
2452
  const next = !o
2412
- if (next && rows === null && !busy) load(limit)
2453
+ if (next && rows === null && !busy) load(limit, sortMode)
2413
2454
  return next
2414
2455
  })
2415
2456
  }
2416
2457
  const changeLimit = n => {
2417
2458
  setLimit(n)
2418
- if (open || rows !== null) load(n)
2459
+ if (open || rows !== null) load(n, sortMode)
2460
+ }
2461
+ const changeSort = mode => {
2462
+ setSortMode(mode)
2463
+ if (open || rows !== null) load(limit, mode)
2419
2464
  }
2420
2465
  return el('div', { className: 'cm-budget' },
2421
2466
  el('div', { className: 'cm-budget-head' },
@@ -2424,12 +2469,21 @@ window.__ModuleLoader__.load({
2424
2469
  el('h3', { className: 'cm-h' }, t('sessionRankTitle')))),
2425
2470
  open ? el('div', { className: 'cm-collapse-body' },
2426
2471
  el('p', { className: 'cm-hint' }, t('sessionRankHint')),
2427
- el('div', { className: 'cm-field', style: { margin: '6px 0' } },
2428
- el('label', null, t('sessionRankLimit')),
2429
- el('select', { className: 'cm-input', value: String(limit), onChange: event => changeLimit(Number(event.target.value)) },
2430
- el('option', { value: '50' }, '50'),
2431
- el('option', { value: '100' }, '100'),
2432
- el('option', { value: '200' }, '200'))),
2472
+ el('div', { style: { display: 'flex', gap: '12px', flexWrap: 'wrap', margin: '6px 0' } },
2473
+ el('div', { className: 'cm-field' },
2474
+ el('label', null, t('sessionRankSort')),
2475
+ el('select', { className: 'cm-input', value: sortMode, onChange: event => changeSort(event.target.value) },
2476
+ el('option', { value: 'cost-desc' }, t('sessionSortCostDesc')),
2477
+ el('option', { value: 'cost-asc' }, t('sessionSortCostAsc')),
2478
+ el('option', { value: 'time-desc' }, t('sessionSortTimeDesc')),
2479
+ el('option', { value: 'time-asc' }, t('sessionSortTimeAsc')),
2480
+ el('option', { value: 'recent' }, t('sessionSortRecent')))),
2481
+ el('div', { className: 'cm-field' },
2482
+ el('label', null, t('sessionRankLimit')),
2483
+ el('select', { className: 'cm-input', value: String(limit), onChange: event => changeLimit(Number(event.target.value)) },
2484
+ el('option', { value: '50' }, '50'),
2485
+ el('option', { value: '100' }, '100'),
2486
+ el('option', { value: '200' }, '200')))),
2433
2487
  busy ? el('p', { className: 'cm-empty' }, t('sessionRankLoading'))
2434
2488
  : err ? el('p', { className: 'cm-empty' }, t('sessionRankError'))
2435
2489
  : rows === null || rows.length === 0 ? el('p', { className: 'cm-empty' }, t('sessionRankEmpty'))
@@ -2441,7 +2495,7 @@ window.__ModuleLoader__.load({
2441
2495
  el('th', { className: 'num' }, t('colCost')))),
2442
2496
  el('tbody', null, rows.map(session => el('tr', { key: session.date + ':' + session.id },
2443
2497
  el('td', null, session.date),
2444
- el('td', null, String(session.id).slice(0, 14) + '…'),
2498
+ sessionCell(session, state.config?.showSessionId === true, t),
2445
2499
  el('td', { className: 'num' }, String(session.calls)),
2446
2500
  el('td', { className: 'num' }, formatTokens(session.input)),
2447
2501
  el('td', { className: 'num' }, formatTokens((session.cacheRead ?? 0) + (session.cacheWrite ?? 0))),
@@ -2455,6 +2509,7 @@ window.__ModuleLoader__.load({
2455
2509
  const t = makeT(resolveLocale(state.config?.locale))
2456
2510
  const sessions = state.today.sessions ?? []
2457
2511
  if (sessions.length === 0) return el('p', { className: 'cm-empty' }, t('noSessionsToday'))
2512
+ const showId = state.config?.showSessionId === true
2458
2513
  return el('div', { className: 'cm-scroll' },
2459
2514
  el('table', { className: 'cm-table' },
2460
2515
  el('thead', null, el('tr', null,
@@ -2462,7 +2517,7 @@ window.__ModuleLoader__.load({
2462
2517
  el('th', { className: 'num' }, t('colInTok')), el('th', { className: 'num' }, t('colCacheTok')), el('th', { className: 'num' }, t('colOutTok')),
2463
2518
  el('th', { className: 'num' }, t('colCost')))),
2464
2519
  el('tbody', null, sessions.map(session => el('tr', { key: session.id },
2465
- el('td', null, session.id.slice(0, 14) + '…'),
2520
+ sessionCell(session, showId, t),
2466
2521
  el('td', { className: 'num' }, String(session.calls)),
2467
2522
  el('td', { className: 'num' }, formatTokens(session.input)),
2468
2523
  el('td', { className: 'num' }, formatTokens(session.cacheRead + session.cacheWrite)),
@@ -2470,6 +2525,17 @@ window.__ModuleLoader__.load({
2470
2525
  el('td', { className: 'num' }, formatMoneyUsd(session.cost, state.config)))))))
2471
2526
  }
2472
2527
 
2528
+ /** 会话单元格:标题为主行(未命名回落短 id),showId 时附显等宽短 id;悬停看完整标题与 id。 */
2529
+ function sessionCell(session, showId, t) {
2530
+ const rawTitle = typeof session.title === 'string' ? session.title.trim() : ''
2531
+ const shortId = String(session.id).slice(0, 14) + '…'
2532
+ const main = rawTitle.length > 0 ? rawTitle : shortId
2533
+ const tooltip = rawTitle.length > 0 ? rawTitle + ' · ' + session.id : session.id
2534
+ return el('td', { title: tooltip },
2535
+ el('div', { className: 'cm-sess-title' + (rawTitle.length === 0 ? ' cm-sess-id' : '') }, main),
2536
+ showId && rawTitle.length > 0 ? el('div', { className: 'cm-sess-id' }, shortId) : null)
2537
+ }
2538
+
2473
2539
  const CURRENCY_PRESETS = {
2474
2540
  CNY: { symbol: '¥', decimals: 4, exchangeRate: 7.2 },
2475
2541
  USD: { symbol: '$', decimals: 6, exchangeRate: 1 },
@@ -3736,10 +3802,8 @@ window.__ModuleLoader__.load({
3736
3802
  el('div', null,
3737
3803
  el('h3', { className: 'cm-h' }, t('todaySessions')),
3738
3804
  el(TodaySessions, { state, t })),
3739
- // 历史
3740
- el('div', null,
3741
- el('h3', { className: 'cm-h' }, t('history')),
3742
- el(HistoryTable, { state, api })),
3805
+ // 历史(三角折叠面板;日期行可再展开会话明细)
3806
+ el(HistoryPanel, { state, api }),
3743
3807
  // 按会话统计(全部历史,不分日期;issue #22)
3744
3808
  el(SessionRankPanel, { state, api }),
3745
3809
  // 显示设置
@@ -3781,6 +3845,13 @@ window.__ModuleLoader__.load({
3781
3845
  },
3782
3846
  el('option', { value: 'on' }, t('sidebarOn')),
3783
3847
  el('option', { value: 'off' }, t('off')))),
3848
+ el('label', { className: 'cm-check' },
3849
+ el('input', {
3850
+ type: 'checkbox',
3851
+ checked: draft?.showSessionId === true,
3852
+ onChange: event => setField('showSessionId', event.target.checked),
3853
+ }),
3854
+ el('span', null, t('showSessionIdLabel'))),
3784
3855
  el('div', { className: 'cm-grid-group' }, t('groupMoney')),
3785
3856
  el('div', { className: 'cm-field' },
3786
3857
  el('label', null, t('currencyLabel')),
@@ -4159,8 +4230,8 @@ window.__ModuleLoader__.load({
4159
4230
  },
4160
4231
  // 按需拉取某天会话明细(issue #22),返回当日完整记录。
4161
4232
  getDaySessions: async date => call('getDaySessions', [date]),
4162
- // 跨全部日期按费用降序的会话排行(issue #22 不分日期视角)
4163
- getTopSessions: async limit => call('getTopSessions', [limit]),
4233
+ // 跨全部日期的会话排行(issue #22 不分日期视角):支持费用/时间升降序与实时顺序。
4234
+ getTopSessions: async (limit, sort, dir) => call('getTopSessions', [limit, sort, dir]),
4164
4235
  refreshBalance: async () => {
4165
4236
  const result = await costMeter.refreshBalance()
4166
4237
  if (result === null || typeof result !== 'object' || result.ok !== true) {
package/lib/index.js CHANGED
@@ -909,17 +909,28 @@ function createService(ctx, ledger) {
909
909
  return day === undefined ? zeroDay(date) : ledger.copyDay(day)
910
910
  },
911
911
 
912
- // 跨全部日期按费用降序返回前 N 个会话(issue #22 按会话视角,不分日期)。
913
- async getTopSessions(limit) {
912
+ // 跨全部日期返回前 N 个会话(issue #22 按会话视角,不分日期)。
913
+ // sort:cost(费用) | time(会话创建时间) | recent(实时顺序,即账本/侧边栏顺序);dir:asc | desc。
914
+ async getTopSessions(limit, sort = 'cost', dir = 'desc') {
914
915
  const n = Math.max(1, Math.min(500, Math.floor(Number(limit)) || 100))
916
+ const sortKey = sort === 'time' || sort === 'recent' ? sort : 'cost'
917
+ const asc = dir === 'asc'
915
918
  const all = []
916
- for (const [date, day] of Object.entries(ledger.days)) {
919
+ const dateKeys = Object.keys(ledger.days)
920
+ // recent 的降序 = 侧边栏直觉的「新会话在前」:日期倒序 + 每日会话倒序。
921
+ if (sortKey === 'recent' && !asc) dateKeys.reverse()
922
+ for (const date of dateKeys) {
923
+ const day = ledger.days[date]
917
924
  if (!Array.isArray(day.sessions)) continue
918
- for (const s of day.sessions) {
925
+ const rows = day.sessions.slice()
926
+ if (sortKey === 'recent' && !asc) rows.reverse()
927
+ for (const s of rows) {
919
928
  if (s === null || typeof s !== 'object') continue
920
929
  all.push({
921
930
  date,
922
931
  id: String(s.id ?? ''),
932
+ title: typeof s.title === 'string' && s.title.length > 0 ? s.title : undefined,
933
+ at: Number.isFinite(Number(s.at)) && Number(s.at) > 0 ? Number(s.at) : undefined,
923
934
  input: s.input ?? 0,
924
935
  output: s.output ?? 0,
925
936
  cacheRead: s.cacheRead ?? 0,
@@ -931,7 +942,16 @@ function createService(ctx, ledger) {
931
942
  })
932
943
  }
933
944
  }
934
- all.sort((a, b) => b.cost - a.cost)
945
+ if (sortKey === 'cost') all.sort((a, b) => asc ? a.cost - b.cost : b.cost - a.cost)
946
+ else if (sortKey === 'time') {
947
+ // 无时间戳的条目排末尾。
948
+ all.sort((a, b) => {
949
+ const ta = Number.isFinite(a.at) ? a.at : asc ? Number.MAX_SAFE_INTEGER : 0
950
+ const tb = Number.isFinite(b.at) ? b.at : asc ? Number.MAX_SAFE_INTEGER : 0
951
+ return asc ? ta - tb : tb - ta
952
+ })
953
+ }
954
+ // recent 已按构造顺序排好,不再重排。
935
955
  return { sessions: all.slice(0, n) }
936
956
  },
937
957
  }
@@ -961,8 +981,13 @@ export function apply(ctx) {
961
981
  // 宿主会话日志填补空 byProviderModel(幂等,只填空条目,不重复计数)。
962
982
  const backfillTimer = setTimeout(() => {
963
983
  backfillLegacyLedger(ledger, join(resolveDshHome(), 'sessions')).then(filled => {
964
- if (filled.days > 0 || filled.sessions > 0) {
965
- console.log(`[dsh-cost-meter] 历史按模型统计回填完成:${filled.days} / ${filled.sessions} 个会话(扫描 ${filled.scanned} 份会话日志${filled.recosted > 0 ? `,重算 ${filled.recosted} 天金额` : ''})`)
984
+ if (filled.days > 0 || filled.sessions > 0 || filled.titles > 0) {
985
+ const extras = [
986
+ filled.days > 0 || filled.sessions > 0 ? `${filled.days} 天 / ${filled.sessions} 个会话` : null,
987
+ filled.titles > 0 ? `${filled.titles} 个会话标题` : null,
988
+ filled.recosted > 0 ? `重算 ${filled.recosted} 天金额` : null,
989
+ ].filter(Boolean).join(',')
990
+ console.log(`[dsh-cost-meter] 历史按模型统计回填完成:${extras}(扫描 ${filled.scanned} 份会话日志)`)
966
991
  }
967
992
  }, error => {
968
993
  console.warn(`[dsh-cost-meter] 历史按模型统计回填失败: ${String(error?.message ?? error)}`)
package/lib/store.js CHANGED
@@ -37,6 +37,7 @@ export function defaultConfig() {
37
37
  peakEffectiveAt: DEFAULT_PEAK_EFFECTIVE_AT,
38
38
  peakWindows: DEFAULT_PEAK_WINDOWS.map(w => ({ ...w })),
39
39
  peakNotice: true, // 峰时高价时段显著提示(侧边栏预算框/今日费用/设置页预算面板)
40
+ showSessionId: false, // 会话列表中附显会话 ID(默认只显示标题,需要时开启)
40
41
  peakStyle: 'compact', // 峰谷时段条样式:compact(简洁单行/竖向同构) | classic(经典分段/胶囊芯片)
41
42
  priceMatch: 'auto', // 未知模型名自动匹配价格表:auto(去后缀/前缀/家族相似) | exact(仅精确)
42
43
  priceOverrides: {}, // 手动匹配覆盖:{ 'provider:modelId': '同provider模型 | provider:模型 | deepseek:__default__' }
@@ -153,7 +154,7 @@ function sanitizeBuckets(target) {
153
154
  * 历史版本曾写入 `reasoning: null` 等非法数值,会导致 Typert strict 状态
154
155
  * codec 拒绝整个 getState 结果(账本不可用 / 额度刷新连带失败)。
155
156
  */
156
- function sanitizeDays(days) {
157
+ export function sanitizeDays(days) {
157
158
  for (const day of Object.values(days)) {
158
159
  if (sanitizeBuckets(day) === null) continue
159
160
  day.byProviderModel = day.byProviderModel !== null && typeof day.byProviderModel === 'object' && !Array.isArray(day.byProviderModel)
@@ -169,6 +170,10 @@ function sanitizeDays(days) {
169
170
  for (const session of day.sessions) {
170
171
  if (sanitizeBuckets(session) === null) continue
171
172
  session.id = typeof session.id === 'string' ? session.id : ''
173
+ // 会话标题(由历史回填从会话日志补齐):非字符串一律剔除,防击穿 strict sessionSchema。
174
+ if (typeof session.title !== 'string' || session.title.length === 0) delete session.title
175
+ // 会话时间戳(实时入账/回填补齐):非正数剔除。
176
+ if (!Number.isFinite(Number(session.at)) || Number(session.at) <= 0) delete session.at
172
177
  session.byProviderModel = session.byProviderModel !== null && typeof session.byProviderModel === 'object' && !Array.isArray(session.byProviderModel)
173
178
  ? session.byProviderModel
174
179
  : {}
@@ -211,6 +216,7 @@ const VALIDATION_MESSAGES = {
211
216
  peakEffectiveAt: 'peakEffectiveAt 非法',
212
217
  peakWindows: 'peakWindows 必须是数组',
213
218
  peakNotice: 'peakNotice 必须是布尔值',
219
+ showSessionId: 'showSessionId 必须是布尔值',
214
220
  peakStyle: 'peakStyle 必须是 compact / classic',
215
221
  priceMatch: 'priceMatch 必须是 auto / exact',
216
222
  priceOverrides: 'priceOverrides 必须是字符串→字符串映射',
@@ -271,6 +277,7 @@ const VALIDATION_MESSAGES = {
271
277
  peakEffectiveAt: 'Invalid peakEffectiveAt',
272
278
  peakWindows: 'peakWindows must be an array',
273
279
  peakNotice: 'peakNotice must be a boolean',
280
+ showSessionId: 'showSessionId must be a boolean',
274
281
  peakStyle: 'peakStyle must be compact / classic',
275
282
  priceMatch: 'priceMatch must be auto / exact',
276
283
  priceOverrides: 'priceOverrides must be a string→string map',
@@ -387,6 +394,7 @@ export function applyConfigPatch(current, patch) {
387
394
  if (typeof candidate.peakEffectiveAt !== 'string') errors.push(vmsg(locale, 'peakEffectiveAt'))
388
395
  if (!Array.isArray(candidate.peakWindows)) errors.push(vmsg(locale, 'peakWindows'))
389
396
  if (typeof candidate.peakNotice !== 'boolean') errors.push(vmsg(locale, 'peakNotice'))
397
+ if (candidate.showSessionId !== undefined && typeof candidate.showSessionId !== 'boolean') errors.push(vmsg(locale, 'showSessionId'))
390
398
  if (candidate.peakStyle !== 'compact' && candidate.peakStyle !== 'classic') errors.push(vmsg(locale, 'peakStyle'))
391
399
  if (candidate.priceMatch !== 'auto' && candidate.priceMatch !== 'exact') errors.push(vmsg(locale, 'priceMatch'))
392
400
  const overrides = candidate.priceOverrides
@@ -569,6 +577,7 @@ export function sanitizeConfig(raw) {
569
577
  out.priceMatch = oneOf(out.priceMatch, ['auto', 'exact'], 'auto')
570
578
  out.decimals = Math.max(0, Math.min(10, Math.floor(Number(out.decimals) || 0)))
571
579
  out.historyDays = Math.max(7, Math.min(3650, Math.floor(Number(out.historyDays) || 180)))
580
+ out.showSessionId = out.showSessionId === true
572
581
  if (!isNum(out.exchangeRate) || out.exchangeRate <= 0) out.exchangeRate = base.exchangeRate
573
582
  if (!Array.isArray(out.peakWindows)) out.peakWindows = base.peakWindows
574
583
  else out.peakWindows = out.peakWindows.filter(w => w !== null && typeof w === 'object' && isNum(Number(w.start)) && isNum(Number(w.end)))
@@ -790,6 +799,7 @@ export class Ledger {
790
799
  let session = sessions.find(s => s.id === sessionId)
791
800
  if (session === undefined) {
792
801
  session = zeroSession(sessionId)
802
+ session.at = atMs // 会话首次入账时刻(按会话排行「按时间排序」用)。
793
803
  sessions.push(session)
794
804
  if (sessions.length > MAX_SESSIONS_PER_DAY) sessions = sessions.slice(-MAX_SESSIONS_PER_DAY)
795
805
  day.sessions = sessions
@@ -10,6 +10,8 @@ const num = z.number()
10
10
 
11
11
  const sessionSchema = z.object({
12
12
  id: z.string(),
13
+ title: z.string().optional(),
14
+ at: num.optional(),
13
15
  input: num,
14
16
  output: num,
15
17
  cacheRead: num,
@@ -101,6 +103,7 @@ const configSchema = z.object({
101
103
  peakEffectiveAt: z.string(),
102
104
  peakWindows: z.array(z.object({ start: num, end: num })),
103
105
  peakNotice: z.boolean().optional(),
106
+ showSessionId: z.boolean().optional(),
104
107
  peakStyle: z.enum(['compact', 'classic']).optional(),
105
108
  priceMatch: z.enum(['auto', 'exact']).optional(),
106
109
  priceOverrides: z.record(z.string(), z.string()).optional(),
@@ -252,6 +255,8 @@ const _day$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#DayRecord', sch
252
255
  const _date$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#DayKey', schema: z.string() }
253
256
  const _topSessions$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#TopSessions', schema: topSessionsSchema }
254
257
  const _limit$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionLimit', schema: num }
258
+ const _sort$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSort', schema: z.string() }
259
+ const _dir$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#SessionSortDir', schema: z.string() }
255
260
 
256
261
  export const TYPERT = {
257
262
  package: 'dsh-cost-meter',
@@ -353,6 +358,8 @@ export const TYPERT = {
353
358
  invocation: { kind: 'direct' },
354
359
  parameters: [
355
360
  { name: 'limit', wire: 'limit', source: 'json', codec: _limit$codec },
361
+ { name: 'sort', wire: 'sort', source: 'json', codec: _sort$codec },
362
+ { name: 'dir', wire: 'dir', source: 'json', codec: _dir$codec },
356
363
  ],
357
364
  result: _topSessions$codec,
358
365
  },
@@ -433,9 +440,9 @@ export const TYPERT = {
433
440
  {
434
441
  kind: 'method',
435
442
  name: 'getTopSessions',
436
- signature: 'getTopSessions(limit: number): TopSessions',
437
- summary: '跨全部日期按费用降序返回前 N 个会话(不分日期视角)。Return the top N sessions by cost across all days.',
438
- jsDoc: '/**\n * 跨全部日期按费用降序返回前 N 个会话(每条带所属日期;不含今日实时条目之外的去重)。\n * @param limit - 返回条数上限(服务端限制 1-500)。\n * @returns 会话列表(含 date 字段)。\n * Return the top N sessions by cost across all days (each tagged with its date).\n * @param limit - Max rows to return (server clamps to 1-500).\n * @returns Session list with a date field.\n */',
443
+ signature: 'getTopSessions(limit: number, sort: string, dir: string): TopSessions',
444
+ summary: '跨全部日期按指定排序返回前 N 个会话(不分日期视角)。Return the top N sessions across all days with the given sort.',
445
+ jsDoc: '/**\n * 跨全部日期返回前 N 个会话(每条带所属日期/标题/创建时刻)。\n * @param limit - 返回条数上限(服务端限制 1-500)。\n * @param sort - cost(费用) | time(创建时间) | recent(实时顺序)。\n * @param dir - asc | desc(默认 desc)。\n * @returns 会话列表(含 date/title/at 字段)。\n * Return the top N sessions across all days (each tagged with date/title/createdAt).\n * @param limit - Max rows to return (server clamps to 1-500).\n * @param sort - cost | time | recent (ledger/sidebar order).\n * @param dir - asc | desc (defaults to desc).\n * @returns Session list with date/title/at fields.\n */',
439
446
  },
440
447
  ],
441
448
  types: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.5.8",
3
+ "version": "1.5.10",
4
4
  "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(6 家)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条,界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 6 vendors, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, in a bilingual (Chinese/English) UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",