dsh-cost-meter 1.5.9 → 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.9-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.9`** — 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.9/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.9
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.9.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.9-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.9`**,建议先下载审阅再运行):
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.9/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.9
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.9.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, title, 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 })
@@ -151,6 +151,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
151
151
  }
152
152
  let sessionId = ''
153
153
  let title = ''
154
+ let createdAt = 0
154
155
  let provider = 'deepseek'
155
156
  let model = 'default'
156
157
  let last = null
@@ -172,6 +173,8 @@ export function replaySessionRecords(records, config, wantDates = null) {
172
173
  if (event === null || typeof event !== 'object') continue
173
174
  if (event.type === 'session' && typeof event.id === 'string') {
174
175
  sessionId = event.id
176
+ const created = Number(event.createdAt)
177
+ if (Number.isFinite(created) && created > 0) createdAt = created
175
178
  continue
176
179
  }
177
180
  // 会话标题(宿主生成的 session/title 事件;同名多次时取最后一次)。
@@ -238,7 +241,7 @@ export function replaySessionRecords(records, config, wantDates = null) {
238
241
  shift(sample, 1)
239
242
  last = sample
240
243
  }
241
- return { sessionId, title, days }
244
+ return { sessionId, title, createdAt, days }
242
245
  }
243
246
 
244
247
  /**
@@ -267,11 +270,11 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
267
270
  }
268
271
  }
269
272
  }
270
- // 会话标题补齐:任一会话条目缺标题则需扫描(日志里的 session/title 事件是标题权威来源)。
273
+ // 会话标题/时间戳补齐:任一字段缺失则需扫描(日志里的 session/title / createdAt 是权威来源)。
271
274
  let needTitles = false
272
275
  for (const day of Object.values(ledger.days ?? {})) {
273
276
  for (const session of day?.sessions ?? []) {
274
- if (typeof session?.title !== 'string' || session.title.length === 0) {
277
+ if (typeof session?.title !== 'string' || session.title.length === 0 || !Number.isFinite(Number(session?.at))) {
275
278
  needTitles = true
276
279
  break
277
280
  }
@@ -281,6 +284,7 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
281
284
  if (needDates.size === 0 && !needTitles) return result
282
285
  const bySession = new Map()
283
286
  const titles = new Map()
287
+ const createdAts = new Map()
284
288
  let scannedCount = 0
285
289
  for (const path of listSessionLogs(sessionsRoot)) {
286
290
  // 会话日志多时逐份解压会长时间占住事件循环:每 8 份让出一次,不卡宿主 UI。
@@ -294,6 +298,7 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
294
298
  }
295
299
  if (replayed.sessionId.length === 0) continue
296
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)
297
302
  if (needDates.size === 0) continue
298
303
  const existing = bySession.get(replayed.sessionId)
299
304
  if (existing === undefined) bySession.set(replayed.sessionId, replayed.days)
@@ -372,16 +377,22 @@ export async function backfillLegacyLedger(ledger, sessionsRoot) {
372
377
  }
373
378
  }
374
379
  }
375
- // 会话标题补齐:只填缺失的标题,不覆盖已有(幂等;实时新建的会话下次启动补齐)。
376
- if (titles.size > 0) {
380
+ // 会话标题/时间戳补齐:只填缺失字段,不覆盖已有(幂等;实时新建的会话下次启动补齐)。
381
+ if (titles.size > 0 || createdAts.size > 0) {
377
382
  for (const day of Object.values(ledger.days ?? {})) {
378
383
  for (const session of day?.sessions ?? []) {
379
384
  if (session === null || typeof session !== 'object') continue
380
- if (typeof session.title === 'string' && session.title.length > 0) continue
381
- const found = titles.get(session.id)
382
- if (found === undefined) continue
383
- session.title = found
384
- result.titles += 1
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
+ }
385
396
  }
386
397
  }
387
398
  }
package/lib/client.js CHANGED
@@ -491,12 +491,18 @@ window.__ModuleLoader__.load({
491
491
  historyNoSessions: '该日无会话明细(早期数据或会话日志已清理)',
492
492
  historySessionsError: '会话明细加载失败',
493
493
  sessionRankTitle: '按会话统计(全部历史)',
494
- sessionRankHint: '不按日期分组,全部历史会话按费用降序',
494
+ sessionRankHint: '不按日期分组,全部历史会话;排序与条数可切换',
495
495
  sessionRankEmpty: '暂无会话数据',
496
496
  sessionRankLoading: '会话排行加载中…',
497
497
  sessionRankError: '会话排行加载失败',
498
498
  sessionRankLimit: '显示条数',
499
499
  showSessionIdLabel: '会话列表附显会话 ID',
500
+ sessionRankSort: '排序',
501
+ sessionSortCostDesc: '费用 高→低',
502
+ sessionSortCostAsc: '费用 低→高',
503
+ sessionSortTimeDesc: '时间 新→旧',
504
+ sessionSortTimeAsc: '时间 旧→新',
505
+ sessionSortRecent: '实时顺序',
500
506
  history: '历史记录',
501
507
  usageTitle: 'Token 用量统计',
502
508
  usageTotal: '累计 {tokens} tokens · 输入 {input} · 缓存 {cache} · 输出 {output} · {calls} 次调用',
@@ -778,12 +784,18 @@ window.__ModuleLoader__.load({
778
784
  historyNoSessions: 'No session details for this day (early data or session logs cleaned)',
779
785
  historySessionsError: 'Failed to load session details',
780
786
  sessionRankTitle: 'By session (all history)',
781
- 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',
782
788
  sessionRankEmpty: 'No session data yet',
783
789
  sessionRankLoading: 'Loading session ranking…',
784
790
  sessionRankError: 'Failed to load session ranking',
785
791
  sessionRankLimit: 'Rows',
786
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',
787
799
  history: 'History',
788
800
  usageTitle: 'Token usage stats',
789
801
  usageTotal: 'All-time {tokens} tokens · input {input} · cache {cache} · output {output} · {calls} calls',
@@ -1182,6 +1194,10 @@ window.__ModuleLoader__.load({
1182
1194
  if (!Number.isFinite(Number(v))) fail('limit', 'number')
1183
1195
  return Number(v)
1184
1196
  })
1197
+ const sortCodec = codecOf(v => {
1198
+ if (typeof v !== 'string') fail('sort', 'string')
1199
+ return v
1200
+ })
1185
1201
  const topSessionsCodec = codecOf(v => {
1186
1202
  if (v === null || typeof v !== 'object' || Array.isArray(v)) fail('topSessions', 'object')
1187
1203
  return v
@@ -1243,7 +1259,11 @@ window.__ModuleLoader__.load({
1243
1259
  {
1244
1260
  id: 'dsh-cost-meter#costMeter/getTopSessions', service: 'costMeter', namespace: 'costMeter', method: 'getTopSessions',
1245
1261
  invocation: { kind: 'direct' },
1246
- 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
+ ],
1247
1267
  result: { mode: 'strict', typeSymbol: 'dsh-cost-meter#TopSessions', schema: topSessionsCodec },
1248
1268
  },
1249
1269
  ],
@@ -2335,6 +2355,19 @@ window.__ModuleLoader__.load({
2335
2355
  el('p', { className: 'cm-card-sub' }, props.sub))
2336
2356
  }
2337
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
+
2338
2371
  function HistoryTable(props) {
2339
2372
  const { state, api } = props
2340
2373
  const t = makeT(resolveLocale(state.config?.locale))
@@ -2394,19 +2427,22 @@ window.__ModuleLoader__.load({
2394
2427
  }))))
2395
2428
  }
2396
2429
 
2397
- // 按会话统计(issue #22 不分日期视角):全部历史会话按费用降序,默认收起、展开时按需拉取。
2430
+ // 按会话统计(issue #22 不分日期视角):全部历史会话排行,默认收起、展开时按需拉取;排序可切换。
2398
2431
  function SessionRankPanel(props) {
2399
2432
  const { state, api } = props
2400
2433
  const t = makeT(resolveLocale(state.config?.locale))
2401
2434
  const [open, setOpen] = useState(false)
2402
2435
  const [limit, setLimit] = useState(100)
2436
+ // 排序模式:cost-desc / cost-asc / time-desc / time-asc / recent(实时顺序)。
2437
+ const [sortMode, setSortMode] = useState('cost-desc')
2403
2438
  const [rows, setRows] = useState(null)
2404
2439
  const [busy, setBusy] = useState(false)
2405
2440
  const [err, setErr] = useState(false)
2406
- const load = n => {
2441
+ const load = (n, mode) => {
2442
+ const [sort, dir] = mode === 'recent' ? ['recent', 'desc'] : mode.split('-')
2407
2443
  setBusy(true)
2408
2444
  setErr(false)
2409
- api.getTopSessions(n)
2445
+ api.getTopSessions(n, sort, dir)
2410
2446
  .then(res => setRows(Array.isArray(res?.sessions) ? res.sessions : []))
2411
2447
  .catch(() => setErr(true))
2412
2448
  .finally(() => setBusy(false))
@@ -2414,13 +2450,17 @@ window.__ModuleLoader__.load({
2414
2450
  const toggle = () => {
2415
2451
  setOpen(o => {
2416
2452
  const next = !o
2417
- if (next && rows === null && !busy) load(limit)
2453
+ if (next && rows === null && !busy) load(limit, sortMode)
2418
2454
  return next
2419
2455
  })
2420
2456
  }
2421
2457
  const changeLimit = n => {
2422
2458
  setLimit(n)
2423
- 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)
2424
2464
  }
2425
2465
  return el('div', { className: 'cm-budget' },
2426
2466
  el('div', { className: 'cm-budget-head' },
@@ -2429,12 +2469,21 @@ window.__ModuleLoader__.load({
2429
2469
  el('h3', { className: 'cm-h' }, t('sessionRankTitle')))),
2430
2470
  open ? el('div', { className: 'cm-collapse-body' },
2431
2471
  el('p', { className: 'cm-hint' }, t('sessionRankHint')),
2432
- el('div', { className: 'cm-field', style: { margin: '6px 0' } },
2433
- el('label', null, t('sessionRankLimit')),
2434
- el('select', { className: 'cm-input', value: String(limit), onChange: event => changeLimit(Number(event.target.value)) },
2435
- el('option', { value: '50' }, '50'),
2436
- el('option', { value: '100' }, '100'),
2437
- 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')))),
2438
2487
  busy ? el('p', { className: 'cm-empty' }, t('sessionRankLoading'))
2439
2488
  : err ? el('p', { className: 'cm-empty' }, t('sessionRankError'))
2440
2489
  : rows === null || rows.length === 0 ? el('p', { className: 'cm-empty' }, t('sessionRankEmpty'))
@@ -3753,10 +3802,8 @@ window.__ModuleLoader__.load({
3753
3802
  el('div', null,
3754
3803
  el('h3', { className: 'cm-h' }, t('todaySessions')),
3755
3804
  el(TodaySessions, { state, t })),
3756
- // 历史
3757
- el('div', null,
3758
- el('h3', { className: 'cm-h' }, t('history')),
3759
- el(HistoryTable, { state, api })),
3805
+ // 历史(三角折叠面板;日期行可再展开会话明细)
3806
+ el(HistoryPanel, { state, api }),
3760
3807
  // 按会话统计(全部历史,不分日期;issue #22)
3761
3808
  el(SessionRankPanel, { state, api }),
3762
3809
  // 显示设置
@@ -4183,8 +4230,8 @@ window.__ModuleLoader__.load({
4183
4230
  },
4184
4231
  // 按需拉取某天会话明细(issue #22),返回当日完整记录。
4185
4232
  getDaySessions: async date => call('getDaySessions', [date]),
4186
- // 跨全部日期按费用降序的会话排行(issue #22 不分日期视角)
4187
- getTopSessions: async limit => call('getTopSessions', [limit]),
4233
+ // 跨全部日期的会话排行(issue #22 不分日期视角):支持费用/时间升降序与实时顺序。
4234
+ getTopSessions: async (limit, sort, dir) => call('getTopSessions', [limit, sort, dir]),
4188
4235
  refreshBalance: async () => {
4189
4236
  const result = await costMeter.refreshBalance()
4190
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
  }
package/lib/store.js CHANGED
@@ -172,6 +172,8 @@ export function sanitizeDays(days) {
172
172
  session.id = typeof session.id === 'string' ? session.id : ''
173
173
  // 会话标题(由历史回填从会话日志补齐):非字符串一律剔除,防击穿 strict sessionSchema。
174
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
175
177
  session.byProviderModel = session.byProviderModel !== null && typeof session.byProviderModel === 'object' && !Array.isArray(session.byProviderModel)
176
178
  ? session.byProviderModel
177
179
  : {}
@@ -797,6 +799,7 @@ export class Ledger {
797
799
  let session = sessions.find(s => s.id === sessionId)
798
800
  if (session === undefined) {
799
801
  session = zeroSession(sessionId)
802
+ session.at = atMs // 会话首次入账时刻(按会话排行「按时间排序」用)。
800
803
  sessions.push(session)
801
804
  if (sessions.length > MAX_SESSIONS_PER_DAY) sessions = sessions.slice(-MAX_SESSIONS_PER_DAY)
802
805
  day.sessions = sessions
@@ -11,6 +11,7 @@ const num = z.number()
11
11
  const sessionSchema = z.object({
12
12
  id: z.string(),
13
13
  title: z.string().optional(),
14
+ at: num.optional(),
14
15
  input: num,
15
16
  output: num,
16
17
  cacheRead: num,
@@ -254,6 +255,8 @@ const _day$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#DayRecord', sch
254
255
  const _date$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#DayKey', schema: z.string() }
255
256
  const _topSessions$codec = { mode: 'strict', typeSymbol: 'dsh-cost-meter#TopSessions', schema: topSessionsSchema }
256
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() }
257
260
 
258
261
  export const TYPERT = {
259
262
  package: 'dsh-cost-meter',
@@ -355,6 +358,8 @@ export const TYPERT = {
355
358
  invocation: { kind: 'direct' },
356
359
  parameters: [
357
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 },
358
363
  ],
359
364
  result: _topSessions$codec,
360
365
  },
@@ -435,9 +440,9 @@ export const TYPERT = {
435
440
  {
436
441
  kind: 'method',
437
442
  name: 'getTopSessions',
438
- signature: 'getTopSessions(limit: number): TopSessions',
439
- summary: '跨全部日期按费用降序返回前 N 个会话(不分日期视角)。Return the top N sessions by cost across all days.',
440
- 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 */',
441
446
  },
442
447
  ],
443
448
  types: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cost-meter",
3
- "version": "1.5.9",
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",