@goodandready/dsh-clinebot 0.6.3 → 0.6.5
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 +15 -0
- package/README.md +3 -1
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/client.js +76 -22
- package/lib/config.js +5 -0
- package/lib/index.js +5 -2
- package/lib/routes/settings.js +12 -1
- package/lib/slash-command.js +30 -5
- package/lib/stats-export.js +11 -0
- package/lib/stats-metrics.js +68 -0
- package/lib/stats-storage.js +35 -1
- package/lib/transport/cline-transport.js +1 -1
- package/lib/tui-settings.js +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,21 @@ 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.5] - 2026-10-21
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Grouped model table**: Settings now separates `Included in plan`, `Other models` and `Custom models` with per-group counts.
|
|
12
|
+
- **Extended `/cline`**: `/cline cost` (`/cline budget`) reports estimated UTC day/month spend and guard state; `/cline usage` falls through to the quota report, which now includes the monthly window.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- Headless/web fixture profiles no longer carry the removed `transport` and `proxyMode` fields.
|
|
16
|
+
|
|
17
|
+
## [0.6.4] - 2026-10-21
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **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).
|
|
21
|
+
- Retention is exposed in Settings → ClineBot → Advanced and the TUI; JSON export is available beside CSV.
|
|
22
|
+
|
|
8
23
|
## [0.6.3] - 2026-10-21
|
|
9
24
|
|
|
10
25
|
### 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.
|
|
7
|
+
<p><b>0.6.5</b> groups the model table by plan availability and adds <code>/cline cost</code> / monthly quota reporting on top of the analytics work. 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.
|
|
7
|
+
<p><b>0.6.5</b> группирует таблицу моделей по доступности в тарифе и добавляет <code>/cline cost</code> / отчёт по месячной квоте поверх аналитики. Чат работает через нативный адаптер <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.
|
|
7
|
+
<p><b>0.6.5</b> 按套餐可用性对模型表分组,并在分析功能之上添加 <code>/cline cost</code> / 每月配额报告。聊天使用原生 <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
|
@@ -140,6 +140,7 @@ const en = {
|
|
|
140
140
|
'models.visible_models_hint': 'Choose which models appear in the DSH chat selector (empty means all supported models).',
|
|
141
141
|
'models.in_plan': 'In Plan',
|
|
142
142
|
'models.out_of_plan': 'Out of Plan',
|
|
143
|
+
'models.group_plan': 'Included in plan', 'models.group_other': 'Other models', 'models.group_custom': 'Custom models',
|
|
143
144
|
'models.plan_only': 'Plan Only',
|
|
144
145
|
'models.show_all': 'Show All',
|
|
145
146
|
'models.clean_stale': 'Clean Stale ({count})',
|
|
@@ -195,7 +196,7 @@ const en = {
|
|
|
195
196
|
'stats.requests': 'Successful / Total Requests',
|
|
196
197
|
'stats.tokens': 'Estimated Session Tokens',
|
|
197
198
|
'stats.reasoning_tokens': 'Reasoning Tokens (Thinking)',
|
|
198
|
-
'stats.export_csv': 'Export CSV',
|
|
199
|
+
'stats.export_csv': 'Export CSV', 'stats.export_json': 'Export JSON',
|
|
199
200
|
'stats.latency': 'Last Request Latency',
|
|
200
201
|
'stats.last_req': 'Last Request Time',
|
|
201
202
|
'models.custom_add_title': 'Custom Models',
|
|
@@ -260,6 +261,7 @@ const en = {
|
|
|
260
261
|
'settings.base_url': 'API base URL', 'settings.base_url_hint': 'ClinePass API root. Remote endpoints must use https://.',
|
|
261
262
|
'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
263
|
'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.',
|
|
264
|
+
'settings.stats_retention': 'Stats retention (days)', 'settings.stats_retention_hint': 'Trim day/hour/session telemetry older than this. 0 keeps everything.',
|
|
263
265
|
'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
266
|
'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
267
|
'providerCard.title': 'ClineBot (ClinePass)',
|
|
@@ -286,7 +288,9 @@ const en = {
|
|
|
286
288
|
'analytics.heatmap_empty': 'No hourly data yet. Send a message to start the heatmap.',
|
|
287
289
|
'analytics.input': 'Input', 'analytics.output': 'Output', 'analytics.reasoning': 'Reasoning',
|
|
288
290
|
'analytics.cache_read': 'Cache read', 'analytics.cache_write': 'Cache write',
|
|
289
|
-
'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency',
|
|
291
|
+
'analytics.avg_ttft': 'Avg TTFT', 'analytics.avg_latency': 'Avg latency',
|
|
292
|
+
'analytics.p50_latency': 'P50 latency', 'analytics.p95_latency': 'P95 latency',
|
|
293
|
+
'analytics.heatmap_selected': '{day} {hour}:00 — {count} requests. Click the cell again to clear.', 'analytics.errors': 'Errors',
|
|
290
294
|
'analytics.no_key': 'ClinePass key is not configured.',
|
|
291
295
|
'analytics.no_key_hint': 'Open Settings → ClineBot and paste a key, or import a discovered local key.',
|
|
292
296
|
'analytics.health_error': 'ClinePass endpoint is unreachable.',
|
|
@@ -422,6 +426,7 @@ const zh = {
|
|
|
422
426
|
'models.visible_models_hint': '选择在 DSH 聊天选择器中显示的模型(为空时显示全部可用模型)。',
|
|
423
427
|
'models.in_plan': '套餐内',
|
|
424
428
|
'models.out_of_plan': '套餐外',
|
|
429
|
+
'models.group_plan': '套餐内模型', 'models.group_other': '其他模型', 'models.group_custom': '自定义模型',
|
|
425
430
|
'models.plan_only': '仅套餐模型',
|
|
426
431
|
'models.show_all': '显示全部',
|
|
427
432
|
'models.clean_stale': '清理过期 ({count})',
|
|
@@ -477,7 +482,7 @@ const zh = {
|
|
|
477
482
|
'stats.requests': '成功 / 总请求次数',
|
|
478
483
|
'stats.tokens': '会话累计 Token 估算',
|
|
479
484
|
'stats.reasoning_tokens': '深度思考 Token 消耗',
|
|
480
|
-
'stats.export_csv': '导出 CSV',
|
|
485
|
+
'stats.export_csv': '导出 CSV', 'stats.export_json': '导出 JSON',
|
|
481
486
|
'stats.latency': '最近一次请求延迟',
|
|
482
487
|
'stats.last_req': '最近请求时间',
|
|
483
488
|
'models.custom_add_title': '自定义模型',
|
|
@@ -542,6 +547,7 @@ const zh = {
|
|
|
542
547
|
'settings.base_url': 'API 基础地址', 'settings.base_url_hint': 'ClinePass API 根地址。远程端点必须使用 https://。',
|
|
543
548
|
'settings.timeout': '连接超时(毫秒)', 'settings.timeout_hint': '等待上游响应头的最大时间。', 'settings.smoke_timeout': '烟雾测试超时(毫秒)', 'settings.smoke_timeout_hint': '等待诊断聊天补全的最大时间。',
|
|
544
549
|
'settings.stream_idle': '流空闲超时(毫秒)', 'settings.stream_idle_hint': 'SSE 数据块之间的最大间隔,超过后视为断流。', 'settings.max_payload': '多模态负载上限(MB)', 'settings.max_payload_hint': '请求超过预算时会淘汰最旧的图片。',
|
|
550
|
+
'settings.stats_retention': '统计保留天数', 'settings.stats_retention_hint': '超过该天数的日/小时/会话遥测会被清理。0 表示永久保留。',
|
|
545
551
|
'settings.daily_budget': '每日预算(USD)', 'settings.daily_budget_hint': '按当前 UTC 日估算的花费上限。0 表示关闭。', 'settings.monthly_budget': '每月预算(USD)', 'settings.monthly_budget_hint': '按当前 UTC 月估算的花费上限。0 表示关闭。',
|
|
546
552
|
'settings.stats_path': '统计文件路径', 'settings.stats_path_hint': '持久化 JSON 遥测路径(支持 ~/)。', 'settings.models_cache_path': '模型缓存路径', 'settings.models_cache_path_hint': '离线模型目录缓存(支持 ~/)。',
|
|
547
553
|
'providerCard.title': 'ClineBot(ClinePass)',
|
|
@@ -569,6 +575,8 @@ const zh = {
|
|
|
569
575
|
'analytics.input': '输入', 'analytics.output': '输出', 'analytics.reasoning': '推理',
|
|
570
576
|
'analytics.cache_read': '缓存读取', 'analytics.cache_write': '缓存写入',
|
|
571
577
|
'analytics.avg_ttft': '平均首字延迟', 'analytics.avg_latency': '平均延迟', 'analytics.errors': '错误',
|
|
578
|
+
'analytics.p50_latency': 'P50 延迟', 'analytics.p95_latency': 'P95 延迟',
|
|
579
|
+
'analytics.heatmap_selected': '{day} {hour}:00 — {count} 次请求。再次点击可清除。',
|
|
572
580
|
'analytics.no_key': '尚未配置 ClinePass 密钥。',
|
|
573
581
|
'analytics.no_key_hint': '打开“设置 → ClineBot”粘贴密钥,或导入已发现的本地密钥。',
|
|
574
582
|
'analytics.health_error': 'ClinePass 端点不可达。',
|
|
@@ -1033,7 +1041,9 @@ function ensureCss() {
|
|
|
1033
1041
|
.cb-heat-hour{color:var(--dsw-alias-label-tertiary);font-size:9px;line-height:12px;text-align:center}
|
|
1034
1042
|
.cb-heat-day{color:var(--dsw-alias-label-secondary);font-size:10px;line-height:14px;white-space:nowrap}
|
|
1035
1043
|
.cb-heat-cell,.cb-heat-swatch{display:block;border-radius:3px;background:var(--dsw-alias-bg-layer-2)}
|
|
1036
|
-
.cb-heat-cell{height:14px}
|
|
1044
|
+
.cb-heat-cell{height:14px;border:0;padding:0;cursor:pointer}
|
|
1045
|
+
.cb-heat-cell:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
1046
|
+
.cb-heat-cell.cb-heat-selected{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
1037
1047
|
.cb-heat-swatch{width:12px;height:12px;border-radius:2px}
|
|
1038
1048
|
.cb-heat-l0{background:var(--dsw-alias-bg-layer-2)}
|
|
1039
1049
|
.cb-heat-l1{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,var(--dsw-alias-bg-layer-2))}
|
|
@@ -1077,6 +1087,7 @@ function ensureCss() {
|
|
|
1077
1087
|
.cb-table{font-size:12px}
|
|
1078
1088
|
.cb-table th{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:11px;font-weight:600;opacity:.8}
|
|
1079
1089
|
.cb-table td{padding:5px 8px;border-bottom:1px solid rgba(127,127,127,.18);font-size:12px}
|
|
1090
|
+
.cb-model-group td{padding:8px 8px 4px;color:var(--dsw-alias-label-secondary);font-size:11px;font-weight:650;background:rgba(127,127,127,.08)}
|
|
1080
1091
|
.cb-progress-track,.cb-bar-track{height:8px;border-radius:999px;background:rgba(127,127,127,.2)}
|
|
1081
1092
|
.cb-progress-bar-fill,.cb-bar-fill,.cb-footFill,.cb-meter-fill{background:#4fbf86}
|
|
1082
1093
|
.cb-progress-bar-warn{background:#d9a24a}
|
|
@@ -1762,6 +1773,14 @@ function AdvancedSection({ draft, onStage, t }) {
|
|
|
1762
1773
|
type: 'number',
|
|
1763
1774
|
onChange: numeric('maxPayloadMb', 0),
|
|
1764
1775
|
}),
|
|
1776
|
+
React.createElement(AdvancedField, {
|
|
1777
|
+
id: 'cb-retention',
|
|
1778
|
+
label: t('settings.stats_retention'),
|
|
1779
|
+
hint: t('settings.stats_retention_hint'),
|
|
1780
|
+
value: current.statsRetentionDays,
|
|
1781
|
+
type: 'number',
|
|
1782
|
+
onChange: numeric('statsRetentionDays', -1),
|
|
1783
|
+
}),
|
|
1765
1784
|
React.createElement(AdvancedField, {
|
|
1766
1785
|
id: 'cb-daily-budget',
|
|
1767
1786
|
label: t('settings.daily_budget'),
|
|
@@ -3021,7 +3040,6 @@ function ModelsSection({
|
|
|
3021
3040
|
const [editingModelId, setEditingModelId] = React.useState(null)
|
|
3022
3041
|
const [customCtxVal, setCustomCtxVal] = React.useState('')
|
|
3023
3042
|
const [customMaxVal, setCustomMaxVal] = React.useState('')
|
|
3024
|
-
|
|
3025
3043
|
function formatCtx(num) {
|
|
3026
3044
|
num = Number(num) || 200000
|
|
3027
3045
|
if (num >= 1000000) {
|
|
@@ -3034,7 +3052,6 @@ function ModelsSection({
|
|
|
3034
3052
|
}
|
|
3035
3053
|
return String(num)
|
|
3036
3054
|
}
|
|
3037
|
-
|
|
3038
3055
|
const filteredModels = modelsList.filter((m) => {
|
|
3039
3056
|
if (viewFilter === 'vision') {
|
|
3040
3057
|
const hasVision = Boolean(m.input?.includes('image') || m.input?.includes('vision'))
|
|
@@ -3055,10 +3072,13 @@ function ModelsSection({
|
|
|
3055
3072
|
}
|
|
3056
3073
|
return true
|
|
3057
3074
|
})
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3075
|
+
const groupFor = (m) => (m.isCustom ? 'custom' : (m.inPlan ? 'plan' : 'other'))
|
|
3076
|
+
const orderedModels = [...filteredModels].sort((a, b) => {
|
|
3077
|
+
const order = { plan: 0, other: 1, custom: 2 }
|
|
3078
|
+
return order[groupFor(a)] - order[groupFor(b)]
|
|
3079
|
+
})
|
|
3080
|
+
let lastGroup = ''
|
|
3081
|
+
return React.createElement('div', { className: 'cb-section-card' },
|
|
3062
3082
|
defaultModelWarning ? React.createElement('div', { className: 'cb-alert-bad cb-inline-alert', style: { marginBottom: '12px' } }, React.createElement(CbIcon, { name: 'warning', size: 14 }), ` ${defaultModelWarning}`) : null,
|
|
3063
3083
|
React.createElement(VisibleModelsSub, {
|
|
3064
3084
|
modelsList,
|
|
@@ -3122,8 +3142,6 @@ function ModelsSection({
|
|
|
3122
3142
|
)
|
|
3123
3143
|
),
|
|
3124
3144
|
React.createElement('div', { className: 'cb-section-desc' }, t('models.desc')),
|
|
3125
|
-
|
|
3126
|
-
// Search and display filter bar
|
|
3127
3145
|
React.createElement(
|
|
3128
3146
|
'div',
|
|
3129
3147
|
{
|
|
@@ -3198,7 +3216,6 @@ function ModelsSection({
|
|
|
3198
3216
|
)
|
|
3199
3217
|
)
|
|
3200
3218
|
),
|
|
3201
|
-
|
|
3202
3219
|
React.createElement(
|
|
3203
3220
|
'table',
|
|
3204
3221
|
{ className: 'cb-table' },
|
|
@@ -3218,7 +3235,10 @@ function ModelsSection({
|
|
|
3218
3235
|
React.createElement(
|
|
3219
3236
|
'tbody',
|
|
3220
3237
|
null,
|
|
3221
|
-
|
|
3238
|
+
orderedModels.map((m) => {
|
|
3239
|
+
const groupKey = groupFor(m)
|
|
3240
|
+
const showGroup = groupKey !== lastGroup
|
|
3241
|
+
lastGroup = groupKey
|
|
3222
3242
|
const isEnabled = !disabledSet.has(m.id)
|
|
3223
3243
|
const defaultCtx = m.defaultContextLength || m.clineContextLength || 200000
|
|
3224
3244
|
const origCtx = m.originalContextLength || 200000
|
|
@@ -3227,10 +3247,7 @@ function ModelsSection({
|
|
|
3227
3247
|
const origProv = m.originalProvider || 'Original'
|
|
3228
3248
|
const isOverridden = Boolean(m.isContextOverridden)
|
|
3229
3249
|
const isOriginal = isOverridden && (m.contextLength === origCtx && (m.maxTokens || defaultMax) === origMax)
|
|
3230
|
-
|
|
3231
|
-
return React.createElement(
|
|
3232
|
-
'tr',
|
|
3233
|
-
{ key: m.id },
|
|
3250
|
+
const row = React.createElement('tr', { key: m.id },
|
|
3234
3251
|
React.createElement(
|
|
3235
3252
|
'td',
|
|
3236
3253
|
null,
|
|
@@ -3474,6 +3491,15 @@ function ModelsSection({
|
|
|
3474
3491
|
})()
|
|
3475
3492
|
)
|
|
3476
3493
|
)
|
|
3494
|
+
const groupCount = orderedModels.filter((entry) => groupFor(entry) === groupKey).length
|
|
3495
|
+
return React.createElement(React.Fragment, { key: `group-wrap-${m.id}` },
|
|
3496
|
+
showGroup
|
|
3497
|
+
? React.createElement('tr', { className: 'cb-model-group' },
|
|
3498
|
+
React.createElement('td', { colSpan: 5 }, `${t(`models.group_${groupKey}`)} · ${groupCount}`)
|
|
3499
|
+
)
|
|
3500
|
+
: null,
|
|
3501
|
+
row
|
|
3502
|
+
)
|
|
3477
3503
|
})
|
|
3478
3504
|
)
|
|
3479
3505
|
),
|
|
@@ -3519,6 +3545,16 @@ function StatsSection({ status, onResetStats, busy, t }) {
|
|
|
3519
3545
|
React.createElement(
|
|
3520
3546
|
'div',
|
|
3521
3547
|
{ className: 'cb-section-actions' },
|
|
3548
|
+
React.createElement(
|
|
3549
|
+
'a',
|
|
3550
|
+
{
|
|
3551
|
+
href: '/dsh-clinebot/stats/export?format=json',
|
|
3552
|
+
download: 'clinebot-stats.json',
|
|
3553
|
+
className: 'cb-btn cb-btn-link',
|
|
3554
|
+
},
|
|
3555
|
+
React.createElement(CbIcon, { name: 'download', size: 13 }),
|
|
3556
|
+
` ${t('stats.export_json')}`
|
|
3557
|
+
),
|
|
3522
3558
|
React.createElement(
|
|
3523
3559
|
'a',
|
|
3524
3560
|
{
|
|
@@ -3957,7 +3993,7 @@ function heatHourLabel(hour) {
|
|
|
3957
3993
|
return hour % 3 === 0 ? String(hour).padStart(2, '0') : ''
|
|
3958
3994
|
}
|
|
3959
3995
|
|
|
3960
|
-
function HeatmapView({ heatmap, model, onModelChange, t }) {
|
|
3996
|
+
function HeatmapView({ heatmap, model, onModelChange, onSelect, selected, t }) {
|
|
3961
3997
|
if (!heatmap || !Array.isArray(heatmap.grid)) {
|
|
3962
3998
|
return React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_empty'))
|
|
3963
3999
|
}
|
|
@@ -4021,10 +4057,14 @@ function HeatmapView({ heatmap, model, onModelChange, t }) {
|
|
|
4021
4057
|
React.Fragment,
|
|
4022
4058
|
{ key: `day-${day}` },
|
|
4023
4059
|
React.createElement('span', { className: 'cb-heat-day' }, label),
|
|
4024
|
-
(grid[day] || []).map((value, hour) => React.createElement('
|
|
4060
|
+
(grid[day] || []).map((value, hour) => React.createElement('button', {
|
|
4025
4061
|
key: `cell-${day}-${hour}`,
|
|
4026
|
-
|
|
4062
|
+
type: 'button',
|
|
4063
|
+
className: `cb-heat-cell cb-heat-l${level(value)}${selected?.day === day && selected?.hour === hour ? ' cb-heat-selected' : ''}`,
|
|
4027
4064
|
title: `${label} ${String(hour).padStart(2, '0')}:00 — ${value} ${t('analytics.requests')}`,
|
|
4065
|
+
'aria-label': `${label} ${String(hour).padStart(2, '0')}:00 — ${value}`,
|
|
4066
|
+
'aria-pressed': selected?.day === day && selected?.hour === hour,
|
|
4067
|
+
onClick: () => onSelect && onSelect({ day, hour, value, label }),
|
|
4028
4068
|
}))
|
|
4029
4069
|
))
|
|
4030
4070
|
),
|
|
@@ -4196,6 +4236,7 @@ function DashboardDialog(props) {
|
|
|
4196
4236
|
const [selectedAccount, setSelectedAccount] = React.useState('')
|
|
4197
4237
|
const [analyticsDays, setAnalyticsDays] = React.useState(7)
|
|
4198
4238
|
const [heatmapModel, setHeatmapModel] = React.useState('')
|
|
4239
|
+
const [selectedHeat, setSelectedHeat] = React.useState(null)
|
|
4199
4240
|
|
|
4200
4241
|
const loadData = React.useCallback(async () => {
|
|
4201
4242
|
setLoading(true)
|
|
@@ -4370,6 +4411,8 @@ function DashboardDialog(props) {
|
|
|
4370
4411
|
[t('analytics.cache_write'), fmtTokens(totals.cacheWriteTokens)],
|
|
4371
4412
|
[t('analytics.avg_ttft'), fmtMs(totals.avgTtftMs)],
|
|
4372
4413
|
[t('analytics.avg_latency'), fmtMs(totals.avgLatencyMs)],
|
|
4414
|
+
[t('analytics.p50_latency'), fmtMs(totals.p50LatencyMs)],
|
|
4415
|
+
[t('analytics.p95_latency'), fmtMs(totals.p95LatencyMs)],
|
|
4373
4416
|
[t('analytics.errors'), String(Math.round(Number(totals.failed) || 0))],
|
|
4374
4417
|
]
|
|
4375
4418
|
: []
|
|
@@ -4522,8 +4565,19 @@ function DashboardDialog(props) {
|
|
|
4522
4565
|
heatmap: analytics?.heatmap,
|
|
4523
4566
|
model: heatmapModel,
|
|
4524
4567
|
onModelChange: setHeatmapModel,
|
|
4568
|
+
selected: selectedHeat,
|
|
4569
|
+
onSelect: (cell) => setSelectedHeat((current) => (
|
|
4570
|
+
current && current.day === cell.day && current.hour === cell.hour ? null : cell
|
|
4571
|
+
)),
|
|
4525
4572
|
t,
|
|
4526
|
-
})
|
|
4573
|
+
}),
|
|
4574
|
+
selectedHeat
|
|
4575
|
+
? React.createElement('div', { className: 'cb-hint' }, t('analytics.heatmap_selected', {
|
|
4576
|
+
day: selectedHeat.label,
|
|
4577
|
+
hour: String(selectedHeat.hour).padStart(2, '0'),
|
|
4578
|
+
count: selectedHeat.value,
|
|
4579
|
+
}))
|
|
4580
|
+
: null
|
|
4527
4581
|
)
|
|
4528
4582
|
|
|
4529
4583
|
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
|
|
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
|
package/lib/routes/settings.js
CHANGED
|
@@ -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
|
-
|
|
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')
|
package/lib/slash-command.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { publicConfig, plainConfig, Config } from './config.js'
|
|
2
2
|
import { resolveActiveAccountKey, formatProgressBar } from './provider-sync.js'
|
|
3
|
+
import { getBudgetSpend } from './stats-storage.js'
|
|
4
|
+
import { budgetStatus, budgetErrorMessage } from './budget.js'
|
|
3
5
|
import { resolveAccountPool, rotateToNextAccount, getLastRotation } from './account-pool.js'
|
|
4
6
|
import {
|
|
5
7
|
getAllModels,
|
|
@@ -148,7 +150,27 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
148
150
|
return `Cline API Ping Failed: ${health.error || 'Host unreachable'}`
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
// 6. Subcommand /cline
|
|
153
|
+
// 6. Subcommand /cline cost or /cline budget
|
|
154
|
+
if (subcmd === 'cost' || subcmd === 'budget') {
|
|
155
|
+
const spend = getBudgetSpend()
|
|
156
|
+
const daily = budgetStatus({ dailyBudgetUsd: pub.dailyBudgetUsd }, spend)
|
|
157
|
+
const monthly = budgetStatus({ monthlyBudgetUsd: pub.monthlyBudgetUsd }, spend)
|
|
158
|
+
const money = (value) => `$${Number(value || 0).toFixed(4)}`
|
|
159
|
+
const limit = (value) => Number(value) > 0 ? money(value) : 'off'
|
|
160
|
+
const lines = [
|
|
161
|
+
`ClineBot Estimated Spend · today ${money(spend.dayCost)} · month ${money(spend.monthCost)}`,
|
|
162
|
+
'',
|
|
163
|
+
`Today: ${money(spend.dayCost)} / ${limit(pub.dailyBudgetUsd)}`,
|
|
164
|
+
`Month: ${money(spend.monthCost)} / ${limit(pub.monthlyBudgetUsd)}`,
|
|
165
|
+
`Daily guard: ${daily.blocked ? `BLOCKED — ${budgetErrorMessage(daily)}` : 'ok'}`,
|
|
166
|
+
`Monthly guard: ${monthly.blocked ? `BLOCKED — ${budgetErrorMessage(monthly)}` : 'ok'}`,
|
|
167
|
+
'',
|
|
168
|
+
'Set limits in Settings -> ClineBot -> Advanced.',
|
|
169
|
+
]
|
|
170
|
+
return lines.join('\n')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 7. Subcommand /cline stats (real DSH stream metrics)
|
|
152
174
|
if (subcmd === 'stats' || subcmd === 'telemetry') {
|
|
153
175
|
const summary = `ClineBot Telemetry · ${sessionStats.successfulRequests}/${sessionStats.totalRequests} reqs · ~${sessionStats.totalTokensEst} tokens`
|
|
154
176
|
const lines = [
|
|
@@ -177,7 +199,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
177
199
|
return lines.join('\n')
|
|
178
200
|
}
|
|
179
201
|
|
|
180
|
-
//
|
|
202
|
+
// 8. Subcommand /cline test [model] / smoke
|
|
181
203
|
if (subcmd === 'test' || subcmd === 'smoke') {
|
|
182
204
|
if (param && !isSupportedModel(param, pub.dynamicModels, pub.modelContextOverrides, pub.customModels)) {
|
|
183
205
|
return `ClineBot: Model "${param}" is not recognized. Use /cline models to see available models.`
|
|
@@ -221,7 +243,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
221
243
|
return `Smoke Test Failed: ${outcome.error} (HTTP ${outcome.status || 'timeout'})${failoverNotice}`
|
|
222
244
|
}
|
|
223
245
|
|
|
224
|
-
//
|
|
246
|
+
// 9. Subcommand /cline quota, /cline usage or /cline balance
|
|
225
247
|
const activeKey = await resolveActiveAccountKey(ctx, live())
|
|
226
248
|
if (!activeKey.value) {
|
|
227
249
|
return 'ClineBot: API key is not configured. Open Settings -> Plugins -> ClineBot.'
|
|
@@ -234,9 +256,11 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
234
256
|
|
|
235
257
|
const fiveHour = usage?.windows?.fiveHour
|
|
236
258
|
const weekly = usage?.windows?.weekly
|
|
259
|
+
const monthly = usage?.windows?.monthly
|
|
237
260
|
const reset5h = fiveHour?.resetsAt ? new Date(fiveHour.resetsAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'N/A'
|
|
238
261
|
const resetWk = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString([], { day: '2-digit', month: '2-digit' }) : 'N/A'
|
|
239
262
|
const resetWkFull = weekly?.resetsAt ? new Date(weekly.resetsAt).toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' }) : 'N/A'
|
|
263
|
+
const resetMo = monthly?.resetsAt ? new Date(monthly.resetsAt).toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' }) : 'N/A'
|
|
240
264
|
|
|
241
265
|
const planRaw = usage?.plan || 'ClinePass'
|
|
242
266
|
const planShort = planRaw.replace(/\s*\(\$\d+\.\d+\/mo\)/i, '').trim()
|
|
@@ -268,6 +292,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
268
292
|
'',
|
|
269
293
|
`${pad('5-Hour Quota')}${formatProgressBar(fiveHour?.percentUsed)} (resets: ${reset5h})`,
|
|
270
294
|
`${pad('Weekly Quota')}${formatProgressBar(weekly?.percentUsed)} (resets: ${resetWkFull})`,
|
|
295
|
+
`${pad('Monthly Quota')}${formatProgressBar(monthly?.percentUsed)} (resets: ${resetMo})`,
|
|
271
296
|
)
|
|
272
297
|
|
|
273
298
|
if (fiveHour?.percentUsed >= 95) {
|
|
@@ -315,8 +340,8 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
|
|
|
315
340
|
const unregister = commands.register({
|
|
316
341
|
definitionId: '@goodandready/dsh-clinebot',
|
|
317
342
|
name: 'cline',
|
|
318
|
-
description: 'Check ClinePass
|
|
319
|
-
input: { hint: '[quota|models|accounts|switch <name>|test [model]|ping|rotate]' },
|
|
343
|
+
description: 'Check ClinePass quota, cost, models, accounts and connectivity (/cline [quota|usage|cost|models|accounts|switch <name>|test [model]|ping|rotate])',
|
|
344
|
+
input: { hint: '[quota|usage|cost|models|accounts|switch <name>|test [model]|ping|rotate]' },
|
|
320
345
|
handler: commandHandler,
|
|
321
346
|
execute: commandExecute,
|
|
322
347
|
})
|
|
@@ -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
|
+
}
|
package/lib/stats-storage.js
CHANGED
|
@@ -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
|
|
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
|
+
|
package/lib/tui-settings.js
CHANGED
|
@@ -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
|
+
"version": "0.6.5",
|
|
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",
|