@goodandready/dsh-clinebot 0.6.0 → 0.6.2

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/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
1
+ import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS } from './config.js'
2
2
  import { registerPluginUpdater } from './updater.js'
3
3
  import {
4
4
  sessionStats,
@@ -7,28 +7,20 @@ import {
7
7
  clearUsageCache,
8
8
  clearProbeCache,
9
9
  } from './cline-client.js'
10
- import {
11
- rotateToNextAccount,
12
- isQuotaExceededError,
13
- } from './account-pool.js'
10
+ import { rotateToNextAccount } from './account-pool.js'
14
11
  import { PROVIDER_ID } from './models.js'
15
12
  import {
16
- upsertPiAiProvider,
17
- removePiAiProvider,
18
13
  autoDiscoverPlanModels,
19
- resolveActiveAccountKey,
20
14
  } from './provider-sync.js'
21
15
  import { registerSettingsRoutes } from './routes/settings.js'
22
16
  import { registerAccountsRoutes } from './routes/accounts.js'
23
17
  import { registerModelsRoutes } from './routes/models.js'
24
18
  import { registerAuthRoutes } from './routes/auth.js'
25
- import { registerProxyRoutes } from './routes/proxy.js'
26
- import { startLoopbackProxyServer, stopLoopbackProxyServer } from './proxy-server.js'
27
19
  import { ClineBotAdapter } from './adapter.js'
28
20
  import { registerSlashCommand } from './slash-command.js'
29
21
  import { ClineBotSearchProvider, CLINEBOT_SEARCH_PROVIDER_ID } from './web-search.js'
30
22
  import { resolveAccountPool } from './account-pool.js'
31
- import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
23
+ import { loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
32
24
  import { service, migrateLegacyPiAiProvider } from './services.js'
33
25
  import { applyTuiSettings } from './tui-settings.js'
34
26
 
@@ -39,7 +31,7 @@ export function configReader(config) {
39
31
  return () => config
40
32
  }
41
33
 
42
- export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
34
+ export { NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
43
35
 
44
36
  export function apply(ctx, config) {
45
37
  let currentConfig = config || {}
@@ -86,26 +78,8 @@ export function apply(ctx, config) {
86
78
  const syncProviderState = async (cfg) => {
87
79
  try {
88
80
  const pub = publicConfig(cfg)
89
- if (pub.transport !== 'pi-ai') {
90
- // Native ctx.llm adapter: the route set follows `enabled`; no
91
- // llm-pi-ai mutation and no loopback proxy are involved.
92
- if (adapterRegistration) {
93
- adapterRegistration.replace(pub.enabled ? [PROVIDER_ID] : [])
94
- }
95
- return
96
- }
97
- if (!pub.enabled) {
98
- await removePiAiProvider(ctx)
99
- return
100
- }
101
- if (pub.proxyMode !== false && (service(ctx, 'webServer') || typeof ctx?.get === 'function')) {
102
- await startLoopbackProxyServer(ctx, routeEnv)
103
- } else {
104
- await stopLoopbackProxyServer()
105
- }
106
- const activeKey = await resolveActiveAccountKey(ctx, cfg)
107
- if (activeKey.value) {
108
- await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
81
+ if (adapterRegistration) {
82
+ adapterRegistration.replace(pub.enabled ? [PROVIDER_ID] : [])
109
83
  }
110
84
  } catch {
111
85
  /* ignore transient settings unavailable */
@@ -284,7 +258,6 @@ export function apply(ctx, config) {
284
258
  }
285
259
 
286
260
  const ensureAdapter = () => {
287
- if (publicConfig(live()).transport === 'pi-ai') return
288
261
  if (typeof ctx.inject !== 'function') return
289
262
  ctx.inject(['llm'], (llmCtx) => {
290
263
  const llm = llmCtx?.llm || llmCtx?.get?.('llm')
@@ -322,20 +295,12 @@ export function apply(ctx, config) {
322
295
  ensureAdapter()
323
296
  applyTuiSettings(ctx, { live })
324
297
 
325
- if (typeof ctx.inject === 'function' && publicConfig(live()).transport !== 'pi-ai') {
298
+ if (typeof ctx.inject === 'function') {
326
299
  ctx.inject(['settings'], () => {
327
300
  void migrateLegacyPiAiProvider(ctx)
328
301
  })
329
302
  }
330
303
 
331
- if (typeof ctx.effect === 'function') {
332
- ctx.effect(() => {
333
- return () => {
334
- stopLoopbackProxyServer()
335
- }
336
- }, 'dsh-clinebot: loopback-proxy-server')
337
- }
338
-
339
304
  syncProviderState(live())
340
305
  if (typeof ctx.effect === 'function') {
341
306
  ctx.effect(() => {
@@ -363,7 +328,6 @@ export function apply(ctx, config) {
363
328
  registerAccountsRoutes(webCtx, routeEnv)
364
329
  registerModelsRoutes(webCtx, routeEnv)
365
330
  registerAuthRoutes(webCtx, routeEnv)
366
- registerProxyRoutes(webCtx, routeEnv)
367
331
  }
368
332
 
369
333
  if (service(ctx, 'webServer')?.register) {
@@ -380,164 +344,6 @@ export function apply(ctx, config) {
380
344
  syncProviderState,
381
345
  })
382
346
 
383
- let lastFailoverAt = 0
384
-
385
- const handleStream429 = async () => {
386
- const now = Date.now()
387
- if (now - lastFailoverAt < 30000) {
388
- ctx?.logger?.warn?.('[dsh-clinebot] Suppressing rapid failover rotation within 30s storm window')
389
- return
390
- }
391
- lastFailoverAt = now
392
- try {
393
- const failover = await rotateToNextAccount(ctx, live(), 'stream_429', settingsApi)
394
- if (failover?.rotated) {
395
- await syncProviderState(live())
396
- }
397
- } catch (err) {
398
- ctx?.logger?.warn?.('[dsh-clinebot] Auto-failover rotation failed: ' + (err?.message || err))
399
- }
400
- }
401
-
402
- if (typeof ctx.on === 'function' && publicConfig(live()).transport === 'pi-ai') {
403
- const unlisten = ctx.on('llm/stream', (options, next) => {
404
- const isCline = options?.provider === PROVIDER_ID
405
- const stream = next()
406
- const startTime = Date.now()
407
- let promptTokens = 0
408
- let completionTokens = 0
409
- let finished = false
410
-
411
- return (async function* () {
412
- try {
413
- for await (const chunk of stream) {
414
- if (isCline && chunk?.type === 'usage' && chunk?.usage) {
415
- const u = chunk.usage
416
- promptTokens += (Number(u.inputTokens) || 0) + (Number(u.cacheReadTokens) || 0) + (Number(u.cacheWriteTokens) || 0)
417
- completionTokens += Number(u.outputTokens) || 0
418
- }
419
- if (isCline && chunk?.type === 'finish' && chunk?.reason) {
420
- finished = true
421
- const latencyMs = Date.now() - startTime
422
- const reason = chunk.reason
423
- const failure = reason.failure || {}
424
- const isError = reason.kind === 'error'
425
- const isAborted = reason.kind === 'aborted'
426
- const is429 = failure.status === 429 || failure.code === 429 || failure.code === 'rate_limit_exceeded'
427
- const isExceeded = isQuotaExceededError(`${failure.message || ''} ${failure.code || ''}`)
428
-
429
- const isProxy = publicConfig(live()).proxyMode !== false
430
- if (isError) {
431
- recordSessionRequest({
432
- latencyMs,
433
- ok: false,
434
- error: failure.message || 'Stream error',
435
- promptTokens,
436
- completionTokens,
437
- model: options?.model,
438
- })
439
- if (!isProxy) {
440
- recordUsage({
441
- model: options?.model,
442
- promptTokens,
443
- completionTokens,
444
- totalTokens: promptTokens + completionTokens,
445
- isError: true,
446
- is429,
447
- statsPath: publicConfig(live()).statsPath,
448
- })
449
- }
450
- if (is429 || isExceeded) {
451
- handleStream429()
452
- }
453
- } else if (isAborted) {
454
- recordSessionRequest({
455
- latencyMs,
456
- ok: true,
457
- aborted: true,
458
- promptTokens,
459
- completionTokens,
460
- model: options?.model,
461
- })
462
- } else {
463
- recordSessionRequest({
464
- latencyMs,
465
- ok: true,
466
- promptTokens,
467
- completionTokens,
468
- model: options?.model,
469
- })
470
- if (!isProxy) {
471
- recordUsage({
472
- model: options?.model,
473
- promptTokens,
474
- completionTokens,
475
- totalTokens: promptTokens + completionTokens,
476
- statsPath: publicConfig(live()).statsPath,
477
- })
478
- }
479
- }
480
- }
481
- yield chunk
482
- }
483
- if (isCline && !finished) {
484
- recordSessionRequest({
485
- latencyMs: Date.now() - startTime,
486
- ok: true,
487
- promptTokens,
488
- completionTokens,
489
- model: options?.model,
490
- })
491
- const isProxy = publicConfig(live()).proxyMode !== false
492
- if (!isProxy) {
493
- recordUsage({
494
- model: options?.model,
495
- promptTokens,
496
- completionTokens,
497
- totalTokens: promptTokens + completionTokens,
498
- statsPath: publicConfig(live()).statsPath,
499
- })
500
- }
501
- }
502
- } catch (err) {
503
- if (isCline) {
504
- const latencyMs = Date.now() - startTime
505
- const status = err?.status || err?.statusCode
506
- const msg = String(err?.message || err)
507
- recordSessionRequest({
508
- latencyMs,
509
- ok: false,
510
- error: msg,
511
- promptTokens,
512
- completionTokens,
513
- model: options?.model,
514
- })
515
- const isProxy = publicConfig(live()).proxyMode !== false
516
- if (!isProxy) {
517
- recordUsage({
518
- model: options?.model,
519
- promptTokens,
520
- completionTokens,
521
- totalTokens: promptTokens + completionTokens,
522
- isError: true,
523
- is429: status === 429,
524
- statsPath: publicConfig(live()).statsPath,
525
- })
526
- }
527
- if (status === 429 || isQuotaExceededError(msg)) {
528
- handleStream429()
529
- }
530
- }
531
- throw err
532
- }
533
- })()
534
- }, { global: true, prepend: true })
535
-
536
- if (typeof ctx.effect === 'function') {
537
- ctx.effect(() => () => unlisten?.(), 'dsh-clinebot: llm/stream failover')
538
- }
539
- }
540
-
541
347
  // Native DSH WebSearchProvider over ctx.web (#233)
542
348
  if (typeof ctx.inject === 'function') {
543
349
  ctx.inject(['web'], (webCtx) => {
@@ -15,17 +15,12 @@ import {
15
15
  probeHealth,
16
16
  fetchUsageLimits,
17
17
  sessionStats,
18
- usageCache,
19
- buildPiAiProvider,
20
18
  DEFAULT_API_KEY_ENV,
21
19
  } from './cline-client.js'
22
- import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
20
+ import { publicConfig, plainConfig, Config } from './config.js'
23
21
  import { publicUsage } from './http.js'
24
22
  import { resolveKeyValue } from './credential-refs.js'
25
23
  import { resolveAccountPool, getLastRotation } from './account-pool.js'
26
- import { getLocalProxyToken, LOCAL_PROXY_KEY_ENV } from './proxy-token.js'
27
- import { saveCredentialKey } from './credential-refs.js'
28
- import { getProxyPort, detectHostWebServerFence } from './proxy-server.js'
29
24
 
30
25
  export function resolvePathWithHome(p) {
31
26
  let guard = 0
@@ -48,28 +43,6 @@ export function resolvePathWithHome(p) {
48
43
  return p
49
44
  }
50
45
 
51
- export async function checkRegisteredInPiAi(ctx) {
52
- const settings = ctx?.get?.('settings')
53
- try {
54
- if (typeof settings?.describe === 'function') {
55
- const descriptors = settings.describe()
56
- const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
57
- if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
58
- }
59
-
60
- const llm = ctx?.get?.('llm')
61
- if (typeof llm?.listProviders === 'function') {
62
- const list = await llm.listProviders()
63
- if (Array.isArray(list) && list.some((p) => (p?.id || p) === PROVIDER_ID)) {
64
- return true
65
- }
66
- }
67
- } catch {
68
- return false
69
- }
70
- return false
71
- }
72
-
73
46
  export async function checkRegisteredInLlm(ctx) {
74
47
  try {
75
48
  const llm = ctx?.get?.('llm')
@@ -110,14 +83,12 @@ export async function buildStatus(ctx, cfg) {
110
83
  const pub = publicConfig(cfg)
111
84
  const probeTimeout = Math.min(2500, pub.timeoutMs || 2500)
112
85
 
113
- const [key, pool, isRegisteredInPiAi, isRegisteredInLlm, health] = await Promise.all([
86
+ const [key, pool, isRegistered, health] = await Promise.all([
114
87
  resolveKeyValue(ctx, pub.apiKeyEnv),
115
88
  resolveAccountPool(ctx, cfg),
116
- checkRegisteredInPiAi(ctx),
117
89
  checkRegisteredInLlm(ctx),
118
90
  probeHealth(pub.baseUrl, { timeoutMs: probeTimeout }),
119
91
  ])
120
- const isRegistered = isRegisteredInLlm || isRegisteredInPiAi
121
92
 
122
93
  const activeAcc = await resolveActiveAccountKey(ctx, cfg, pool)
123
94
  const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
@@ -210,116 +181,6 @@ export async function buildStatus(ctx, cfg) {
210
181
  }
211
182
  }
212
183
 
213
- export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
214
- const settings = ctx?.get?.('settings')
215
- if (!settings?.mutate) {
216
- throw new Error('DSH settings service unavailable')
217
- }
218
-
219
- const pub = publicConfig(cfg)
220
- const allModels = getAllModels(pub.dynamicModels, pub.modelContextOverrides, pub.customModels)
221
- const allowedSet = new Set(activeModelIds || pub.enabledModels)
222
- const modelsToRegister = allModels.filter((m) => allowedSet.has(m.id))
223
-
224
- const activeAcc = await resolveActiveAccountKey(ctx, cfg)
225
-
226
- let isProxy = pub.proxyMode !== false
227
- let effectiveBaseUrl = pub.baseUrl
228
- let proxyPort = getProxyPort()
229
-
230
- if (isProxy) {
231
- if (!proxyPort) {
232
- proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
233
- const isFenced = await detectHostWebServerFence(proxyPort)
234
- if (isFenced) {
235
- ctx?.logger?.warn?.(
236
- `[dsh-clinebot] DesktopWebServer security fence detected on port ${proxyPort} and dedicated loopback proxy is not active. Falling back to direct upstream mode.`
237
- )
238
- isProxy = false
239
- }
240
- }
241
- }
242
-
243
- if (isProxy && proxyPort) {
244
- effectiveBaseUrl = `http://127.0.0.1:${proxyPort}/dsh-clinebot/v1`
245
- } else {
246
- isProxy = false
247
- effectiveBaseUrl = pub.baseUrl
248
- }
249
-
250
- const localToken = isProxy ? getLocalProxyToken() : undefined
251
- if (isProxy && localToken) {
252
- try {
253
- await saveCredentialKey(ctx, LOCAL_PROXY_KEY_ENV, localToken)
254
- } catch {
255
- /* credentials service might be absent/read-only in tests; process.env is set */
256
- }
257
- }
258
-
259
- let existingModels = []
260
- try {
261
- if (typeof settings.describe === 'function') {
262
- const descriptors = settings.describe()
263
- const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
264
- const existingProv = piAiDesc?.value?.providers?.[PROVIDER_ID]
265
- if (Array.isArray(existingProv?.models)) existingModels = existingProv.models
266
- } else if (typeof settings.get === 'function') {
267
- const piAiVal = settings.get(LLM_PI_AI_NS)
268
- const existingProv = piAiVal?.providers?.[PROVIDER_ID]
269
- if (Array.isArray(existingProv?.models)) existingModels = existingProv.models
270
- }
271
- } catch {
272
- /* ignore read error */
273
- }
274
-
275
- const providerObj = buildPiAiProvider({
276
- baseUrl: effectiveBaseUrl,
277
- apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
278
- models: modelsToRegister.length ? modelsToRegister : allModels,
279
- dynamicModels: pub.dynamicModels,
280
- modelContextOverrides: pub.modelContextOverrides,
281
- customModels: pub.customModels,
282
- existingModels,
283
- displayName: PROVIDER_DISPLAY_NAME,
284
- })
285
-
286
- await settings.mutate(LLM_PI_AI_NS, [
287
- {
288
- op: 'set',
289
- path: ['providers', PROVIDER_ID],
290
- value: providerObj,
291
- },
292
- ])
293
-
294
- ctx?.logger?.info?.(
295
- `[dsh-clinebot] Synchronized llm-pi-ai provider "${PROVIDER_ID}" with ${providerObj.models.length} models.`
296
- )
297
-
298
- return providerObj
299
- }
300
-
301
- export async function removePiAiProvider(ctx) {
302
- const settings = ctx?.get?.('settings')
303
- if (!settings?.mutate) {
304
- throw new Error('DSH settings service unavailable')
305
- }
306
-
307
- try {
308
- await settings.mutate(LLM_PI_AI_NS, [
309
- {
310
- op: 'remove',
311
- path: ['providers', PROVIDER_ID],
312
- },
313
- ])
314
- } catch (err) {
315
- const msg = String(err?.message || err).toLowerCase()
316
- if (!msg.includes('not found') && !msg.includes('absent') && !msg.includes('enoent') && !msg.includes('does not exist')) {
317
- throw err
318
- }
319
- }
320
- return { ok: true }
321
- }
322
-
323
184
  export function formatProgressBar(pct, totalWidth = 10) {
324
185
  const clamped = Math.max(0, Math.min(100, pct || 0))
325
186
  const filled = Math.round((clamped / 100) * totalWidth)
@@ -2,7 +2,7 @@ import { writeJson, readBody, publicUsage } from '../http.js'
2
2
  import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
3
3
  import { assertTrustedSettingsRequest } from '../access.js'
4
4
  import { publicConfig, plainConfig, Config } from '../config.js'
5
- import { upsertPiAiProvider, removePiAiProvider, resolveActiveAccountKey } from '../provider-sync.js'
5
+ import { resolveActiveAccountKey } from '../provider-sync.js'
6
6
  import { resolveAccountPool } from '../account-pool.js'
7
7
  import {
8
8
  clearUsageCache,
@@ -313,42 +313,6 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
313
313
  },
314
314
  }), 'dsh-clinebot: /accounts/active')
315
315
 
316
- // POST /dsh-clinebot/register — upsert into DSH llm-pi-ai
317
- ctx.effect(() => ctx.webServer.register({
318
- kind: 'exact',
319
- path: '/dsh-clinebot/register',
320
- handler: async (req, res) => {
321
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
322
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
323
- try {
324
- const bodyBuf = await readBody(req)
325
- let body = {}
326
- try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
327
- const activeModels = body.models || publicConfig(live()).enabledModels
328
- const result = await upsertPiAiProvider(ctx, live(), activeModels)
329
- writeJson(res, 200, { ok: true, provider: result })
330
- } catch (err) {
331
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
332
- }
333
- },
334
- }), 'dsh-clinebot: /register')
335
-
336
- // POST /dsh-clinebot/unregister — remove from DSH
337
- ctx.effect(() => ctx.webServer.register({
338
- kind: 'exact',
339
- path: '/dsh-clinebot/unregister',
340
- handler: async (req, res) => {
341
- if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
342
- if (!assertTrustedSettingsRequest(req, res, ctx)) return
343
- try {
344
- await removePiAiProvider(ctx)
345
- writeJson(res, 200, { ok: true })
346
- } catch (err) {
347
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
348
- }
349
- },
350
- }), 'dsh-clinebot: /unregister')
351
-
352
316
  // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232, #246)
353
317
  ctx.effect(() => ctx.webServer.register({
354
318
  kind: 'exact',
@@ -3,7 +3,7 @@ import { assertTrustedSettingsRequest } from '../access.js'
3
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
- import { getStatsSummary, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
6
+ import { getStatsSummary, getSessionStats, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
7
7
  import { priceTable } from '../model-prices.js'
8
8
 
9
9
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
@@ -197,6 +197,24 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
197
197
  },
198
198
  }), 'dsh-clinebot: /stats')
199
199
 
200
+ // 4b. GET /dsh-clinebot/stats/session — ClineBot-only aggregate for one DSH session
201
+ ctx.effect(() => ctx.webServer.register({
202
+ kind: 'exact',
203
+ path: '/dsh-clinebot/stats/session',
204
+ handler: async (req, res) => {
205
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' })
206
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
207
+ try {
208
+ const url = new URL(req.url || '/', 'http://localhost')
209
+ const sessionId = String(url.searchParams.get('sessionId') || '').trim().slice(0, 200)
210
+ if (!sessionId) return writeJson(res, 400, { ok: false, error: 'sessionId required' })
211
+ writeJson(res, 200, { ok: true, session: getSessionStats(sessionId) })
212
+ } catch (err) {
213
+ writeJson(res, 500, { ok: false, error: String(err?.message || err) })
214
+ }
215
+ },
216
+ }), 'dsh-clinebot: /stats/session')
217
+
200
218
  // 5. POST /dsh-clinebot/stats/reset — reset persistent stats (Issue #129)
201
219
  ctx.effect(() => ctx.webServer.register({
202
220
  kind: 'exact',
@@ -336,7 +336,10 @@ export function recordUsage({
336
336
  inMemoryStats.byHourModel[hourKey][model] = Number(inMemoryStats.byHourModel[hourKey][model] || 0) + 1
337
337
  pruneHours(now)
338
338
  if (sessionId) {
339
- incrementBucket(inMemoryStats.bySession, String(sessionId), entry)
339
+ const key = String(sessionId)
340
+ incrementBucket(inMemoryStats.bySession, key, entry)
341
+ const bucket = inMemoryStats.bySession[key]
342
+ if (bucket) bucket.last = { ...entry, sessionId: key }
340
343
  pruneSessions(now)
341
344
  }
342
345
  queueSaveStats(statsPath)
@@ -346,6 +349,18 @@ export function getStatsSummary() {
346
349
  return JSON.parse(JSON.stringify(inMemoryStats))
347
350
  }
348
351
 
352
+ /**
353
+ * Return the ClineBot-only aggregate for one DSH session.
354
+ * Returns null when the session has no recorded ClineBot traffic.
355
+ * @param {string} sessionId
356
+ */
357
+ export function getSessionStats(sessionId) {
358
+ const key = String(sessionId || '').trim()
359
+ if (!key) return null
360
+ const bucket = inMemoryStats.bySession?.[key]
361
+ return bucket ? JSON.parse(JSON.stringify(bucket)) : null
362
+ }
363
+
349
364
  export function resetStats(customPath) {
350
365
  inMemoryStats = createEmptyStats()
351
366
  saveStatsSync(customPath)
@@ -9,7 +9,7 @@
9
9
  import { createRequire } from 'node:module'
10
10
 
11
11
  const require = createRequire(import.meta.url)
12
- let packageVersion = '0.6.0'
12
+ let packageVersion = '0.6.2'
13
13
  try {
14
14
  packageVersion = require('../../package.json').version || packageVersion
15
15
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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",