@goodandready/dsh-clinebot 0.4.6 → 0.4.8

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,188 @@
1
+ /**
2
+ * Session Router & Least-Used Sticky Balancing (Issue #128)
3
+ *
4
+ * Implements sticky session pinning with least-used quota routing:
5
+ * 1. An account with the highest available quota (lowest percentUsed) is selected on session start.
6
+ * 2. The account is pinned to the session for all subsequent requests in that session.
7
+ * 3. Mid-session failover occurs strictly if the pinned account encounters HTTP 429 or quota depletion.
8
+ * 4. Automatic cooldown recovery restores accounts once their rate-limit / reset timestamp has elapsed.
9
+ */
10
+
11
+ // In-memory sessions: sessionId -> { accountId, assignedAt, lastUsedAt }
12
+ const sessions = new Map()
13
+
14
+ // In-memory cooldowns: accountId -> { until: timestamp, reason: string }
15
+ const cooldowns = new Map()
16
+
17
+ /**
18
+ * Check if an account is currently in cooldown or rate-limited.
19
+ * Performs automatic recovery if the cooldown duration has passed.
20
+ *
21
+ * @param {string} accountId
22
+ * @returns {boolean} true if account is currently blocked
23
+ */
24
+ export function isAccountInCooldown(accountId) {
25
+ if (!accountId) return false
26
+ const entry = cooldowns.get(accountId)
27
+ if (!entry) return false
28
+
29
+ if (Date.now() >= entry.until) {
30
+ cooldowns.delete(accountId)
31
+ return false
32
+ }
33
+ return true
34
+ }
35
+
36
+ /**
37
+ * Mark an account as in cooldown (rate-limited or quota exhausted).
38
+ *
39
+ * @param {string} accountId
40
+ * @param {number} [durationMs=60000] Default 60s cooldown if resetsAt is not provided
41
+ * @param {string|number|null} [resetsAt] ISO string or timestamp of quota window reset
42
+ * @param {string} [reason='rate_limit']
43
+ */
44
+ export function markAccountCooldown(accountId, durationMs = 60000, resetsAt = null, reason = 'rate_limit') {
45
+ if (!accountId) return
46
+
47
+ let until = Date.now() + durationMs
48
+
49
+ if (resetsAt) {
50
+ const resetTime = typeof resetsAt === 'number' ? resetsAt : new Date(resetsAt).getTime()
51
+ if (!Number.isNaN(resetTime) && resetTime > Date.now()) {
52
+ until = resetTime
53
+ }
54
+ }
55
+
56
+ cooldowns.set(accountId, { until, reason })
57
+ }
58
+
59
+ /**
60
+ * Clear cooldown for an account manually.
61
+ *
62
+ * @param {string} accountId
63
+ */
64
+ export function clearAccountCooldown(accountId) {
65
+ if (accountId) {
66
+ cooldowns.delete(accountId)
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Select the best account from the pool based on least-used quota.
72
+ * Lowest percentUsed is prioritized.
73
+ *
74
+ * @param {Array<Object>} accounts Pool of accounts
75
+ * @returns {Object|null} Selected account or null
76
+ */
77
+ export function selectLeastUsedAccount(accounts = []) {
78
+ if (!Array.isArray(accounts) || accounts.length === 0) {
79
+ return null
80
+ }
81
+
82
+ // Filter available accounts that have keys present and are not in cooldown
83
+ const available = accounts.filter(acc => {
84
+ if (!acc || acc.present === false) return false
85
+ return !isAccountInCooldown(acc.id || acc.label)
86
+ })
87
+
88
+ const candidatePool = available.length > 0 ? available : accounts.filter(a => a && a.present !== false)
89
+ if (candidatePool.length === 0) {
90
+ return accounts[0] || null
91
+ }
92
+
93
+ // Sort by lowest percentUsed (least used). If percentUsed is null/undefined, treat as 0% used
94
+ const sorted = [...candidatePool].sort((a, b) => {
95
+ const aUsed = typeof a.percentUsed === 'number' ? a.percentUsed : 0
96
+ const bUsed = typeof b.percentUsed === 'number' ? b.percentUsed : 0
97
+ return aUsed - bUsed
98
+ })
99
+
100
+ return sorted[0]
101
+ }
102
+
103
+ /**
104
+ * Resolve or assign an account for a given session.
105
+ *
106
+ * @param {string|null} sessionId Unique session or conversation identifier
107
+ * @param {Array<Object>} accounts Configured accounts pool
108
+ * @param {string} [defaultActiveId] Fallback active account ID
109
+ * @returns {{ account: Object|null, isReassigned: boolean, reason: string }}
110
+ */
111
+ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId = '') {
112
+ const safeAccounts = Array.isArray(accounts) ? accounts : []
113
+ if (safeAccounts.length === 0) {
114
+ return { account: null, isReassigned: false, reason: 'empty_pool' }
115
+ }
116
+
117
+ // If no sessionId is provided, use least-used account or default active
118
+ if (!sessionId) {
119
+ const best = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
120
+ return { account: best, isReassigned: false, reason: 'no_session_id' }
121
+ }
122
+
123
+ const existing = sessions.get(sessionId)
124
+ if (existing) {
125
+ const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
126
+ // If assigned account is still valid and NOT in cooldown, keep sticky pin
127
+ if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
128
+ existing.lastUsedAt = Date.now()
129
+ return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
130
+ }
131
+ // Failover: account was deleted or entered cooldown mid-session
132
+ }
133
+
134
+ // New session or mid-session failover: pick least-used account
135
+ const chosen = selectLeastUsedAccount(safeAccounts) || safeAccounts.find(a => a.id === defaultActiveId) || safeAccounts[0]
136
+ if (chosen) {
137
+ const chosenId = chosen.id || chosen.label
138
+ sessions.set(sessionId, {
139
+ accountId: chosenId,
140
+ assignedAt: existing ? existing.assignedAt : Date.now(),
141
+ lastUsedAt: Date.now()
142
+ })
143
+ }
144
+
145
+ return {
146
+ account: chosen,
147
+ isReassigned: Boolean(existing),
148
+ reason: existing ? 'mid_session_failover' : 'new_session_assigned'
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Explicitly release a session.
154
+ *
155
+ * @param {string} sessionId
156
+ */
157
+ export function releaseSession(sessionId) {
158
+ if (sessionId) {
159
+ sessions.delete(sessionId)
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Clear all sessions and cooldowns (for test environments).
165
+ */
166
+ export function resetSessionRouter() {
167
+ sessions.clear()
168
+ cooldowns.clear()
169
+ }
170
+
171
+ /**
172
+ * Return current session router diagnostics.
173
+ */
174
+ export function getSessionRouterStatus() {
175
+ return {
176
+ activeSessionsCount: sessions.size,
177
+ cooldownsCount: cooldowns.size,
178
+ cooldowns: Object.fromEntries(
179
+ Array.from(cooldowns.entries()).map(([k, v]) => [
180
+ k,
181
+ {
182
+ remainingMs: Math.max(0, v.until - Date.now()),
183
+ reason: v.reason
184
+ }
185
+ ])
186
+ )
187
+ }
188
+ }
@@ -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
+ export 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.6",
3
+ "version": "0.4.8",
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",
@@ -61,7 +61,6 @@
61
61
  "@deepseek-ai/cordis": "^4.0.1",
62
62
  "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6",
63
63
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
64
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
65
64
  "@deepseek-ai/schemastery": "^3.18.1"
66
65
  },
67
66
  "devDependencies": {