@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.
@@ -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.4.7",
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",