@goodandready/dsh-clinebot 0.3.25 → 0.4.1

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.
@@ -1,6 +1,6 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
2
  import { isTrustedSettingsRequest, assertTrustedSettingsRequest } from '../access.js'
3
- import { publicConfig, Config } from '../config.js'
3
+ 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
 
@@ -33,22 +33,66 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
33
33
  if (req.method !== 'PUT') {
34
34
  return writeJson(res, 405, { ok: false, error: 'GET or PUT' })
35
35
  }
36
- if (!isTrustedSettingsRequest(req)) {
37
- return writeJson(res, 403, { ok: false, error: 'same-origin only' })
38
- }
36
+ if (!assertTrustedSettingsRequest(req, res)) return
39
37
  const settingsApi = getSettingsApi()
40
38
  if (!settingsApi) {
41
39
  return writeJson(res, 503, { ok: false, error: 'settings not ready' })
42
40
  }
41
+ let bodyBuf
42
+ try {
43
+ bodyBuf = await readBody(req)
44
+ } catch (err) {
45
+ return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
46
+ }
43
47
  let payload
44
48
  try {
45
- payload = JSON.parse((await readBody(req)).toString('utf8') || '{}')
49
+ payload = JSON.parse(bodyBuf.toString('utf8') || '{}')
46
50
  } catch {
47
51
  return writeJson(res, 400, { ok: false, error: 'invalid json' })
48
52
  }
49
- if (payload && typeof payload.config === 'object') payload = payload.config
53
+ const rawPayload = payload && typeof payload.config === 'object' && payload.config !== null ? payload.config : payload
54
+ if (!rawPayload || typeof rawPayload !== 'object' || Array.isArray(rawPayload)) {
55
+ return writeJson(res, 400, { ok: false, error: 'body must be a JSON object' })
56
+ }
57
+ if ('enabledModels' in rawPayload) {
58
+ return writeJson(res, 400, { ok: false, error: 'enabledModels is deprecated and not allowed; configure disabledModels instead' })
59
+ }
60
+ const allowedKeys = new Set(Object.keys(Config.dict || {}))
61
+ allowedKeys.delete('enabledModels')
62
+ for (const k of Object.keys(rawPayload)) {
63
+ if (!allowedKeys.has(k)) {
64
+ return writeJson(res, 400, { ok: false, error: `unknown config field: ${k}` })
65
+ }
66
+ }
67
+ if (rawPayload.apiKeyEnv && !/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(String(rawPayload.apiKeyEnv))) {
68
+ return writeJson(res, 400, { ok: false, error: `Disallowed apiKeyEnv: "${rawPayload.apiKeyEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$` })
69
+ }
70
+ if (Array.isArray(rawPayload.accounts)) {
71
+ for (const acc of rawPayload.accounts) {
72
+ if (!acc || typeof acc !== 'object') {
73
+ return writeJson(res, 400, { ok: false, error: 'Each account must be an object' })
74
+ }
75
+ const env = String(acc.apiKeyEnv || '').trim()
76
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(env)) {
77
+ return writeJson(res, 400, { ok: false, error: `Disallowed account apiKeyEnv: "${env}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$` })
78
+ }
79
+ }
80
+ }
81
+ if (rawPayload.baseUrl) {
82
+ try {
83
+ const parsed = new URL(rawPayload.baseUrl)
84
+ if (parsed.protocol !== 'https:' && parsed.hostname !== 'localhost' && parsed.hostname !== '127.0.0.1') {
85
+ return writeJson(res, 400, { ok: false, error: 'Insecure baseUrl: remote endpoint must use https://' })
86
+ }
87
+ } catch {
88
+ return writeJson(res, 400, { ok: false, error: 'Invalid baseUrl' })
89
+ }
90
+ }
50
91
  try {
51
- const parsed = Config({ ...publicConfig(live()), ...payload })
92
+ const base = plainConfig(live()) || {}
93
+ delete base.enabledModels
94
+ const merged = { ...base, ...rawPayload }
95
+ const parsed = Config(merged)
52
96
  await settingsApi.replace(parsed)
53
97
  await syncProviderState(parsed)
54
98
  writeJson(res, 200, { ok: true, config: publicConfig(live()) })
@@ -77,15 +121,43 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
77
121
 
78
122
  const pub = publicConfig(live())
79
123
  const targetEnvName = String(body.apiKeyEnv || pub.apiKeyEnv || DEFAULT_API_KEY_ENV).trim()
124
+
125
+ const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnvName)
126
+ if (!isDefaultPattern) {
127
+ return writeJson(res, 400, {
128
+ ok: false,
129
+ error: `Disallowed apiKeyEnv: "${targetEnvName}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
130
+ })
131
+ }
132
+
133
+ const isConfiguredEnv = targetEnvName === DEFAULT_API_KEY_ENV ||
134
+ targetEnvName === pub.apiKeyEnv ||
135
+ (Array.isArray(pub.accounts) && pub.accounts.some(acc => acc && acc.apiKeyEnv === targetEnvName))
136
+
137
+ if (!isConfiguredEnv) {
138
+ return writeJson(res, 400, {
139
+ ok: false,
140
+ error: `Disallowed apiKeyEnv: "${targetEnvName}". Must be configured in settings accounts pool.`,
141
+ })
142
+ }
143
+
80
144
  await saveCredentialKey(ctx, targetEnvName, apiKey)
81
145
 
82
146
  await syncProviderState(live())
83
147
  triggerAutoDiscover()
84
148
 
85
- const validation = await smokeChat(pub.baseUrl, apiKey, {
86
- model: pub.defaultModel,
87
- timeoutMs: 15000,
88
- })
149
+ let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
150
+ try {
151
+ const parsedUrl = new URL(pub.baseUrl)
152
+ if (parsedUrl.protocol === 'https:') {
153
+ validation = await smokeChat(pub.baseUrl, apiKey, {
154
+ model: pub.defaultModel,
155
+ timeoutMs: 15000,
156
+ })
157
+ }
158
+ } catch {
159
+ validation = { ok: false, error: 'Invalid baseUrl' }
160
+ }
89
161
 
90
162
  writeJson(res, 200, {
91
163
  ok: true,
@@ -1,4 +1,4 @@
1
- import { publicConfig, Config } from './config.js'
1
+ import { publicConfig, plainConfig, Config } from './config.js'
2
2
  import { resolveActiveAccountKey, formatProgressBar } from './provider-sync.js'
3
3
  import {
4
4
  getAllModels,
@@ -13,11 +13,12 @@ import {
13
13
  probeHealth,
14
14
  smokeChat,
15
15
  fetchUsageLimits,
16
- recordSessionRequest,
16
+ recordSmokeTest,
17
17
  clearUsageCache,
18
18
  clearProbeCache,
19
19
  usageCache,
20
20
  sessionStats,
21
+ getLastRotation,
21
22
  } from './cline-client.js'
22
23
 
23
24
  export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderState }) {
@@ -25,10 +26,8 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
25
26
  const commands = cmdCtx.commands
26
27
  if (typeof commands?.register !== 'function') return
27
28
 
28
- const unregister = commands.register({
29
- name: 'cline',
30
- description: 'Check ClinePass subscription quota, models, accounts and test connectivity (/cline [quota|models|accounts|switch <name>|test [model]|ping|rotate])',
31
- execute: async (rawArgs) => {
29
+ const executeCommand = async (rawArgs, invocation = null) => {
30
+ try {
32
31
  const pub = publicConfig(live())
33
32
  const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
34
33
  const param = String(rawArgs || '').trim().split(/\s+/)[1] || ''
@@ -37,11 +36,18 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
37
36
  if (subcmd === 'models') {
38
37
  const allModels = getAllModels(pub.dynamicModels)
39
38
  const disabledSet = new Set(pub.disabledModels || [])
39
+ const syncStatus = pub.planSynced
40
+ ? `✅ Verified with plan (${pub.planSyncedAt ? new Date(pub.planSyncedAt).toLocaleDateString() : 'synced'})`
41
+ : '⚠️ Not verified with plan (fallback catalog)'
40
42
  const lines = [
41
43
  '### 🎯 ClinePass Models Catalog',
44
+ `* **Plan Sync**: ${syncStatus}`,
42
45
  `* **Total models**: ${allModels.length} (${allModels.filter((m) => !disabledSet.has(m.id)).length} active)`,
43
- '',
44
46
  ]
47
+ if (pub.defaultModelWarning) {
48
+ lines.push(`* ⚠️ **Warning**: ${pub.defaultModelWarning}`)
49
+ }
50
+ lines.push('')
45
51
  for (const m of allModels) {
46
52
  const active = !disabledSet.has(m.id) ? '✅' : '❌'
47
53
  const isVis = isVisionModel(m.id, pub.dynamicModels) ? '📷 Vision' : '📝 Text'
@@ -67,6 +73,10 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
67
73
  const usageInfo = cached?.windows?.fiveHour ? ` · ⏱ ${cached.windows.fiveHour.percentUsed}% used` : ''
68
74
  lines.push(`* **${acc.label}** (\`${acc.apiKeyEnv}\`): ${statusBadge} ${pinBadge}${usageInfo}`)
69
75
  }
76
+ const lastRot = getLastRotation()
77
+ if (lastRot) {
78
+ lines.push('', `* **Last Failover**: \`${lastRot.from}\` → \`${lastRot.to}\` (${lastRot.reason})`)
79
+ }
70
80
  lines.push('', 'Switch active account: `/cline switch <env_variable_name>`')
71
81
  return lines.join('\n')
72
82
  }
@@ -80,7 +90,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
80
90
  clearProbeCache()
81
91
  const settingsApi = getSettingsApi()
82
92
  if (settingsApi?.replace) {
83
- const next = Config({ ...live(), activeAccount: param })
93
+ const next = Config({ ...plainConfig(live()), activeAccount: param })
84
94
  await settingsApi.replace(next)
85
95
  await syncProviderState(next)
86
96
  return `✅ Active account switched to \`${param}\``
@@ -107,7 +117,33 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
107
117
  return `❌ **Cline API Ping Failed**: ${health.error || 'Host unreachable'}`
108
118
  }
109
119
 
110
- // 6. Subcommand /cline test [model] / smoke
120
+ // 6. Subcommand /cline stats (real DSH stream metrics)
121
+ if (subcmd === 'stats' || subcmd === 'telemetry') {
122
+ const lines = [
123
+ '### 📈 DSH ClineBot Stream Telemetry',
124
+ `* **Requests**: ${sessionStats.successfulRequests} successful / ${sessionStats.totalRequests} total${sessionStats.abortedRequests ? ` (${sessionStats.abortedRequests} aborted)` : ''}${sessionStats.failedRequests ? ` (${sessionStats.failedRequests} failed)` : ''}`,
125
+ `* **Estimated Tokens**: ~${sessionStats.totalTokensEst} total (~${sessionStats.promptTokensEst} prompt, ~${sessionStats.completionTokensEst} completion)`,
126
+ `* **Last Stream Latency**: ${sessionStats.lastLatencyMs !== null ? `${sessionStats.lastLatencyMs} ms` : '—'}`,
127
+ `* **Last Request At**: ${sessionStats.lastRequestAt ? new Date(sessionStats.lastRequestAt).toLocaleTimeString() : '—'}`,
128
+ ]
129
+ if (sessionStats.lastError) {
130
+ lines.push(`* **Last Stream Error**: ${sessionStats.lastError}`)
131
+ }
132
+ if (sessionStats.byModel && Object.keys(sessionStats.byModel).length) {
133
+ lines.push('', '**By Model:**')
134
+ for (const [mId, mStats] of Object.entries(sessionStats.byModel)) {
135
+ lines.push(`* \`${mId}\`: ${mStats.requests} reqs, ~${mStats.promptTokens + mStats.completionTokens} tokens`)
136
+ }
137
+ }
138
+ if (sessionStats.lastSmoke) {
139
+ const smoke = sessionStats.lastSmoke
140
+ const smokeTime = new Date(smoke.at).toLocaleTimeString()
141
+ lines.push('', `* **Last Smoke Test**: ${smoke.ok ? '✅ Passed' : '❌ Failed'} (${smoke.latencyMs} ms at ${smokeTime}${smoke.model ? `, model: \`${smoke.model}\`` : ''})`)
142
+ }
143
+ return lines.join('\n')
144
+ }
145
+
146
+ // 7. Subcommand /cline test [model] / smoke
111
147
  if (subcmd === 'test' || subcmd === 'smoke') {
112
148
  if (param && !isSupportedModel(param, pub.dynamicModels)) {
113
149
  return `⚠️ **ClineBot**: Model \`${param}\` is not recognized. Use \`/cline models\` to see available models.`
@@ -121,12 +157,11 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
121
157
  model: modelToTest,
122
158
  timeoutMs: pub.smokeTimeoutMs,
123
159
  })
124
- recordSessionRequest({
160
+ recordSmokeTest({
125
161
  latencyMs: outcome.latencyMs,
126
162
  ok: outcome.ok,
127
163
  error: outcome.error,
128
- promptTokens: outcome.promptTokens || 5,
129
- completionTokens: outcome.completionTokens || 10,
164
+ model: modelToTest,
130
165
  })
131
166
 
132
167
  let failoverNotice = ''
@@ -170,10 +205,15 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
170
205
  `* **Host Ping**: ${health.ok ? `✅ ${health.latencyMs} ms` : '❌ Unreachable'}`,
171
206
  `* **Active Key**: \`${activeKey.envName}\` (${activeKey.source})`,
172
207
  `* **Default Model**: \`${pub.defaultModel}\``,
208
+ ]
209
+ if (pub.defaultModelWarning) {
210
+ lines.push(`* ⚠️ **Warning**: ${pub.defaultModelWarning}`)
211
+ }
212
+ lines.push(
173
213
  '',
174
214
  `**⏱ 5-Hour Rolling Limit**: ${formatProgressBar(fiveHour?.percentUsed)} (reset: ${reset5h})`,
175
215
  `**📅 Weekly Window**: ${formatProgressBar(weekly?.percentUsed)} (reset: ${resetWk})`,
176
- ]
216
+ )
177
217
 
178
218
  if (fiveHour?.percentUsed >= 95) {
179
219
  lines.push('', '🚨 **CRITICAL LIMIT**: 5-hour quota is 95%+ consumed! New requests may be throttled until reset.')
@@ -190,7 +230,44 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
190
230
  }
191
231
 
192
232
  return lines.join('\n')
193
- },
233
+ } catch (err) {
234
+ return `❌ **/cline error**: ${String(err?.message || err)}`
235
+ }
236
+ }
237
+
238
+ const commandHandler = async (invocation) => {
239
+ const rawArgs = typeof invocation === 'string'
240
+ ? invocation
241
+ : (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
242
+ try {
243
+ const output = await executeCommand(rawArgs, invocation)
244
+ return {
245
+ kind: 'success',
246
+ text: output,
247
+ // Support toString() for callers that treat result directly as string
248
+ toString() { return output },
249
+ }
250
+ } catch (err) {
251
+ return {
252
+ kind: 'error',
253
+ text: `❌ /cline error: ${String(err?.message || err)}`,
254
+ toString() { return `❌ /cline error: ${String(err?.message || err)}` },
255
+ }
256
+ }
257
+ }
258
+
259
+ const commandExecute = async (rawArgs, invocation = null) => {
260
+ const result = await commandHandler(invocation || rawArgs)
261
+ return result.text
262
+ }
263
+
264
+ const unregister = commands.register({
265
+ definitionId: '@goodandready/dsh-clinebot',
266
+ name: 'cline',
267
+ description: 'Check ClinePass subscription quota, models, accounts and test connectivity (/cline [quota|models|accounts|switch <name>|test [model]|ping|rotate])',
268
+ input: { hint: '[quota|models|accounts|switch <name>|test [model]|ping|rotate]' },
269
+ handler: commandHandler,
270
+ execute: commandExecute,
194
271
  })
195
272
 
196
273
  ctx.effect(() => () => unregister?.(), 'dsh-clinebot: slash-command')
@@ -0,0 +1,78 @@
1
+ /**
2
+ * In-memory runtime session metrics for ClinePass requests in DeepSeek Harness.
3
+ */
4
+
5
+ export const sessionStats = {
6
+ totalRequests: 0,
7
+ successfulRequests: 0,
8
+ failedRequests: 0,
9
+ abortedRequests: 0,
10
+ promptTokensEst: 0,
11
+ completionTokensEst: 0,
12
+ totalTokensEst: 0,
13
+ lastLatencyMs: null,
14
+ lastRequestAt: null,
15
+ lastError: null,
16
+ byModel: {},
17
+ lastSmoke: null,
18
+ }
19
+
20
+ export function recordSessionRequest({
21
+ latencyMs,
22
+ ok,
23
+ error,
24
+ aborted = false,
25
+ promptTokens = 0,
26
+ completionTokens = 0,
27
+ model,
28
+ }) {
29
+ sessionStats.totalRequests += 1
30
+ if (aborted) {
31
+ sessionStats.abortedRequests = (sessionStats.abortedRequests || 0) + 1
32
+ } else if (ok) {
33
+ sessionStats.successfulRequests += 1
34
+ sessionStats.lastLatencyMs = typeof latencyMs === 'number' ? latencyMs : null
35
+ sessionStats.lastError = null
36
+ } else {
37
+ sessionStats.failedRequests += 1
38
+ sessionStats.lastError = error || 'Request failed'
39
+ }
40
+ sessionStats.lastRequestAt = Date.now()
41
+ sessionStats.promptTokensEst += Number(promptTokens) || 0
42
+ sessionStats.completionTokensEst += Number(completionTokens) || 0
43
+ sessionStats.totalTokensEst += (Number(promptTokens) || 0) + (Number(completionTokens) || 0)
44
+
45
+ if (model) {
46
+ if (!sessionStats.byModel) sessionStats.byModel = {}
47
+ const m = sessionStats.byModel[model] || { requests: 0, promptTokens: 0, completionTokens: 0 }
48
+ m.requests += 1
49
+ m.promptTokens += Number(promptTokens) || 0
50
+ m.completionTokens += Number(completionTokens) || 0
51
+ sessionStats.byModel[model] = m
52
+ }
53
+ }
54
+
55
+ export function recordSmokeTest({ latencyMs, ok, error, model }) {
56
+ sessionStats.lastSmoke = {
57
+ at: Date.now(),
58
+ latencyMs: typeof latencyMs === 'number' ? latencyMs : null,
59
+ ok: Boolean(ok),
60
+ error: error || null,
61
+ model: model || null,
62
+ }
63
+ }
64
+
65
+ export function resetSessionStats() {
66
+ sessionStats.totalRequests = 0
67
+ sessionStats.successfulRequests = 0
68
+ sessionStats.failedRequests = 0
69
+ sessionStats.abortedRequests = 0
70
+ sessionStats.promptTokensEst = 0
71
+ sessionStats.completionTokensEst = 0
72
+ sessionStats.totalTokensEst = 0
73
+ sessionStats.lastLatencyMs = null
74
+ sessionStats.lastRequestAt = null
75
+ sessionStats.lastError = null
76
+ sessionStats.byModel = {}
77
+ sessionStats.lastSmoke = null
78
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.3.25",
3
+ "version": "0.4.1",
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",
@@ -63,6 +63,9 @@
63
63
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
64
64
  "@deepseek-ai/schemastery": "^3.18.1"
65
65
  },
66
+ "devDependencies": {
67
+ "@deepseek-ai/schemastery": "^3.18.1"
68
+ },
66
69
  "homepage": "https://github.com/GooDAnDReaDY/dsh-clinebot#readme",
67
70
  "bugs": {
68
71
  "url": "https://github.com/GooDAnDReaDY/dsh-clinebot/issues"