@goodandready/dsh-clinebot 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/lib/client.js +411 -53
- package/lib/cline-client.js +1 -2
- package/lib/config.js +21 -3
- package/lib/http.js +1 -1
- package/lib/index.js +38 -1
- package/lib/models.js +33 -8
- package/lib/provider-sync.js +50 -25
- package/lib/routes/auth.js +1 -1
- package/lib/routes/proxy.js +290 -0
- package/lib/routes/settings.js +34 -0
- package/lib/session-router.js +236 -0
- package/lib/slash-command.js +1 -3
- package/lib/stats-storage.js +217 -0
- package/package.json +1 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent JSON Token Analytics Storage (Issue #129)
|
|
3
|
+
*
|
|
4
|
+
* Persists token consumption and request telemetry to ~/.dsh/clinebot-stats.json.
|
|
5
|
+
* Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'node:fs'
|
|
9
|
+
import { homedir } from 'node:os'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
let inMemoryStats = createEmptyStats()
|
|
13
|
+
let saveTimer = null
|
|
14
|
+
let activeStatsPath = ''
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a fresh, empty stats object.
|
|
18
|
+
*/
|
|
19
|
+
export function createEmptyStats() {
|
|
20
|
+
return {
|
|
21
|
+
version: 1,
|
|
22
|
+
createdAt: Date.now(),
|
|
23
|
+
updatedAt: Date.now(),
|
|
24
|
+
totals: {
|
|
25
|
+
requests: 0,
|
|
26
|
+
successful: 0,
|
|
27
|
+
failed: 0,
|
|
28
|
+
rateLimited429: 0,
|
|
29
|
+
promptTokens: 0,
|
|
30
|
+
completionTokens: 0,
|
|
31
|
+
totalTokens: 0
|
|
32
|
+
},
|
|
33
|
+
byDay: {},
|
|
34
|
+
byMonth: {},
|
|
35
|
+
byModel: {},
|
|
36
|
+
byAccount: {}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve '~' in paths to user's home directory.
|
|
42
|
+
*/
|
|
43
|
+
export function resolvePath(targetPath) {
|
|
44
|
+
if (!targetPath) {
|
|
45
|
+
return path.join(homedir(), '.dsh', 'clinebot-stats.json')
|
|
46
|
+
}
|
|
47
|
+
if (targetPath.startsWith('~/') || targetPath === '~') {
|
|
48
|
+
return path.join(homedir(), targetPath.slice(1))
|
|
49
|
+
}
|
|
50
|
+
return path.resolve(targetPath)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load stats from disk.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} [customPath]
|
|
57
|
+
* @returns {Object} Loaded stats
|
|
58
|
+
*/
|
|
59
|
+
export function loadStats(customPath) {
|
|
60
|
+
const filePath = resolvePath(customPath)
|
|
61
|
+
activeStatsPath = filePath
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (existsSync(filePath)) {
|
|
65
|
+
const raw = readFileSync(filePath, 'utf8')
|
|
66
|
+
const parsed = JSON.parse(raw)
|
|
67
|
+
if (parsed && typeof parsed === 'object' && parsed.totals) {
|
|
68
|
+
inMemoryStats = {
|
|
69
|
+
...createEmptyStats(),
|
|
70
|
+
...parsed,
|
|
71
|
+
totals: { ...createEmptyStats().totals, ...parsed.totals },
|
|
72
|
+
byDay: parsed.byDay || {},
|
|
73
|
+
byMonth: parsed.byMonth || {},
|
|
74
|
+
byModel: parsed.byModel || {},
|
|
75
|
+
byAccount: parsed.byAccount || {}
|
|
76
|
+
}
|
|
77
|
+
return inMemoryStats
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// If reading fails or file is corrupted, fallback to clean stats
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
inMemoryStats = createEmptyStats()
|
|
85
|
+
return inMemoryStats
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Synchronously write stats to disk using atomic rename.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} [customPath]
|
|
92
|
+
*/
|
|
93
|
+
export function saveStatsSync(customPath) {
|
|
94
|
+
const filePath = resolvePath(customPath || activeStatsPath)
|
|
95
|
+
activeStatsPath = filePath
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const dir = path.dirname(filePath)
|
|
99
|
+
if (!existsSync(dir)) {
|
|
100
|
+
mkdirSync(dir, { recursive: true })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
inMemoryStats.updatedAt = Date.now()
|
|
104
|
+
const tempPath = `${filePath}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
|
|
105
|
+
const data = JSON.stringify(inMemoryStats, null, 2)
|
|
106
|
+
|
|
107
|
+
writeFileSync(tempPath, data, 'utf8')
|
|
108
|
+
renameSync(tempPath, filePath)
|
|
109
|
+
} catch {
|
|
110
|
+
// Best-effort disk persistence
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Queue a debounced write to avoid disk thrashing during rapid streaming tokens.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} [customPath]
|
|
118
|
+
* @param {number} [debounceMs=500]
|
|
119
|
+
*/
|
|
120
|
+
function queueSaveStats(customPath, debounceMs = 500) {
|
|
121
|
+
if (saveTimer) {
|
|
122
|
+
clearTimeout(saveTimer)
|
|
123
|
+
}
|
|
124
|
+
saveTimer = setTimeout(() => {
|
|
125
|
+
saveTimer = null
|
|
126
|
+
saveStatsSync(customPath)
|
|
127
|
+
}, debounceMs)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Record a completion event with usage metrics.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} params
|
|
134
|
+
* @param {string} [params.model]
|
|
135
|
+
* @param {string} [params.accountId]
|
|
136
|
+
* @param {number} [params.promptTokens=0]
|
|
137
|
+
* @param {number} [params.completionTokens=0]
|
|
138
|
+
* @param {number} [params.totalTokens=0]
|
|
139
|
+
* @param {boolean} [params.isError=false]
|
|
140
|
+
* @param {boolean} [params.is429=false]
|
|
141
|
+
* @param {string} [params.statsPath]
|
|
142
|
+
*/
|
|
143
|
+
export function recordUsage({
|
|
144
|
+
model = 'unknown',
|
|
145
|
+
accountId = 'default',
|
|
146
|
+
promptTokens = 0,
|
|
147
|
+
completionTokens = 0,
|
|
148
|
+
totalTokens = 0,
|
|
149
|
+
isError = false,
|
|
150
|
+
is429 = false,
|
|
151
|
+
statsPath
|
|
152
|
+
} = {}) {
|
|
153
|
+
const now = new Date()
|
|
154
|
+
const dayKey = now.toISOString().slice(0, 10) // 'YYYY-MM-DD'
|
|
155
|
+
const monthKey = dayKey.slice(0, 7) // 'YYYY-MM'
|
|
156
|
+
|
|
157
|
+
const safePrompt = Math.max(0, Number(promptTokens) || 0)
|
|
158
|
+
const safeComp = Math.max(0, Number(completionTokens) || 0)
|
|
159
|
+
const safeTotal = Math.max(safePrompt + safeComp, Number(totalTokens) || 0)
|
|
160
|
+
|
|
161
|
+
// Totals
|
|
162
|
+
inMemoryStats.totals.requests += 1
|
|
163
|
+
if (isError) {
|
|
164
|
+
inMemoryStats.totals.failed += 1
|
|
165
|
+
} else {
|
|
166
|
+
inMemoryStats.totals.successful += 1
|
|
167
|
+
}
|
|
168
|
+
if (is429) {
|
|
169
|
+
inMemoryStats.totals.rateLimited429 += 1
|
|
170
|
+
}
|
|
171
|
+
inMemoryStats.totals.promptTokens += safePrompt
|
|
172
|
+
inMemoryStats.totals.completionTokens += safeComp
|
|
173
|
+
inMemoryStats.totals.totalTokens += safeTotal
|
|
174
|
+
|
|
175
|
+
// Helper to increment bucket
|
|
176
|
+
function incrementBucket(container, key) {
|
|
177
|
+
if (!container[key]) {
|
|
178
|
+
container[key] = {
|
|
179
|
+
requests: 0,
|
|
180
|
+
promptTokens: 0,
|
|
181
|
+
completionTokens: 0,
|
|
182
|
+
totalTokens: 0
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
container[key].requests += 1
|
|
186
|
+
container[key].promptTokens += safePrompt
|
|
187
|
+
container[key].completionTokens += safeComp
|
|
188
|
+
container[key].totalTokens += safeTotal
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
incrementBucket(inMemoryStats.byDay, dayKey)
|
|
192
|
+
incrementBucket(inMemoryStats.byMonth, monthKey)
|
|
193
|
+
incrementBucket(inMemoryStats.byModel, model)
|
|
194
|
+
incrementBucket(inMemoryStats.byAccount, accountId)
|
|
195
|
+
|
|
196
|
+
queueSaveStats(statsPath)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Retrieve current statistics summary.
|
|
201
|
+
*
|
|
202
|
+
* @returns {Object}
|
|
203
|
+
*/
|
|
204
|
+
export function getStatsSummary() {
|
|
205
|
+
return JSON.parse(JSON.stringify(inMemoryStats))
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Reset statistics to initial state and persist.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} [customPath]
|
|
212
|
+
*/
|
|
213
|
+
export function resetStats(customPath) {
|
|
214
|
+
inMemoryStats = createEmptyStats()
|
|
215
|
+
saveStatsSync(customPath)
|
|
216
|
+
return inMemoryStats
|
|
217
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-clinebot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|