@goodandready/dsh-cost-meter 0.8.25 → 0.8.27

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
@@ -30,11 +30,16 @@ try {
30
30
  export * from './tariff.js'
31
31
  export * from './projection.js'
32
32
  export * from './fx.js'
33
+ export * from './alerts.js'
34
+ export * from './commands.js'
35
+ export * from './export.js'
33
36
  export { STATE_PATH, REFRESH_PATH } from './routes.js'
34
- import { indexCatalog, resolveTariff, matchModel, unwrapVolatile } from './tariff.js'
35
- import { usageByModelProjection } from './projection.js'
37
+ import { indexCatalog, resolveTariff, matchModel, unwrapVolatile, calculateCacheSavings } from './tariff.js'
38
+ import { usageByModelProjection, getLastTurns, resetLastTurns } from './projection.js'
36
39
  import { getFxRate, refreshFxRates } from './fx.js'
37
-
40
+ import { registerSlashCommand } from './commands.js'
41
+ import { calculateBurnRateAndProjection } from './analytics.js'
42
+ import { ratesAt } from './alerts.js'
38
43
 
39
44
  export const name = '@goodandready/dsh-cost-meter'
40
45
  export const inject = ['webServer', 'settings', 'sessionProjections']
@@ -84,7 +89,6 @@ export function isTrustedCaller(request, ctx = null) {
84
89
  if (isLoopback(url.hostname) && loopback) return true
85
90
  return false
86
91
  }
87
- // Non-http/https origin (e.g. dsh-app://app in DSH Desktop)
88
92
  if (loopback) return true
89
93
  return false
90
94
  } catch {
@@ -101,7 +105,6 @@ export function isTrustedCaller(request, ctx = null) {
101
105
  if (isLoopback(url.hostname) && loopback) return true
102
106
  return false
103
107
  }
104
- // Non-http/https referer (e.g. dsh-app://app/ in DSH Desktop)
105
108
  if (loopback) return true
106
109
  return false
107
110
  } catch {
@@ -110,7 +113,6 @@ export function isTrustedCaller(request, ctx = null) {
110
113
  }
111
114
 
112
115
  if (loopback) return true
113
-
114
116
  return false
115
117
  }
116
118
 
@@ -126,6 +128,9 @@ export function normalizeConfig(raw) {
126
128
  displayTimeZone: typeof u.displayTimeZone === 'string' && u.displayTimeZone ? u.displayTimeZone : 'Europe/Moscow',
127
129
  budgetThreshold: Number(u.budgetThreshold) || 0,
128
130
  displayMode: (u.displayMode === 'compact' || u.displayMode === 'full') ? u.displayMode : 'auto',
131
+ alertBeforeSwitch: u.alertBeforeSwitch !== false,
132
+ alertLeadMinutes: Math.max(1, Math.min(30, Number(u.alertLeadMinutes) || 5)),
133
+ browserNotifications: Boolean(u.browserNotifications),
129
134
  useOpenRouter: u.useOpenRouter !== false,
130
135
  refreshHours: Number(u.refreshHours) || 24,
131
136
  deepseekPeakPrices: u.deepseekPeakPrices || {},
@@ -154,6 +159,9 @@ export const Config = z.object({
154
159
  displayTimeZone: z.string().description('IANA zone the widget shows tariff hours in.').default('Europe/Moscow').volatile(),
155
160
  budgetThreshold: z.number().description('Optional session budget threshold in USD. 0 disables threshold warnings.').default(0).volatile(),
156
161
  displayMode: z.union(['auto', 'full', 'compact']).description('Widget display mode: auto, full, or compact.').default('auto').volatile(),
162
+ alertBeforeSwitch: z.boolean().description('Pre-switch notification and visual alert before tariff change.').default(true).volatile(),
163
+ alertLeadMinutes: z.number().description('Minutes before tariff change to trigger pre-switch alert.').default(5).volatile(),
164
+ browserNotifications: z.boolean().description('Enable desktop browser notifications for tariff switches.').default(false).volatile(),
157
165
  deepseekPeakPrices: z.dict(PriceRow).description('Override the built-in DeepSeek peak rates, by model id.').default({}).volatile(),
158
166
  useOpenRouter: z.boolean().description('Pull rates and discount windows from the public OpenRouter catalog.').default(true).volatile(),
159
167
  refreshHours: z.number().description('How often to refetch the catalog.').default(24).volatile(),
@@ -163,9 +171,6 @@ export const Config = z.object({
163
171
  manualOffPeakMultiplier: z.number().description('Multiplier outside the manual peak windows.').default(1).volatile(),
164
172
  })
165
173
 
166
-
167
- // Per-model usage projection is defined in and exported from ./projection.js
168
-
169
174
  /** The agent's default route, read off settings descriptors when available. */
170
175
  function defaultRoute(ctx) {
171
176
  const settings = (ctx?.get && typeof ctx.get === 'function' ? ctx.get('settings') : null) || ctx?.settings
@@ -195,17 +200,13 @@ export function apply(ctx, config) {
195
200
  void refreshFxRates()
196
201
  let current = normalizeConfig(config)
197
202
  let catalog = { index: { byId: {}, byLowerId: {}, bySuffix: {} }, fetchedAt: 0, error: null }
198
-
199
- // Server-side cache for resolved tariffs: key -> { schedule, source, matchedId }
200
203
  const resolveCache = new Map()
201
204
 
202
-
203
205
  function getCachedTariff(cfg, route) {
204
206
  if (!route) return resolveTariff(cfg, catalog.index, route)
205
207
  const cacheKey = (route.provider || '') + '/' + (route.model || '')
206
208
  if (resolveCache.has(cacheKey)) return resolveCache.get(cacheKey)
207
209
  const res = resolveTariff(cfg, catalog.index, route)
208
- // Limit cache size to prevent runaway memory
209
210
  if (resolveCache.size >= 500) resolveCache.clear()
210
211
  resolveCache.set(cacheKey, res)
211
212
  return res
@@ -215,15 +216,12 @@ export function apply(ctx, config) {
215
216
  resolveCache.clear()
216
217
  }
217
218
 
218
- // Disk cache survives restarts, so a boot without network still prices.
219
219
  try {
220
220
  const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf8'))
221
221
  if (cached && Array.isArray(cached.models)) {
222
222
  catalog = { index: indexCatalog(cached.models), fetchedAt: cached.fetchedAt || 0, error: null }
223
223
  }
224
- } catch {
225
- // absent or unreadable cache is normal on first run
226
- }
224
+ } catch {}
227
225
 
228
226
  async function refresh(signal, force = false) {
229
227
  const cfg = current ?? config
@@ -244,9 +242,7 @@ export function apply(ctx, config) {
244
242
  try {
245
243
  mkdirSync(dirname(CACHE_FILE), { recursive: true })
246
244
  writeFileSync(CACHE_FILE, JSON.stringify({ fetchedAt: catalog.fetchedAt, models: slim }))
247
- } catch {
248
- // a read-only storages dir only costs us the restart-time cache
249
- }
245
+ } catch {}
250
246
  } catch (error) {
251
247
  catalog = { ...catalog, error: String((error && error.message) || error) }
252
248
  }
@@ -287,6 +283,19 @@ export function apply(ctx, config) {
287
283
  eff(() => pctx.sessionProjections.register(usageByModelProjection))
288
284
  })
289
285
 
286
+ // Register slash command /cost and /cost-summary
287
+ registerSlashCommand(ctx, {
288
+ getCurrentConfig: () => (current ? normalizeConfig(current) : normalizeConfig(config)),
289
+ getCachedTariff,
290
+ getLastTurns,
291
+ resetLastTurns,
292
+ calculateBurnRateAndProjection,
293
+ calculateCacheSavings,
294
+ ratesAt,
295
+ splitRoute,
296
+ defaultRoute,
297
+ })
298
+
290
299
  // Register HTTP routes (refresh, state, update) via webServer service
291
300
  registerRoutes(ctx, {
292
301
  name,
@@ -299,5 +308,3 @@ export function apply(ctx, config) {
299
308
  getCatalog: () => catalog,
300
309
  })
301
310
  }
302
-
303
-
package/lib/projection.js CHANGED
@@ -32,6 +32,8 @@ export const turnRecordSchema = zod.object({
32
32
  cacheWriteTokens: zod.number().nonnegative().optional(),
33
33
  costDeltaUsd: zod.number().nonnegative().optional(),
34
34
  timestamp: zod.number().nonnegative(),
35
+ durationMs: zod.number().nonnegative().optional(),
36
+ cacheHitRate: zod.number().min(0).max(100).optional(),
35
37
  }).passthrough()
36
38
 
37
39
  /** Pull the four buckets out of one provider usage record. */
@@ -152,7 +154,20 @@ export const usageByModelProjection = {
152
154
  ? event.time
153
155
  : (event.time instanceof Date && !Number.isNaN(event.time.getTime()) ? event.time.getTime() : Date.now())
154
156
 
157
+ const promptTokens = buckets.uncachedInputTokens + buckets.cacheReadTokens
158
+ const cacheHitRate = promptTokens > 0
159
+ ? Math.round((buckets.cacheReadTokens / promptTokens) * 100)
160
+ : 0
161
+
155
162
  const currentTurns = Array.isArray(state.turns) ? state.turns : []
163
+ let durationMs = 0
164
+ if (currentTurns.length > 0) {
165
+ const prevTs = currentTurns[currentTurns.length - 1].timestamp
166
+ if (timestamp > prevTs && timestamp - prevTs < 600000) {
167
+ durationMs = timestamp - prevTs
168
+ }
169
+ }
170
+
156
171
  const existingIndex = currentTurns.findIndex((t) => t.turn === turnNum && t.step === stepNum)
157
172
  let nextTurns
158
173
  if (existingIndex >= 0) {
@@ -167,6 +182,8 @@ export const usageByModelProjection = {
167
182
  cacheWriteTokens: buckets.cacheWriteTokens,
168
183
  costDeltaUsd: nextTurns[existingIndex].costDeltaUsd || 0,
169
184
  timestamp,
185
+ durationMs: nextTurns[existingIndex].durationMs || durationMs,
186
+ cacheHitRate,
170
187
  }
171
188
  } else {
172
189
  const newEntry = {
@@ -179,6 +196,8 @@ export const usageByModelProjection = {
179
196
  cacheWriteTokens: buckets.cacheWriteTokens,
180
197
  costDeltaUsd: 0,
181
198
  timestamp,
199
+ durationMs,
200
+ cacheHitRate,
182
201
  }
183
202
  nextTurns = [...currentTurns, newEntry].slice(-MAX_TURNS_HISTORY)
184
203
  }
package/lib/routes.js CHANGED
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
3
3
  import { registerPluginUpdater } from './updater.js'
4
4
  import { getLastTurns } from './projection.js'
5
5
  import { calculateBurnRateAndProjection } from './analytics.js'
6
+ import { getCachedFxState } from './fx.js'
6
7
 
7
8
  export const STATE_PATH = '/dsh-cost-meter/state'
8
9
  export const REFRESH_PATH = '/dsh-cost-meter/refresh'
@@ -107,9 +108,13 @@ export function registerRoutes(ctx, options) {
107
108
  const payloadObj = {
108
109
  currency: typeof cfg?.currency === 'string' && cfg.currency ? cfg.currency : '$',
109
110
  usdRate: Number(cfg?.usdRate) > 0 ? Number(cfg.usdRate) : 1,
111
+ fxRates: { USD: 1, ...(getCachedFxState().rates || {}) },
110
112
  displayTimeZone: typeof cfg?.displayTimeZone === 'string' && cfg.displayTimeZone ? cfg.displayTimeZone : 'Europe/Moscow',
111
113
  budgetThreshold: Number(cfg?.budgetThreshold) || 0,
112
114
  displayMode: cfg?.displayMode || 'auto',
115
+ alertBeforeSwitch: cfg?.alertBeforeSwitch !== false,
116
+ alertLeadMinutes: Number(cfg?.alertLeadMinutes) || 5,
117
+ browserNotifications: Boolean(cfg?.browserNotifications),
113
118
  route,
114
119
  source: own.source,
115
120
  matchedId: own.matchedId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cost-meter",
3
- "version": "0.8.25",
3
+ "version": "0.8.27",
4
4
  "description": "DeepSeek Harness plugin: a session cost chip in the conversation header - live token usage by bucket, peak/off-peak tariff, and a countdown to the next tariff switch.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",