@goodandready/dsh-clinebot 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.4] - 2026-10-21
9
+
10
+ ### Added
11
+ - **Analytics UX**: latency percentiles P50/P95 (compact histograms), a clickable weekday×hour heatmap with cell detail, JSON export at `GET /stats/export?format=json`, and configurable `statsRetentionDays` for day/hour/session telemetry (default 95, 0 keeps everything).
12
+ - Retention is exposed in Settings → ClineBot → Advanced and the TUI; JSON export is available beside CSV.
13
+
8
14
  ## [0.6.3] - 2026-10-21
9
15
 
10
16
  ### Added
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>Native ClineBot / ClinePass Provider Companion for DeepSeek Harness</h3>
6
6
 
7
- <p><b>0.6.3</b> adds quota burn-down forecasts and optional daily/monthly spend guards on top of the adapter-only line. Chat runs on the native <code>ctx.llm</code> adapter and requires DSH <code>0.2.1-rc.2</code>; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
7
+ <p><b>0.6.4</b> adds P50/P95 latency, clickable heatmap detail, JSON export and configurable retention on top of quota forecasts and spend guards. Chat runs on the native <code>ctx.llm</code> adapter and requires DSH <code>0.2.1-rc.2</code>; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -197,6 +197,7 @@ dsh-clinebot:
197
197
  your-reasoning-model: high
198
198
  modelContextOverrides: []
199
199
  statsPath: ~/.dsh/clinebot-stats.json
200
+ statsRetentionDays: 95
200
201
  dailyBudgetUsd: 0
201
202
  monthlyBudgetUsd: 0
202
203
  timeoutMs: 30000
@@ -220,6 +221,7 @@ dsh-clinebot:
220
221
  | `modelReasoningDefaults` | `object` | `{}` | Configured default reasoning effort per model (`low`, `medium`, `high`, `max`) |
221
222
  | `modelContextOverrides` | `array` | `[]` | User-defined model context length and max token overrides |
222
223
  | `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Persistent on-disk path for token usage analytics |
224
+ | `statsRetentionDays` | `number` | `95` | Retention for day/hour/session telemetry; 0 keeps everything |
223
225
  | `dailyBudgetUsd` | `number` | `0` | Optional estimated daily spend guard; 0 disables it |
224
226
  | `monthlyBudgetUsd` | `number` | `0` | Optional estimated monthly spend guard; 0 disables it |
225
227
  | `accounts` | `array` | `[]` | Additional accounts for multi-account failover and quota rotation (`[{ label, apiKeyEnv }]`) |
package/README.ru.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>Нативное подключение провайдера ClineBot / ClinePass для DeepSeek Harness</h3>
6
6
 
7
- <p><b>0.6.3</b> добавляет прогноз расхода квоты и опциональные дневной/месячный лимиты трат. Чат работает через нативный адаптер <code>ctx.llm</code>, требуется DSH <code>0.2.1-rc.2</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
7
+ <p><b>0.6.4</b> добавляет P50/P95 задержки, кликабельный heatmap, JSON-экспорт и настраиваемый retention поверх прогноза квоты и лимитов трат. Чат работает через нативный адаптер <code>ctx.llm</code>, требуется DSH <code>0.2.1-rc.2</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -166,6 +166,7 @@ dsh-clinebot:
166
166
  your-reasoning-model: high
167
167
  modelContextOverrides: []
168
168
  statsPath: ~/.dsh/clinebot-stats.json
169
+ statsRetentionDays: 95
169
170
  dailyBudgetUsd: 0
170
171
  monthlyBudgetUsd: 0
171
172
  timeoutMs: 30000
@@ -189,6 +190,7 @@ dsh-clinebot:
189
190
  | `modelReasoningDefaults` | `object` | `{}` | Значения thinking effort по умолчанию для моделей (`low`, `medium`, `high`, `max`) |
190
191
  | `modelContextOverrides` | `array` | `[]` | Пользовательские переопределения размера контекста и лимита токенов |
191
192
  | `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | Путь к файлу персистентной аналитики использования токенов |
193
+ | `statsRetentionDays` | `number` | `95` | Срок хранения дневной/часовой/сессионной телеметрии; 0 — хранить всё |
192
194
  | `dailyBudgetUsd` | `number` | `0` | Опциональный дневной лимит оценочных трат; 0 отключает |
193
195
  | `monthlyBudgetUsd` | `number` | `0` | Опциональный месячный лимит оценочных трат; 0 отключает |
194
196
  | `accounts` | `array` | `[]` | Дополнительные аккаунты для ротации квоты и failover (`[{ label, apiKeyEnv }]`) |
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>适用于 DeepSeek Harness 的 ClineBot / ClinePass 原生模型提供商伴侣插件</h3>
6
6
 
7
- <p><b>0.6.3</b> 添加配额消耗预测和可选的每日/每月花费上限。聊天使用原生 <code>ctx.llm</code> 适配器,需要 DSH <code>0.2.1-rc.2</code>;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
7
+ <p><b>0.6.4</b> 添加 P50/P95 延迟、可点击热力图、JSON 导出和可配置保留期,并保留配额预测与花费上限。聊天使用原生 <code>ctx.llm</code> 适配器,需要 DSH <code>0.2.1-rc.2</code>;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -168,6 +168,7 @@ dsh-clinebot:
168
168
  your-reasoning-model: high
169
169
  modelContextOverrides: []
170
170
  statsPath: ~/.dsh/clinebot-stats.json
171
+ statsRetentionDays: 95
171
172
  dailyBudgetUsd: 0
172
173
  monthlyBudgetUsd: 0
173
174
  timeoutMs: 30000
@@ -191,6 +192,7 @@ dsh-clinebot:
191
192
  | `modelReasoningDefaults` | `object` | `{}` | 各模型默认思考强度配置 (`low`, `medium`, `high`, `max`) |
192
193
  | `modelContextOverrides` | `array` | `[]` | 用户自定义模型上下文窗口与最大 Token 数量覆盖 |
193
194
  | `statsPath` | `string` | `"~/.dsh/clinebot-stats.json"` | 本地持久化 Token 用量统计文件路径 |
195
+ | `statsRetentionDays` | `number` | `95` | 日/小时/会话遥测保留天数;0 表示永久保留 |
194
196
  | `dailyBudgetUsd` | `number` | `0` | 可选的每日估算花费上限;0 表示关闭 |
195
197
  | `monthlyBudgetUsd` | `number` | `0` | 可选的每月估算花费上限;0 表示关闭 |
196
198
  | `accounts` | `array` | `[]` | 额外轮转账号池 (`[{ label, apiKeyEnv }]`) |
package/lib/client.js CHANGED
@@ -195,7 +195,7 @@ const en = {
195
195
  'stats.requests': 'Successful / Total Requests',
196
196
  'stats.tokens': 'Estimated Session Tokens',
197
197
  'stats.reasoning_tokens': 'Reasoning Tokens (Thinking)',
198
- 'stats.export_csv': 'Export CSV',
198
+ 'stats.export_csv': 'Export CSV', 'stats.export_json': 'Export JSON',
199
199
  'stats.latency': 'Last Request Latency',
200
200
  'stats.last_req': 'Last Request Time',
201
201
  'models.custom_add_title': 'Custom Models',
@@ -260,6 +260,7 @@ const en = {
260
260
  'settings.base_url': 'API base URL', 'settings.base_url_hint': 'ClinePass API root. Remote endpoints must use https://.',
261
261
  'settings.timeout': 'Connect timeout (ms)', 'settings.timeout_hint': 'Maximum wait for the upstream response headers.', 'settings.smoke_timeout': 'Smoke-test timeout (ms)', 'settings.smoke_timeout_hint': 'Maximum wait for the diagnostic chat completion.',
262
262
  'settings.stream_idle': 'Stream idle timeout (ms)', 'settings.stream_idle_hint': 'Maximum gap between SSE chunks before the stream is treated as dead.', 'settings.max_payload': 'Max multimodal payload (MB)', 'settings.max_payload_hint': 'Oldest images are evicted once the request exceeds this budget.',
263
+ 'settings.stats_retention': 'Stats retention (days)', 'settings.stats_retention_hint': 'Trim day/hour/session telemetry older than this. 0 keeps everything.',
263
264
  'settings.daily_budget': 'Daily budget (USD)', 'settings.daily_budget_hint': 'Estimated spend guard for the current UTC day. 0 disables it.', 'settings.monthly_budget': 'Monthly budget (USD)', 'settings.monthly_budget_hint': 'Estimated spend guard for the current UTC month. 0 disables it.',
264
265
  'settings.stats_path': 'Stats file path', 'settings.stats_path_hint': 'Persistent JSON telemetry path (supports ~/).', 'settings.models_cache_path': 'Models cache path', 'settings.models_cache_path_hint': 'Offline model catalogue cache (supports ~/).',
265
266
  'providerCard.title': 'ClineBot (ClinePass)',
@@ -286,7 +287,9 @@ const en = {
286
287
  'analytics.heatmap_empty': 'No hourly data yet. Send a message to start the heatmap.',
287
288
  'analytics.input': 'Input', 'analytics.output': 'Output', 'analytics.reasoning': 'Reasoning',
288
289
  'analytics.cache_read': 'Cache read', 'analytics.cache_write': 'Cache write',
289
- 'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency', 'analytics.errors': 'Errors',
290
+ 'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency',
291
+ 'analytics.p50_latency': 'P50 latency', 'analytics.p95_latency': 'P95 latency',
292
+ 'analytics.heatmap_selected': '{day} {hour}:00 — {count} requests. Click the cell again to clear.', 'analytics.errors': 'Errors',
290
293
  'analytics.no_key': 'ClinePass key is not configured.',
291
294
  'analytics.no_key_hint': 'Open Settings → ClineBot and paste a key, or import a discovered local key.',
292
295
  'analytics.health_error': 'ClinePass endpoint is unreachable.',
@@ -477,7 +480,7 @@ const zh = {
477
480
  'stats.requests': '成功 / 总请求次数',
478
481
  'stats.tokens': '会话累计 Token 估算',
479
482
  'stats.reasoning_tokens': '深度思考 Token 消耗',
480
- 'stats.export_csv': '导出 CSV',
483
+ 'stats.export_csv': '导出 CSV', 'stats.export_json': '导出 JSON',
481
484
  'stats.latency': '最近一次请求延迟',
482
485
  'stats.last_req': '最近请求时间',
483
486
  'models.custom_add_title': '自定义模型',
@@ -542,6 +545,7 @@ const zh = {
542
545
  'settings.base_url': 'API 基础地址', 'settings.base_url_hint': 'ClinePass API 根地址。远程端点必须使用 https://。',
543
546
  'settings.timeout': '连接超时(毫秒)', 'settings.timeout_hint': '等待上游响应头的最大时间。', 'settings.smoke_timeout': '烟雾测试超时(毫秒)', 'settings.smoke_timeout_hint': '等待诊断聊天补全的最大时间。',
544
547
  'settings.stream_idle': '流空闲超时(毫秒)', 'settings.stream_idle_hint': 'SSE 数据块之间的最大间隔,超过后视为断流。', 'settings.max_payload': '多模态负载上限(MB)', 'settings.max_payload_hint': '请求超过预算时会淘汰最旧的图片。',
548
+ 'settings.stats_retention': '统计保留天数', 'settings.stats_retention_hint': '超过该天数的日/小时/会话遥测会被清理。0 表示永久保留。',
545
549
  'settings.daily_budget': '每日预算(USD)', 'settings.daily_budget_hint': '按当前 UTC 日估算的花费上限。0 表示关闭。', 'settings.monthly_budget': '每月预算(USD)', 'settings.monthly_budget_hint': '按当前 UTC 月估算的花费上限。0 表示关闭。',
546
550
  'settings.stats_path': '统计文件路径', 'settings.stats_path_hint': '持久化 JSON 遥测路径(支持 ~/)。', 'settings.models_cache_path': '模型缓存路径', 'settings.models_cache_path_hint': '离线模型目录缓存(支持 ~/)。',
547
551
  'providerCard.title': 'ClineBot(ClinePass)',
@@ -569,6 +573,8 @@ const zh = {
569
573
  'analytics.input': '输入', 'analytics.output': '输出', 'analytics.reasoning': '推理',
570
574
  'analytics.cache_read': '缓存读取', 'analytics.cache_write': '缓存写入',
571
575
  'analytics.avg_ttft': '平均首字延迟', 'analytics.avg_latency': '平均延迟', 'analytics.errors': '错误',
576
+ 'analytics.p50_latency': 'P50 延迟', 'analytics.p95_latency': 'P95 延迟',
577
+ 'analytics.heatmap_selected': '{day} {hour}:00 — {count} 次请求。再次点击可清除。',
572
578
  'analytics.no_key': '尚未配置 ClinePass 密钥。',
573
579
  'analytics.no_key_hint': '打开“设置 → ClineBot”粘贴密钥,或导入已发现的本地密钥。',
574
580
  'analytics.health_error': 'ClinePass 端点不可达。',
@@ -1033,7 +1039,9 @@ function ensureCss() {
1033
1039
  .cb-heat-hour{color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center}
1034
1040
  .cb-heat-day{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px;white-space:nowrap}
1035
1041
  .cb-heat-cell,.cb-heat-swatch{display:block;border-radius:3px;background:var(--dsw-alias-bg-layer-2)}
1036
- .cb-heat-cell{height:14px}
1042
+ .cb-heat-cell{height:14px;border:0;padding:0;cursor:pointer}
1043
+ .cb-heat-cell:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
1044
+ .cb-heat-cell.cb-heat-selected{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
1037
1045
  .cb-heat-swatch{width:12px;height:12px;border-radius:2px}
1038
1046
  .cb-heat-l0{background:var(--dsw-alias-bg-layer-2)}
1039
1047
  .cb-heat-l1{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-bg-layer-2))}
@@ -1762,6 +1770,14 @@ function AdvancedSection({ draft, onStage, t }) {
1762
1770
  type: 'number',
1763
1771
  onChange: numeric('maxPayloadMb', 0),
1764
1772
  }),
1773
+ React.createElement(AdvancedField, {
1774
+ id: 'cb-retention',
1775
+ label: t('settings.stats_retention'),
1776
+ hint: t('settings.stats_retention_hint'),
1777
+ value: current.statsRetentionDays,
1778
+ type: 'number',
1779
+ onChange: numeric('statsRetentionDays', -1),
1780
+ }),
1765
1781
  React.createElement(AdvancedField, {
1766
1782
  id: 'cb-daily-budget',
1767
1783
  label: t('settings.daily_budget'),
@@ -3519,6 +3535,16 @@ function StatsSection({ status, onResetStats, busy, t }) {
3519
3535
  React.createElement(
3520
3536
  'div',
3521
3537
  { className: 'cb-section-actions' },
3538
+ React.createElement(
3539
+ 'a',
3540
+ {
3541
+ href: '/dsh-clinebot/stats/export?format=json',
3542
+ download: 'clinebot-stats.json',
3543
+ className: 'cb-btn cb-btn-link',
3544
+ },
3545
+ React.createElement(CbIcon, { name: 'download', size: 13 }),
3546
+ ` ${t('stats.export_json')}`
3547
+ ),
3522
3548
  React.createElement(
3523
3549
  'a',
3524
3550
  {
@@ -3957,7 +3983,7 @@ function heatHourLabel(hour) {
3957
3983
  return hour % 3 === 0 ? String(hour).padStart(2, '0') : ''
3958
3984
  }
3959
3985
 
3960
- function HeatmapView({ heatmap, model, onModelChange, t }) {
3986
+ function HeatmapView({ heatmap, model, onModelChange, onSelect, selected, t }) {
3961
3987
  if (!heatmap || !Array.isArray(heatmap.grid)) {
3962
3988
  return React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
3963
3989
  }
@@ -4021,10 +4047,14 @@ function HeatmapView({ heatmap, model, onModelChange, t }) {
4021
4047
  React.Fragment,
4022
4048
  { key: `day-${day}` },
4023
4049
  React.createElement('span', { className: 'cb-heat-day' }, label),
4024
- (grid[day] || []).map((value, hour) => React.createElement('span', {
4050
+ (grid[day] || []).map((value, hour) => React.createElement('button', {
4025
4051
  key: `cell-${day}-${hour}`,
4026
- className: `cb-heat-cell cb-heat-l${level(value)}`,
4052
+ type: 'button',
4053
+ className: `cb-heat-cell cb-heat-l${level(value)}${selected?.day === day && selected?.hour === hour ? ' cb-heat-selected' : ''}`,
4027
4054
  title: `${label} ${String(hour).padStart(2, '0')}:00 — ${value} ${t('analytics.requests')}`,
4055
+ 'aria-label': `${label} ${String(hour).padStart(2, '0')}:00 — ${value}`,
4056
+ 'aria-pressed': selected?.day === day && selected?.hour === hour,
4057
+ onClick: () => onSelect && onSelect({ day, hour, value, label }),
4028
4058
  }))
4029
4059
  ))
4030
4060
  ),
@@ -4196,6 +4226,7 @@ function DashboardDialog(props) {
4196
4226
  const [selectedAccount, setSelectedAccount] = React.useState('')
4197
4227
  const [analyticsDays, setAnalyticsDays] = React.useState(7)
4198
4228
  const [heatmapModel, setHeatmapModel] = React.useState('')
4229
+ const [selectedHeat, setSelectedHeat] = React.useState(null)
4199
4230
 
4200
4231
  const loadData = React.useCallback(async () => {
4201
4232
  setLoading(true)
@@ -4370,6 +4401,8 @@ function DashboardDialog(props) {
4370
4401
  [t('analytics.cache_write'), fmtTokens(totals.cacheWriteTokens)],
4371
4402
  [t('analytics.avg_ttft'), fmtMs(totals.avgTtftMs)],
4372
4403
  [t('analytics.avg_latency'), fmtMs(totals.avgLatencyMs)],
4404
+ [t('analytics.p50_latency'), fmtMs(totals.p50LatencyMs)],
4405
+ [t('analytics.p95_latency'), fmtMs(totals.p95LatencyMs)],
4373
4406
  [t('analytics.errors'), String(Math.round(Number(totals.failed) || 0))],
4374
4407
  ]
4375
4408
  : []
@@ -4522,8 +4555,19 @@ function DashboardDialog(props) {
4522
4555
  heatmap: analytics?.heatmap,
4523
4556
  model: heatmapModel,
4524
4557
  onModelChange: setHeatmapModel,
4558
+ selected: selectedHeat,
4559
+ onSelect: (cell) => setSelectedHeat((current) => (
4560
+ current && current.day === cell.day && current.hour === cell.hour ? null : cell
4561
+ )),
4525
4562
  t,
4526
- })
4563
+ }),
4564
+ selectedHeat
4565
+ ? React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_selected', {
4566
+ day: selectedHeat.label,
4567
+ hour: String(selectedHeat.hour).padStart(2, '0'),
4568
+ count: selectedHeat.value,
4569
+ }))
4570
+ : null
4527
4571
  )
4528
4572
 
4529
4573
  const panelBody = React.createElement(
package/lib/config.js CHANGED
@@ -99,6 +99,8 @@ export const Config = z.object({
99
99
  .description('User-defined custom gateway models.').volatile(),
100
100
  statsPath: z.string().default('~/.dsh/clinebot-stats.json')
101
101
  .description('Local on-disk persistent path for token usage analytics.').volatile(),
102
+ statsRetentionDays: z.number().min(0).max(3650).default(95)
103
+ .description('Retention for day/hour/session telemetry in days. 0 keeps everything.').volatile(),
102
104
  dailyBudgetUsd: z.number().min(0).default(0)
103
105
  .description('Optional daily estimated-spend guard in USD. 0 disables the guard.').volatile(),
104
106
  monthlyBudgetUsd: z.number().min(0).default(0)
@@ -242,6 +244,9 @@ export function publicConfig(cfg) {
242
244
  modelRoutes: cfg?.modelRoutes && typeof cfg.modelRoutes === 'object' ? cfg.modelRoutes : {},
243
245
  customModels: custom,
244
246
  statsPath: String(cfg?.statsPath || '~/.dsh/clinebot-stats.json'),
247
+ statsRetentionDays: Number.isFinite(Number(cfg?.statsRetentionDays))
248
+ ? Math.max(0, Math.min(3650, Number(cfg.statsRetentionDays)))
249
+ : 95,
245
250
  dailyBudgetUsd: Math.max(0, Number(cfg?.dailyBudgetUsd) || 0),
246
251
  monthlyBudgetUsd: Math.max(0, Number(cfg?.monthlyBudgetUsd) || 0),
247
252
  disabledModels: disabledList,
package/lib/index.js CHANGED
@@ -20,7 +20,7 @@ import { ClineBotAdapter } from './adapter.js'
20
20
  import { registerSlashCommand } from './slash-command.js'
21
21
  import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
22
22
  import { resolveAccountPool } from './account-pool.js'
23
- import { loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
23
+ import { loadStats, flushStats, resolvePath, switchStatsStorage, setStatsRetentionDays } from './stats-storage.js'
24
24
  import { service, migrateLegacyPiAiProvider } from './services.js'
25
25
  import { applyTuiSettings } from './tui-settings.js'
26
26
 
@@ -91,10 +91,13 @@ export function apply(ctx, config) {
91
91
  }
92
92
 
93
93
  let activeStatsPath = resolvePath(publicConfig(live()).statsPath)
94
+ setStatsRetentionDays(publicConfig(live()).statsRetentionDays)
94
95
  loadStats(activeStatsPath)
95
96
 
96
97
  const syncStatsStorage = (cfg) => {
97
- const nextPath = resolvePath(publicConfig(cfg).statsPath)
98
+ const pub = publicConfig(cfg)
99
+ setStatsRetentionDays(pub.statsRetentionDays)
100
+ const nextPath = resolvePath(pub.statsPath)
98
101
  if (nextPath !== activeStatsPath) {
99
102
  switchStatsStorage(nextPath)
100
103
  activeStatsPath = nextPath
@@ -4,6 +4,7 @@ import { publicConfig, plainConfig, Config } from '../config.js'
4
4
  import { buildStatus } from '../provider-sync.js'
5
5
  import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-client.js'
6
6
  import { getStatsSummary, getSessionStats, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
7
+ import { exportStatsAsJson } from '../stats-export.js'
7
8
  import { priceTable } from '../model-prices.js'
8
9
 
9
10
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
@@ -241,11 +242,21 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
241
242
  if (!assertTrustedSettingsRequest(req, res, ctx)) return
242
243
  try {
243
244
  let detailed = false
245
+ let format = 'csv'
244
246
  try {
245
- detailed = new URL(req.url, 'http://127.0.0.1').searchParams.get('detailed') === '1'
247
+ const url = new URL(req.url, 'http://127.0.0.1')
248
+ detailed = url.searchParams.get('detailed') === '1'
249
+ format = url.searchParams.get('format') === 'json' ? 'json' : 'csv'
246
250
  } catch {
247
251
  detailed = false
248
252
  }
253
+ if (format === 'json') {
254
+ res.statusCode = 200
255
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
256
+ res.setHeader('Content-Disposition', 'attachment; filename="clinebot-stats.json"')
257
+ res.end(exportStatsAsJson())
258
+ return
259
+ }
249
260
  const csv = exportStatsAsCsv(detailed)
250
261
  res.statusCode = 200
251
262
  res.setHeader('Content-Type', 'text/csv; charset=utf-8')
@@ -0,0 +1,11 @@
1
+ import { getStatsSummary } from './stats-storage.js'
2
+
3
+ /**
4
+ * Export the complete persisted telemetry document as JSON.
5
+ */
6
+ export function exportStatsAsJson() {
7
+ return JSON.stringify({
8
+ exportedAt: new Date().toISOString(),
9
+ stats: getStatsSummary(),
10
+ }, null, 2)
11
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Compact latency histograms and retention pruning for the stats store.
3
+ */
4
+
5
+ const LATENCY_BOUNDS = [50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 51200, 102400, Infinity]
6
+
7
+ export function latencyBucketIndex(ms) {
8
+ const value = Math.max(0, Number(ms) || 0)
9
+ const index = LATENCY_BOUNDS.findIndex((bound) => value <= bound)
10
+ return index < 0 ? LATENCY_BOUNDS.length - 1 : index
11
+ }
12
+
13
+ export function mergeHistogram(target, source) {
14
+ if (!Array.isArray(source)) return target
15
+ for (let index = 0; index < source.length; index += 1) {
16
+ target[index] = Number(target[index] || 0) + Number(source[index] || 0)
17
+ }
18
+ return target
19
+ }
20
+
21
+ export function percentileFromHistogram(histogram, percentile) {
22
+ if (!Array.isArray(histogram)) return 0
23
+ const total = histogram.reduce((sum, value) => sum + Number(value || 0), 0)
24
+ if (total <= 0) return 0
25
+ const target = Math.max(1, Math.ceil((Math.max(1, Math.min(99, percentile)) / 100) * total))
26
+ let seen = 0
27
+ for (let index = 0; index < histogram.length; index += 1) {
28
+ seen += Number(histogram[index] || 0)
29
+ if (seen >= target) {
30
+ const bound = LATENCY_BOUNDS[index]
31
+ return bound === Infinity ? LATENCY_BOUNDS[LATENCY_BOUNDS.length - 2] : bound
32
+ }
33
+ }
34
+ return 0
35
+ }
36
+
37
+ function localDateKey(date) {
38
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
39
+ }
40
+
41
+ /**
42
+ * Prune time buckets and sessions older than the configured retention window.
43
+ * @param {Record<string, any>} stats
44
+ * @param {number} days 0 keeps everything
45
+ * @param {number} [now]
46
+ */
47
+ export function pruneRetentionOnStats(stats, days, now = Date.now()) {
48
+ const retentionDays = Math.max(0, Number(days) || 0)
49
+ if (!stats || retentionDays <= 0) return
50
+ const cutoffMs = now - retentionDays * 24 * 60 * 60 * 1000
51
+ const cutoffDay = new Date(cutoffMs).toISOString().slice(0, 10)
52
+ const cutoffMonth = cutoffDay.slice(0, 7)
53
+ const cutoffHourDay = localDateKey(new Date(cutoffMs))
54
+ for (const key of Object.keys(stats.byDay || {})) {
55
+ if (key < cutoffDay) delete stats.byDay[key]
56
+ }
57
+ for (const key of Object.keys(stats.byMonth || {})) {
58
+ if (key < cutoffMonth) delete stats.byMonth[key]
59
+ }
60
+ for (const container of [stats.byHour, stats.byHourModel]) {
61
+ for (const key of Object.keys(container || {})) {
62
+ if (key.slice(0, 10) < cutoffHourDay) delete container[key]
63
+ }
64
+ }
65
+ for (const [key, bucket] of Object.entries(stats.bySession || {})) {
66
+ if (Number(bucket?.lastAt || 0) < cutoffMs) delete stats.bySession[key]
67
+ }
68
+ }
@@ -8,6 +8,12 @@
8
8
  import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
+ import {
12
+ latencyBucketIndex,
13
+ mergeHistogram,
14
+ percentileFromHistogram,
15
+ pruneRetentionOnStats,
16
+ } from './stats-metrics.js'
11
17
 
12
18
  const MAX_SESSIONS = 500
13
19
  const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
@@ -16,6 +22,17 @@ const HEATMAP_RETENTION_MS = 95 * 24 * 60 * 60 * 1000
16
22
  let inMemoryStats = createEmptyStats()
17
23
  let saveTimer = null
18
24
  let activeStatsPath = ''
25
+ let statsRetentionDays = 95
26
+
27
+ export function setStatsRetentionDays(days) {
28
+ statsRetentionDays = Math.max(0, Math.min(3650, Number(days) || 0))
29
+ pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
30
+ return statsRetentionDays
31
+ }
32
+
33
+ export function getStatsRetentionDays() {
34
+ return statsRetentionDays
35
+ }
19
36
 
20
37
  function emptyBucket() {
21
38
  return {
@@ -122,6 +139,7 @@ export function loadStats(customPath) {
122
139
  byHourModel: parsed.byHourModel || {},
123
140
  }
124
141
  backfillHourlyFromSessions()
142
+ pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
125
143
  return inMemoryStats
126
144
  }
127
145
  }
@@ -142,6 +160,7 @@ export function loadStats(customPath) {
142
160
  } catch {}
143
161
  }
144
162
  inMemoryStats = createEmptyStats()
163
+ pruneRetentionOnStats(inMemoryStats, statsRetentionDays)
145
164
  return inMemoryStats
146
165
  }
147
166
 
@@ -203,6 +222,11 @@ function addToBucket(bucket, entry) {
203
222
  else bucket.unpricedRequests += 1
204
223
  bucket.ttftMsSum += entry.ttftMs
205
224
  bucket.latencyMsSum += entry.latencyMs
225
+ if (entry.latencyMs > 0) {
226
+ if (!Array.isArray(bucket.latencyHistogram)) bucket.latencyHistogram = []
227
+ const index = latencyBucketIndex(entry.latencyMs)
228
+ bucket.latencyHistogram[index] = Number(bucket.latencyHistogram[index] || 0) + 1
229
+ }
206
230
  bucket.firstAt = bucket.firstAt || entry.now
207
231
  bucket.lastAt = entry.now
208
232
  if (entry.model) bucket.model = entry.model
@@ -335,6 +359,7 @@ export function recordUsage({
335
359
  if (!inMemoryStats.byHourModel[hourKey]) inMemoryStats.byHourModel[hourKey] = {}
336
360
  inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
337
361
  pruneHours(now)
362
+ pruneRetentionOnStats(inMemoryStats, statsRetentionDays, now)
338
363
  if (sessionId) {
339
364
  const key = String(sessionId)
340
365
  incrementBucket(inMemoryStats.bySession, key, entry)
@@ -450,6 +475,7 @@ export function getAnalytics(query = {}) {
450
475
  : inMemoryStats.byModel
451
476
 
452
477
  const groups = []
478
+ const totalsHistogram = []
453
479
  for (const [key, bucket] of Object.entries(source || {})) {
454
480
  const at = groupBy === 'day' ? Date.parse(`${key}T00:00:00Z`) : Number(bucket.lastAt || 0)
455
481
  if (Number.isFinite(at) && (at < from || at > to)) continue
@@ -457,6 +483,9 @@ export function getAnalytics(query = {}) {
457
483
  if (query.account && bucket.accountId && bucket.accountId !== query.account && key !== query.account) continue
458
484
  const requests = Number(bucket.requests || 0)
459
485
  const successful = Number(bucket.successful || 0)
486
+ const histogram = Array.isArray(bucket.latencyHistogram) ? bucket.latencyHistogram : []
487
+ mergeHistogram(totalsHistogram, histogram)
488
+ const avgLatencyMs = requests > 0 ? Number(bucket.latencyMsSum || 0) / requests : 0
460
489
  groups.push({
461
490
  key,
462
491
  label: key,
@@ -475,7 +504,9 @@ export function getAnalytics(query = {}) {
475
504
  unpricedRequests: Number(bucket.unpricedRequests || 0),
476
505
  successRate: requests > 0 ? (successful / requests) * 100 : 0,
477
506
  avgTtftMs: successful > 0 ? Number(bucket.ttftMsSum || 0) / successful : 0,
478
- avgLatencyMs: requests > 0 ? Number(bucket.latencyMsSum || 0) / requests : 0,
507
+ avgLatencyMs,
508
+ p50LatencyMs: percentileFromHistogram(histogram, 50) || avgLatencyMs,
509
+ p95LatencyMs: percentileFromHistogram(histogram, 95) || avgLatencyMs,
479
510
  ttftMsSum: Number(bucket.ttftMsSum || 0),
480
511
  latencyMsSum: Number(bucket.latencyMsSum || 0),
481
512
  model: bucket.model || '',
@@ -514,6 +545,8 @@ export function getAnalytics(query = {}) {
514
545
  totals.successRate = totals.requests > 0 ? (totals.successful / totals.requests) * 100 : 0
515
546
  totals.avgTtftMs = totals.successful > 0 ? totals.ttftMsSum / totals.successful : 0
516
547
  totals.avgLatencyMs = totals.requests > 0 ? totals.latencyMsSum / totals.requests : 0
548
+ totals.p50LatencyMs = percentileFromHistogram(totalsHistogram, 50) || totals.avgLatencyMs
549
+ totals.p95LatencyMs = percentileFromHistogram(totalsHistogram, 95) || totals.avgLatencyMs
517
550
 
518
551
  return {
519
552
  range: { from, to },
@@ -559,3 +592,4 @@ export function exportStatsAsCsv(detailed = false) {
559
592
  }
560
593
  return lines.join('\n')
561
594
  }
595
+
@@ -9,7 +9,7 @@
9
9
  import { createRequire } from 'node:module'
10
10
 
11
11
  const require = createRequire(import.meta.url)
12
- let packageVersion = '0.6.3'
12
+ let packageVersion = '0.6.4'
13
13
  try {
14
14
  packageVersion = require('../../package.json').version || packageVersion
15
15
  } catch {
@@ -114,6 +114,18 @@ export function buildTuiSection({ live }) {
114
114
  return Number.isInteger(value) && value >= -1 ? { kind: 'set', value } : undefined
115
115
  },
116
116
  },
117
+ {
118
+ path: ['statsRetentionDays'],
119
+ group: 'advanced',
120
+ kind: 'number',
121
+ label: 'Stats retention (days)',
122
+ hint: 'Trim day/hour/session telemetry; 0 keeps everything.',
123
+ format: (value) => (Number(value) > 0 ? String(Number(value)) : ''),
124
+ parse: (text) => {
125
+ const value = Number(text)
126
+ return Number.isFinite(value) && value >= 0 ? { kind: 'set', value } : { kind: 'clear' }
127
+ },
128
+ },
117
129
  {
118
130
  path: ['dailyBudgetUsd'],
119
131
  group: 'advanced',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",