@goodandready/dsh-clinebot 0.6.0 → 0.6.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.1] - 2026-10-21
9
+
10
+ ### Fixed
11
+ - **ClineBot-only session cost**: the composer now reads the plugin's per-session `bySession` bucket instead of the host's session-wide `tokenUsage`. A conversation that also used OpenCode can no longer inflate the ClineBot estimate.
12
+ - **Last request in the tooltip**: each session bucket retains the latest ClineBot request; the native token pill title now shows the ClineBot session cost and the last request cost.
13
+ - **Free estimates**: priced requests that cost zero render as `Free` instead of `$0.0000`.
14
+
15
+ ### Added
16
+ - `GET /dsh-clinebot/stats/session?sessionId=...` returns the ClineBot-only aggregate for one DSH session, including the last request.
17
+
8
18
  ## [0.6.0] - 2026-10-20
9
19
 
10
20
  ### Added
package/lib/client.js CHANGED
@@ -241,7 +241,9 @@ const en = {
241
241
  'dashboard.account_pool': 'Active Routing Account',
242
242
  'dashboard.credits': 'Credit Balance',
243
243
  'dashboard.updated_at': 'Updated at',
244
- 'composer.cost_title': 'Estimated Session Cost',
244
+ 'composer.cost_title': 'ClineBot Session Cost',
245
+ 'composer.last_request': 'Last request',
246
+ 'composer.free': 'Free',
245
247
  'composer.cost_breakdown': 'Token & Cost Breakdown',
246
248
  'composer.prompt_tok': 'Prompt Tokens',
247
249
  'composer.completion_tok': 'Completion Tokens',
@@ -528,7 +530,9 @@ const zh = {
528
530
  'dashboard.account_pool': '当前承载账户',
529
531
  'dashboard.credits': '点数余额',
530
532
  'dashboard.updated_at': '更新于',
531
- 'composer.cost_title': '预估会话成本',
533
+ 'composer.cost_title': 'ClineBot 会话成本',
534
+ 'composer.last_request': '最近一次请求',
535
+ 'composer.free': '免费',
532
536
  'composer.cost_breakdown': 'Token 与费用明细',
533
537
  'composer.prompt_tok': '提示词 Token',
534
538
  'composer.completion_tok': '补全 Token',
@@ -4710,10 +4714,12 @@ function ComposerCostEntry(props) {
4710
4714
  const configSnapshot = useConfigFormsSnapshot(props?.ctx)
4711
4715
  const [stats, setStats] = React.useState(null)
4712
4716
  const [prices, setPrices] = React.useState([])
4717
+ const [sessionStats, setSessionStats] = React.useState(null)
4713
4718
  const markerRef = React.useRef(null)
4714
4719
 
4715
4720
  const projectedTokens = props.useProjection('tokenUsage')
4716
4721
  const selection = props.useProjection('modelSelection')
4722
+ const sessionId = String(props?.sessionId || '')
4717
4723
  const priceOverridesKey = JSON.stringify(configSnapshot?.value?.priceOverrides || {})
4718
4724
 
4719
4725
  React.useEffect(() => {
@@ -4728,7 +4734,40 @@ function ComposerCostEntry(props) {
4728
4734
  }, [priceOverridesKey])
4729
4735
 
4730
4736
  React.useEffect(() => {
4731
- if (projectedTokens) return undefined
4737
+ if (!sessionId) {
4738
+ setSessionStats(null)
4739
+ return undefined
4740
+ }
4741
+ let active = true
4742
+ const loadSession = async () => {
4743
+ try {
4744
+ const res = await fetchWithTimeout(
4745
+ `${ROUTE_PREFIX}/stats/session?sessionId=${encodeURIComponent(sessionId)}`,
4746
+ { cache: 'no-store' },
4747
+ )
4748
+ const data = await res.json().catch(() => null)
4749
+ if (active && data?.ok) setSessionStats(data.session || null)
4750
+ } catch {
4751
+ /* quiet */
4752
+ }
4753
+ }
4754
+ loadSession()
4755
+ let timer = null
4756
+ const schedule = () => {
4757
+ timer = setTimeout(async () => {
4758
+ await loadSession()
4759
+ if (active) schedule()
4760
+ }, 10000)
4761
+ }
4762
+ schedule()
4763
+ return () => {
4764
+ active = false
4765
+ if (timer) clearTimeout(timer)
4766
+ }
4767
+ }, [sessionId, projectedTokens])
4768
+
4769
+ React.useEffect(() => {
4770
+ if (projectedTokens || sessionId) return undefined
4732
4771
  let active = true
4733
4772
  const loadStats = async () => {
4734
4773
  try {
@@ -4744,7 +4783,7 @@ function ComposerCostEntry(props) {
4744
4783
  loadStats()
4745
4784
  const timer = setTimeout(loadStats, 30000)
4746
4785
  return () => { active = false; clearTimeout(timer) }
4747
- }, [projectedTokens])
4786
+ }, [projectedTokens, sessionId])
4748
4787
 
4749
4788
  const selected = selection?.lastUsed
4750
4789
  ?? selection?.next
@@ -4754,32 +4793,65 @@ function ComposerCostEntry(props) {
4754
4793
  const isClineBot = (provider === '' || provider === 'clinebot' || provider === 'llm-clinebot')
4755
4794
  && (provider === 'clinebot' || provider === 'llm-clinebot' || modelId.startsWith('cline-pass/'))
4756
4795
 
4757
- const rawTokens = projectedTokens || {
4796
+ // ClineBot requests are recorded in the plugin's own per-session bucket. That
4797
+ // bucket is provider-scoped, unlike the host's session-wide tokenUsage, so a
4798
+ // session that also used OpenCode cannot inflate the ClineBot read-out.
4799
+ const session = sessionStats && Number(sessionStats.requests || 0) > 0 ? sessionStats : null
4800
+ const sessionTokens = session
4801
+ ? {
4802
+ promptTokens: Number(session.promptTokens || 0),
4803
+ completionTokens: Number(session.completionTokens || 0),
4804
+ reasoningTokens: Number(session.reasoningTokens || 0),
4805
+ cacheReadTokens: Number(session.cacheReadTokens || 0),
4806
+ cacheWriteTokens: Number(session.cacheWriteTokens || 0),
4807
+ }
4808
+ : null
4809
+ const rawTokens = sessionTokens || (sessionId ? null : (projectedTokens || {
4758
4810
  promptTokens: stats?.promptTokens || 0,
4759
4811
  completionTokens: stats?.completionTokens || 0,
4760
4812
  reasoningTokens: stats?.reasoningTokens || 0,
4761
4813
  cacheReadTokens: stats?.cacheReadTokens || 0,
4762
4814
  cacheWriteTokens: stats?.cacheWriteTokens || 0,
4763
- }
4764
- const cacheRead = Number(rawTokens.cacheReadTokens || 0)
4765
- const cacheWrite = Number(rawTokens.cacheWriteTokens || 0)
4766
- const uncachedInput = Number(rawTokens.uncachedInputTokens)
4767
- const promptTokens = Number.isFinite(uncachedInput)
4768
- ? uncachedInput + cacheRead + cacheWrite
4769
- : Number(rawTokens.promptTokens || rawTokens.inputTokens || 0)
4815
+ }))
4816
+ const cacheRead = Number(rawTokens?.cacheReadTokens || 0)
4817
+ const cacheWrite = Number(rawTokens?.cacheWriteTokens || 0)
4818
+ const uncachedInput = Number(rawTokens?.uncachedInputTokens)
4819
+ const promptTokens = rawTokens
4820
+ ? (Number.isFinite(uncachedInput)
4821
+ ? uncachedInput + cacheRead + cacheWrite
4822
+ : Number(rawTokens.promptTokens || rawTokens.inputTokens || 0))
4823
+ : 0
4770
4824
  const tokens = {
4771
4825
  promptTokens,
4772
- completionTokens: Number(rawTokens.completionTokens || rawTokens.outputTokens || 0),
4773
- reasoningTokens: Number(rawTokens.reasoningTokens || 0),
4826
+ completionTokens: Number(rawTokens?.completionTokens || rawTokens?.outputTokens || 0),
4827
+ reasoningTokens: Number(rawTokens?.reasoningTokens || 0),
4774
4828
  cacheReadTokens: cacheRead,
4775
4829
  cacheWriteTokens: cacheWrite,
4776
4830
  }
4777
- const { cost, priced, source, pTok, cTok, rTok, total } = calculateCost(tokens, modelId, prices)
4778
-
4779
- const costValue = priced ? (cost < 0.0001 && cost > 0 ? '< $0.0001' : `$${cost.toFixed(4)}`) : t('composer.unpriced')
4780
- const costDisplay = priced && source === 'estimate' ? `≈ ${costValue}` : costValue
4831
+ const calculated = calculateCost(tokens, session?.model || modelId, prices)
4832
+ const cost = session ? Number(session.costUsd || 0) : calculated.cost
4833
+ const priced = session ? Number(session.pricedRequests || 0) > 0 : calculated.priced
4834
+ const source = session
4835
+ ? (findPrice(session.model || modelId, prices)?.source || 'estimate')
4836
+ : calculated.source
4837
+ const { pTok, cTok, rTok } = calculated
4838
+ const total = session ? Number(session.totalTokens || pTok + cTok) : calculated.total
4839
+ const last = session?.last || null
4840
+ const lastCost = last ? Number(last.costUsd || 0) : 0
4841
+ const lastPriced = Boolean(last?.priced)
4842
+
4843
+ const costValue = priced
4844
+ ? (cost === 0 ? t('composer.free') : (cost < 0.0001 ? '< $0.0001' : `$${cost.toFixed(4)}`))
4845
+ : t('composer.unpriced')
4846
+ const costDisplay = priced && source === 'estimate' && cost > 0 ? `≈ ${costValue}` : costValue
4847
+ const lastCostValue = last
4848
+ ? (lastPriced
4849
+ ? (lastCost === 0 ? t('composer.free') : (lastCost < 0.0001 ? '< $0.0001' : `$${lastCost.toFixed(4)}`))
4850
+ : t('composer.unpriced'))
4851
+ : ''
4781
4852
  const title = [
4782
4853
  `${t('composer.cost_title')} ${costDisplay}`,
4854
+ ...(last ? [`${t('composer.last_request')} ${lastCostValue}`] : []),
4783
4855
  `${t('composer.prompt_tok')} ${formatTokens(pTok)}`,
4784
4856
  `${t('composer.completion_tok')} ${formatTokens(cTok)}`,
4785
4857
  ...(rTok > 0 ? [`${t('composer.reasoning_tok')} ${formatTokens(rTok)}`] : []),
@@ -22,9 +22,9 @@ if (process.env[LOCAL_PROXY_KEY_ENV]) {
22
22
  }
23
23
 
24
24
  const require = createRequire(import.meta.url)
25
- let packageVersion = '0.6.0'
25
+ let packageVersion = '0.6.1'
26
26
  try {
27
- packageVersion = require('../package.json').version || '0.6.0'
27
+ packageVersion = require('../package.json').version || '0.6.1'
28
28
  } catch (err) {
29
29
  /* fallback to default version if manifest is unreadable */
30
30
  }
@@ -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.1'
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.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",