dsh-tacit 0.3.0 → 0.5.0

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/store.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * profile.json persistent user mistake profile
9
9
  * reports/<sessionId>/<turn>.json analysis reports
10
10
  * usage/<YYYY-MM-DD>.json per-day usage ledger (runs of metered model calls)
11
- * usage/summary.json rolling lifetime/byType/byModel/day totals
11
+ * usage/summary.json rolling lifetime/byType/byModel/byProvider/byTrigger/day totals
12
12
  *
13
13
  * Safety rules (hard constraints):
14
14
  * - writes are atomic (temp file + rename) and never truncate an existing
@@ -31,6 +31,14 @@ import { usageDayFileSchema, usageSummarySchema } from './schema.js'
31
31
  const USAGE_DAY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.json$/
32
32
  const USAGE_DAY_RE = /^\d{4}-\d{2}-\d{2}$/
33
33
 
34
+ /**
35
+ * How much summed on-disk day-file weight `readUsageDay` may keep parsed in
36
+ * memory. Chosen so a realistic retention window is never evicted while a
37
+ * pathological one (a year of capped 500-run days) degrades to re-reading
38
+ * instead of pinning hundreds of megabytes for the life of the process.
39
+ */
40
+ const USAGE_DAY_MEMO_BUDGET_BYTES = 16 * 1024 * 1024
41
+
34
42
  /** Local calendar day key, `YYYY-MM-DD` (auto-analysis daily budget and the usage ledger share this). */
35
43
  export function dayKey(now = Date.now()) {
36
44
  const date = new Date(now)
@@ -43,7 +51,7 @@ export function dayKey(now = Date.now()) {
43
51
  * Subtracting a fixed `days * 86_400_000` instead would land a day early across
44
52
  * a spring-forward (a 23 h day), silently keeping one day more than asked for.
45
53
  */
46
- function dayKeyBefore(today, days) {
54
+ export function dayKeyBefore(today, days) {
47
55
  const match = USAGE_DAY_RE.exec(String(today))
48
56
  const at = match !== null
49
57
  ? new Date(Number(match[0].slice(0, 4)), Number(match[0].slice(5, 7)) - 1, Number(match[0].slice(8, 10)))
@@ -54,7 +62,7 @@ function dayKeyBefore(today, days) {
54
62
  }
55
63
 
56
64
  function emptyUsageSummaryRaw() {
57
- return { version: 1, trackingSince: Date.now(), lifetime: {}, byType: {}, byModel: {}, days: {} }
65
+ return { version: 1, trackingSince: Date.now(), lifetime: {}, byType: {}, byModel: {}, byProvider: {}, byTrigger: {}, days: {} }
58
66
  }
59
67
 
60
68
  export function emptyProfile() {
@@ -75,6 +83,9 @@ export class CoachStore {
75
83
  this.root = root
76
84
  /** Absolute paths already warned about (corrupt usage JSON) — warn once per file, not once per read. */
77
85
  this.warnedUsageFiles = new Set()
86
+ /** Parsed day files by absolute path, keyed on the `mtimeMs`/`size` they were read at. */
87
+ this.usageDayMemo = new Map()
88
+ this.usageDayMemoBytes = 0
78
89
  }
79
90
 
80
91
  ensureDir(dir) {
@@ -299,14 +310,55 @@ export class CoachStore {
299
310
  return path.join(this.usageDir(), `${day}.json`)
300
311
  }
301
312
 
313
+ /**
314
+ * A parsed day file. Old day files never change, and `report()` re-reads the
315
+ * whole window on every poll of the cost panel, so an unchanged file is
316
+ * served from memory. The returned object is shared, not cloned: no caller
317
+ * mutates it (`upsertDay` copies `runs` before editing).
318
+ */
302
319
  readUsageDay(day) {
303
- return this.readUsageJson(this.usageDayFile(day), usageDayFileSchema, { version: 1, day, runs: [] })
320
+ const file = this.usageDayFile(day)
321
+ let stat = null
322
+ try {
323
+ stat = fs.statSync(file)
324
+ } catch {
325
+ stat = null
326
+ }
327
+ if (stat === null) {
328
+ this.forgetUsageDay(file)
329
+ return this.readUsageJson(file, usageDayFileSchema, { version: 1, day, runs: [] })
330
+ }
331
+ const memo = this.usageDayMemo.get(file)
332
+ if (memo !== undefined && memo.mtimeMs === stat.mtimeMs && memo.size === stat.size) return memo.value
333
+ const value = this.readUsageJson(file, usageDayFileSchema, { version: 1, day, runs: [] })
334
+ this.forgetUsageDay(file)
335
+ this.usageDayMemo.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, value })
336
+ this.usageDayMemoBytes += stat.size
337
+ while (this.usageDayMemoBytes > USAGE_DAY_MEMO_BUDGET_BYTES) {
338
+ const oldest = this.usageDayMemo.keys().next()
339
+ if (oldest.done) break
340
+ this.forgetUsageDay(oldest.value)
341
+ }
342
+ return value
343
+ }
344
+
345
+ /**
346
+ * Drop one memoized day file. Every in-process write and unlink calls this:
347
+ * a same-millisecond same-size rewrite would otherwise pass the stat check
348
+ * and let `upsertDay`'s read-modify-write silently drop runs.
349
+ */
350
+ forgetUsageDay(file) {
351
+ const memo = this.usageDayMemo.get(file)
352
+ if (memo === undefined) return
353
+ this.usageDayMemo.delete(file)
354
+ this.usageDayMemoBytes -= memo.size
304
355
  }
305
356
 
306
357
  /** Atomic write; caps `runs` to the newest 500 by `startedAt` before writing. */
307
358
  writeUsageDay(day, file) {
308
359
  const runs = Array.isArray(file?.runs) ? [...file.runs] : []
309
360
  runs.sort((a, b) => (Number(a?.startedAt) || 0) - (Number(b?.startedAt) || 0))
361
+ this.forgetUsageDay(this.usageDayFile(day))
310
362
  this.writeJsonAtomic(this.usageDayFile(day), { version: 1, day, runs: runs.slice(-500) })
311
363
  }
312
364
 
@@ -329,8 +381,15 @@ export class CoachStore {
329
381
  .sort()
330
382
  }
331
383
 
384
+ /**
385
+ * `{summary, created}`. `created` is true when there was no usable file and
386
+ * the default was synthesized, which a caller cannot infer from the value:
387
+ * a summary with nothing recorded yet is indistinguishable from a fresh one.
388
+ */
332
389
  readUsageSummary() {
333
- return this.readUsageJson(this.usageSummaryFile(), usageSummarySchema, usageSummarySchema.parse(emptyUsageSummaryRaw()))
390
+ const fallback = usageSummarySchema.parse(emptyUsageSummaryRaw())
391
+ const summary = this.readUsageJson(this.usageSummaryFile(), usageSummarySchema, fallback)
392
+ return { summary, created: summary === fallback }
334
393
  }
335
394
 
336
395
  writeUsageSummary(summary) {
@@ -350,6 +409,7 @@ export class CoachStore {
350
409
  for (const day of this.listUsageDays()) {
351
410
  if (day >= cutoff) continue
352
411
  try {
412
+ this.forgetUsageDay(this.usageDayFile(day))
353
413
  fs.unlinkSync(this.usageDayFile(day))
354
414
  removed += 1
355
415
  } catch {
@@ -368,6 +428,7 @@ export class CoachStore {
368
428
  let removed = 0
369
429
  for (const day of this.listUsageDays()) {
370
430
  try {
431
+ this.forgetUsageDay(this.usageDayFile(day))
371
432
  fs.unlinkSync(this.usageDayFile(day))
372
433
  removed += 1
373
434
  } catch {
package/lib/usage.js CHANGED
@@ -23,18 +23,20 @@
23
23
  * flushed to `usage/summary.json`) so reports never re-scan every day file.
24
24
  */
25
25
 
26
- import { dayKey } from './store.js'
27
- import { USAGE_OPS, USAGE_RUN_TYPES } from './schema.js'
26
+ import { dayKey, dayKeyBefore } from './store.js'
27
+ import { USAGE_ATTEMPT_STATUSES, USAGE_OPS, USAGE_RUN_STATUSES, USAGE_RUN_TYPES } from './schema.js'
28
28
 
29
- const ATTEMPT_STATUSES = ['ok', 'failed', 'unmetered']
30
- const RUN_STATUSES = ['running', 'success', 'partial', 'failed']
31
29
  const TOKEN_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']
30
+ /** The retry ops a repair pays for, each its own billed attempt. */
31
+ const REPAIR_OPS = ['analysis-repair', 'improve-repair']
32
32
  /** Finished runs kept addressable for `runSummary()` after they leave `live`. */
33
33
  const MAX_REMEMBERED_RUNS = 50
34
34
  const MS_PER_DAY = 24 * 60 * 60 * 1000
35
35
  /** Report defaults for the wire-optional filter fields. */
36
36
  const DEFAULT_RANGE = '30d'
37
37
  const DEFAULT_PAGE_SIZE = 20
38
+ /** Summary day buckets kept. Far past the ~30 days any range reads, so nothing dropped is ever missed. */
39
+ const MAX_SUMMARY_DAYS = 400
38
40
  /** Days each `range` looks back over (`month`/`all` are computed instead). */
39
41
  const RANGE_DAYS = { today: 1, '7d': 7, '30d': 30 }
40
42
  /** Spend at or above this share of the limit is a warning (below the limit itself). */
@@ -42,6 +44,15 @@ const WARN_AT = 0.8
42
44
  /** The one honest claim the cost cards may make: real usage, list-price arithmetic. */
43
45
  const PRICING_LABEL = 'Measured usage · list-price cost'
44
46
 
47
+ /**
48
+ * Two random base36 characters. The clock and the per-tracker sequence alone
49
+ * repeat across trackers in one process, and `upsertDay` keys on the run id,
50
+ * so a repeat silently replaces the earlier run in its day file.
51
+ */
52
+ function idSalt() {
53
+ return Math.floor(Math.random() * 36 * 36).toString(36).padStart(2, '0')
54
+ }
55
+
45
56
  /** A non-negative finite number, or 0. */
46
57
  function count(value) {
47
58
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
@@ -51,12 +62,30 @@ function isPlainObject(value) {
51
62
  return value !== null && typeof value === 'object' && !Array.isArray(value)
52
63
  }
53
64
 
65
+ function isFiniteNumber(value) {
66
+ return typeof value === 'number' && Number.isFinite(value)
67
+ }
68
+
69
+ /**
70
+ * A `usageAttemptSchema.priced` object, or `null` for anything else. The
71
+ * schema is strict, so one malformed pricing result written into a day file
72
+ * would make that whole day unreadable on the way back in.
73
+ */
74
+ function narrowPriced(priced) {
75
+ if (!isPlainObject(priced) || !isPlainObject(priced.rates)) return null
76
+ const { source, tier, asOf, usd, rates } = priced
77
+ if (source !== 'bundled' && source !== 'costMeter') return null
78
+ if (typeof tier !== 'string' || typeof asOf !== 'string' || !isFiniteNumber(usd)) return null
79
+ if (!isFiniteNumber(rates.cacheHit) || !isFiniteNumber(rates.cacheMiss) || !isFiniteNumber(rates.output)) return null
80
+ return { source, tier, rates: { cacheHit: rates.cacheHit, cacheMiss: rates.cacheMiss, output: rates.output }, asOf, usd }
81
+ }
82
+
54
83
  function emptyTokens() {
55
84
  return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }
56
85
  }
57
86
 
58
87
  function emptyTotals() {
59
- return { attempts: 0, billedCalls: 0, unmeteredCalls: 0, unpricedCalls: 0, tokens: emptyTokens(), usdKnown: 0 }
88
+ return { attempts: 0, billedCalls: 0, failedCalls: 0, unmeteredCalls: 0, unpricedCalls: 0, tokens: emptyTokens(), usdKnown: 0, failedUsd: 0 }
60
89
  }
61
90
 
62
91
  function emptyDayTotals() {
@@ -86,9 +115,11 @@ function narrowUsage(usage) {
86
115
  function addTotals(target, delta) {
87
116
  target.attempts += delta.attempts
88
117
  target.billedCalls += delta.billedCalls
118
+ target.failedCalls += delta.failedCalls
89
119
  target.unmeteredCalls += delta.unmeteredCalls
90
120
  target.unpricedCalls += delta.unpricedCalls
91
121
  target.usdKnown += delta.usdKnown
122
+ target.failedUsd += delta.failedUsd
92
123
  for (const key of TOKEN_KEYS) target.tokens[key] += delta.tokens[key]
93
124
  }
94
125
 
@@ -150,7 +181,7 @@ function safeTotals(value) {
150
181
  const source = isPlainObject(value) ? value : {}
151
182
  const out = { ...emptyTotals(), ...source }
152
183
  out.tokens = { ...emptyTokens(), ...(isPlainObject(source.tokens) ? source.tokens : {}) }
153
- for (const key of ['attempts', 'billedCalls', 'unmeteredCalls', 'unpricedCalls', 'usdKnown']) out[key] = count(out[key])
184
+ for (const key of ['attempts', 'billedCalls', 'failedCalls', 'unmeteredCalls', 'unpricedCalls', 'usdKnown', 'failedUsd']) out[key] = count(out[key])
154
185
  for (const key of TOKEN_KEYS) out.tokens[key] = count(out.tokens[key])
155
186
  return out
156
187
  }
@@ -158,14 +189,16 @@ function safeTotals(value) {
158
189
  /** The totals delta one stored attempt contributed — the same arithmetic `recordAttempt` applied live. */
159
190
  function attemptDelta(attempt) {
160
191
  const usage = narrowUsage(attempt.usage)
161
- const priced = isPlainObject(attempt.priced) ? attempt.priced : null
192
+ const priced = narrowPriced(attempt.priced)
162
193
  return {
163
194
  attempts: 1,
164
195
  billedCalls: usage !== null ? 1 : 0,
196
+ failedCalls: usage !== null && attempt.status === 'failed' ? 1 : 0,
165
197
  unmeteredCalls: usage === null ? 1 : 0,
166
198
  unpricedCalls: usage !== null && priced === null ? 1 : 0,
167
199
  tokens: usage ?? emptyTokens(),
168
200
  usdKnown: count(priced?.usd),
201
+ failedUsd: attempt.status === 'failed' ? count(priced?.usd) : 0,
169
202
  }
170
203
  }
171
204
 
@@ -202,22 +235,17 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
202
235
  let seq = 0
203
236
  /** '' until the first prune; then the day key it last ran on. */
204
237
  let lastPruneDay = ''
238
+ /** Unknown run types already warned about — warn once per type, not once per run. */
239
+ const warnedRunTypes = new Set()
205
240
 
241
+ const loaded = store.readUsageSummary()
206
242
  // Reassigned by `clear()`, which reloads the (fresh) summary the store wrote.
207
- let summary = store.readUsageSummary()
208
- if (isFreshSummary(summary)) {
243
+ let summary = loaded.summary
244
+ if (loaded.created) {
209
245
  summary.trackingSince = now()
210
246
  store.writeUsageSummary(summary)
211
247
  }
212
248
 
213
- /** A summary with nothing recorded yet was just created by the store — persist it so `trackingSince` sticks. */
214
- function isFreshSummary(value) {
215
- return count(value?.lifetime?.attempts) === 0
216
- && Object.keys(value?.days ?? {}).length === 0
217
- && Object.keys(value?.byType ?? {}).length === 0
218
- && Object.keys(value?.byModel ?? {}).length === 0
219
- }
220
-
221
249
  /** The day file a run belongs to (its start, so a run never splits across two files). */
222
250
  function runDay(run) {
223
251
  return dayKey(run.startedAt)
@@ -245,22 +273,39 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
245
273
  store.writeUsageDay(day, { version: 1, day, runs: list })
246
274
  }
247
275
 
248
- /** Expire old day files at most once per calendar day. */
276
+ /** Expire old day files and summary day buckets, at most once per calendar day. */
249
277
  function pruneIfNewDay() {
250
278
  const today = dayKey(now())
251
279
  if (today === lastPruneDay) return
252
280
  lastPruneDay = today
253
- const keepDays = count(config?.().costHistoryDays)
254
- store.pruneUsageDays(keepDays > 0 ? keepDays : 30, today)
281
+ store.pruneUsageDays(retentionDays(), today)
282
+ pruneSummaryDays()
283
+ }
284
+
285
+ /** One bucket per calendar day would accumulate forever; nothing reads past `MAX_SUMMARY_DAYS`. */
286
+ function pruneSummaryDays() {
287
+ const oldest = dayKeysEnding(now(), MAX_SUMMARY_DAYS)[0]
288
+ let removed = 0
289
+ for (const day of Object.keys(summary.days ?? {})) {
290
+ if (day >= oldest) continue
291
+ delete summary.days[day]
292
+ removed += 1
293
+ }
294
+ if (removed === 0) return
295
+ summaryDirty = true
296
+ scheduleFlush()
255
297
  }
256
298
 
257
299
  function beginRun({ type, trigger = '', sessionId = '', turn = null, workspace = '', model = '', provider = '' } = {}) {
258
300
  const startedAt = now()
259
- const runId = `u${startedAt.toString(36)}-${(seq++).toString(36)}`
301
+ const runId = `u${startedAt.toString(36)}${idSalt()}-${(seq++).toString(36)}`
260
302
  if (!USAGE_RUN_TYPES.includes(type)) {
261
303
  // Never track a run the day-file schema would reject: one bad row makes
262
304
  // the whole day unreadable. The id stays valid; every sink ignores it.
263
- console.warn(`[tacit] usage: unknown run type ${JSON.stringify(type)}, not tracked`)
305
+ if (!warnedRunTypes.has(type)) {
306
+ warnedRunTypes.add(type)
307
+ console.warn(`[tacit] usage: unknown run type ${JSON.stringify(type)}, not tracked`)
308
+ }
264
309
  return runId
265
310
  }
266
311
  live.set(runId, {
@@ -303,27 +348,31 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
303
348
  provider,
304
349
  reasoningEffort: typeof record.reasoningEffort === 'string' ? record.reasoningEffort : null,
305
350
  finish: typeof record.finish === 'string' ? record.finish : '',
306
- status: ATTEMPT_STATUSES.includes(record.status) ? record.status : (usage === null ? 'unmetered' : 'ok'),
351
+ status: USAGE_ATTEMPT_STATUSES.includes(record.status) ? record.status : (usage === null ? 'unmetered' : 'ok'),
307
352
  code: typeof record.code === 'string' ? record.code : '',
308
353
  sessionId: String(sessionId ?? ''),
309
354
  turn: typeof turn === 'number' && Number.isFinite(turn) ? turn : null,
310
355
  usage,
311
- priced: isPlainObject(priced) ? priced : null,
356
+ priced: narrowPriced(priced),
312
357
  }
313
358
  run.attempts.push(attempt)
314
359
 
315
360
  const delta = {
316
361
  attempts: 1,
317
362
  billedCalls: usage !== null ? 1 : 0,
363
+ failedCalls: usage !== null && attempt.status === 'failed' ? 1 : 0,
318
364
  unmeteredCalls: usage === null ? 1 : 0,
319
365
  unpricedCalls: usage !== null && attempt.priced === null ? 1 : 0,
320
366
  tokens: usage ?? emptyTokens(),
321
367
  usdKnown: count(attempt.priced?.usd),
368
+ failedUsd: attempt.status === 'failed' ? count(attempt.priced?.usd) : 0,
322
369
  }
323
370
  addTotals(run.totals, delta)
324
371
  addTotals(bucketOf(summary, 'lifetime', emptyTotals), delta)
325
372
  addTotals(bucketOf(summary.byType, run.type, emptyTotals), delta)
326
373
  if (model.length > 0) addTotals(bucketOf(summary.byModel, model, emptyTotals), delta)
374
+ if (provider.length > 0) addTotals(bucketOf(summary.byProvider, provider, emptyTotals), delta)
375
+ if (run.trigger.length > 0) addTotals(bucketOf(summary.byTrigger, run.trigger, emptyTotals), delta)
327
376
  const day = bucketOf(summary.days, dayKey(startedAt), emptyDayTotals)
328
377
  if (!isPlainObject(day.byType)) day.byType = {}
329
378
  addTotals(day, delta)
@@ -344,7 +393,7 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
344
393
  const run = live.get(runId)
345
394
  if (run === undefined) return null
346
395
  run.endedAt = now()
347
- run.status = RUN_STATUSES.includes(status) ? status : deriveStatus(run.attempts)
396
+ run.status = USAGE_RUN_STATUSES.includes(status) ? status : deriveStatus(run.attempts)
348
397
  run.results = narrowResults(results)
349
398
  upsertDay(runDay(run), [run])
350
399
  live.delete(runId)
@@ -369,10 +418,12 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
369
418
  status: run.status,
370
419
  attempts: run.totals.attempts,
371
420
  billedCalls: run.totals.billedCalls,
421
+ failedCalls: run.totals.failedCalls,
372
422
  unmeteredCalls: run.totals.unmeteredCalls,
373
423
  unpricedCalls: run.totals.unpricedCalls,
374
424
  tokens: { ...run.totals.tokens },
375
425
  usdKnown: run.totals.usdKnown,
426
+ failedUsd: run.totals.failedUsd,
376
427
  }
377
428
  }
378
429
 
@@ -457,7 +508,7 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
457
508
  }
458
509
 
459
510
  /**
460
- * One period card: the summary's own totals plus the two derived figures
511
+ * One period card: the summary's own totals plus the derived figures
461
512
  * the panel shows. `avgAnalysisUsd` is a median (a single bootstrap batch
462
513
  * must not drag the typical cost of one analysis upwards) over the priced
463
514
  * `analysis` attempts of the day files that were loaded — so it is only as
@@ -465,12 +516,26 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
465
516
  */
466
517
  function periodOf(totals, attempts) {
467
518
  const usds = []
519
+ let repairUsd = 0
520
+ let repairUsdNotFailed = 0
468
521
  for (const attempt of attempts) {
469
- if (attempt.op !== 'analysis') continue
470
522
  if (!isPlainObject(attempt.priced)) continue
471
- usds.push(count(attempt.priced.usd))
523
+ const usd = count(attempt.priced.usd)
524
+ if (attempt.op === 'analysis') usds.push(usd)
525
+ if (!REPAIR_OPS.includes(attempt.op)) continue
526
+ repairUsd += usd
527
+ if (attempt.status !== 'failed') repairUsdNotFailed += usd
528
+ }
529
+ // `failedOrRepairUsd` is the union of "failed" and "repair", counted once.
530
+ // `repairUsd` and `totals.failedUsd` both count a failed repair, so those
531
+ // two halves can sum to more than the union.
532
+ return {
533
+ ...totals,
534
+ avgAnalysisUsd: median(usds),
535
+ cachedInputRate: cachedInputRateOf(totals.tokens),
536
+ repairUsd,
537
+ failedOrRepairUsd: totals.failedUsd + repairUsdNotFailed,
472
538
  }
473
- return { ...totals, avgAnalysisUsd: median(usds), cachedInputRate: cachedInputRateOf(totals.tokens) }
474
539
  }
475
540
 
476
541
  /**
@@ -523,6 +588,18 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
523
588
  }
524
589
  }
525
590
 
591
+ /**
592
+ * A run belongs to a range if any day it billed on falls inside it. The row
593
+ * lives in its start day's file, but a run that crosses midnight bills into
594
+ * the next day's totals, so matching on the start day alone would make the
595
+ * `today` tile and the `today` run list disagree by one run.
596
+ */
597
+ function inRange(run, days) {
598
+ if (days.has(dayKey(count(run.startedAt)))) return true
599
+ if (!Array.isArray(run.attempts)) return false
600
+ return run.attempts.some((attempt) => isPlainObject(attempt) && days.has(dayKey(count(attempt.startedAt))))
601
+ }
602
+
526
603
  /** Every filter is an exact match; an absent filter matches everything. */
527
604
  function matchesFilters(run, filters) {
528
605
  if (filters.type !== undefined && run.type !== filters.type) return false
@@ -559,8 +636,11 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
559
636
  // The run list follows `range`; every fixed-window figure below reads the
560
637
  // union, so a narrow range can never shrink a 30-day breakdown or median.
561
638
  const listKeys = rangeKeys(range, keepDays, available)
639
+ // A run row lives in its start day's file, so the run that crossed midnight
640
+ // into the range is only reachable by opening the day before it too.
641
+ const scanKeys = listKeys.length === 0 ? [] : [...new Set([dayKeyBefore(listKeys[0], 1), ...listKeys])]
562
642
  const detailKeys = [...new Set([
563
- ...listKeys,
643
+ ...scanKeys,
564
644
  ...rangeKeys('30d', keepDays, available),
565
645
  ...rangeKeys('month', keepDays, available),
566
646
  ])].sort()
@@ -590,10 +670,12 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
590
670
  const monthPrefix = dayKey(now()).slice(0, 7)
591
671
  const monthKeys = Object.keys(summary.days ?? {}).filter((day) => day.startsWith(monthPrefix)).sort()
592
672
 
593
- // byType comes straight from the summary's own per-day buckets; byModel is
594
- // folded from the loaded attempts because the day buckets carry no model
595
- // split. Both cover the same last-30-days window, whatever `range` asked
596
- // for — the detail window above always includes those 30 days.
673
+ // byType comes straight from the summary's own per-day buckets; byModel,
674
+ // byProvider and byTrigger are folded from the loaded runs because the day
675
+ // buckets carry none of those three splits. Model and provider come off the
676
+ // attempt, the trigger off the run around it. All four cover the same
677
+ // last-30-days window, whatever `range` asked for — the detail window above
678
+ // always includes those 30 days.
597
679
  const byType = {}
598
680
  for (const day of last30Keys) {
599
681
  const buckets = summary.days?.[day]?.byType
@@ -601,10 +683,23 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
601
683
  for (const [type, totals] of Object.entries(buckets)) addTotals(bucketOf(byType, type, emptyTotals), safeTotals(totals))
602
684
  }
603
685
  const byModel = {}
604
- for (const attempt of attemptsIn(last30Keys)) {
605
- const model = typeof attempt.model === 'string' ? attempt.model : ''
606
- if (model.length === 0) continue
607
- addTotals(bucketOf(byModel, model, emptyTotals), attemptDelta(attempt))
686
+ const byProvider = {}
687
+ const byTrigger = {}
688
+ const last30Days = new Set(last30Keys)
689
+ for (const dayRuns of loaded.values()) {
690
+ for (const run of dayRuns) {
691
+ if (!Array.isArray(run.attempts)) continue
692
+ const trigger = typeof run.trigger === 'string' ? run.trigger : ''
693
+ for (const attempt of run.attempts) {
694
+ if (!isPlainObject(attempt) || !last30Days.has(dayKey(count(attempt.startedAt)))) continue
695
+ const delta = attemptDelta(attempt)
696
+ const model = typeof attempt.model === 'string' ? attempt.model : ''
697
+ const provider = typeof attempt.provider === 'string' ? attempt.provider : ''
698
+ if (model.length > 0) addTotals(bucketOf(byModel, model, emptyTotals), delta)
699
+ if (provider.length > 0) addTotals(bucketOf(byProvider, provider, emptyTotals), delta)
700
+ if (trigger.length > 0) addTotals(bucketOf(byTrigger, trigger, emptyTotals), delta)
701
+ }
702
+ }
608
703
  }
609
704
 
610
705
  const today = periodOf(totalsOver(todayKeys), attemptsIn(todayKeys))
@@ -612,10 +707,11 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
612
707
  const dailyLimit = count(effective?.costWarnDailyUsd)
613
708
  const monthlyLimit = count(effective?.costWarnMonthlyUsd)
614
709
 
710
+ const listDays = new Set(listKeys)
615
711
  const listRuns = []
616
- for (const day of listKeys) listRuns.push(...(loaded.get(day) ?? []))
712
+ for (const day of scanKeys) listRuns.push(...(loaded.get(day) ?? []))
617
713
  const matched = listRuns
618
- .filter((run) => matchesFilters(run, filters))
714
+ .filter((run) => inRange(run, listDays) && matchesFilters(run, filters))
619
715
  .sort((a, b) => count(b.startedAt) - count(a.startedAt))
620
716
  const from = (page - 1) * pageSize
621
717
 
@@ -630,6 +726,8 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
630
726
  lifetime: periodOf(safeTotals(summary.lifetime), attempts),
631
727
  byType,
632
728
  byModel,
729
+ byProvider,
730
+ byTrigger,
633
731
  series7: seriesOf(7),
634
732
  series30: seriesOf(30),
635
733
  warnings: {
@@ -683,7 +781,7 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
683
781
  summaryDirty = false
684
782
  lastPruneDay = ''
685
783
  const { removed } = store.clearUsage()
686
- summary = store.readUsageSummary()
784
+ summary = store.readUsageSummary().summary
687
785
  // The store stamps the new window with its own `Date.now()`; the tracker's
688
786
  // injected clock is the one every other timestamp here comes from.
689
787
  summary.trackingSince = now()
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * The identity of a workspace: the absolute directory with `..` resolved, a
6
+ * trailing separator stripped and symlinks followed when it exists on disk.
7
+ * Case is left alone. '' when there is no directory.
8
+ */
9
+ export function normalizeWorkspace(raw) {
10
+ if (typeof raw !== 'string' || raw.length === 0) return ''
11
+ const resolved = path.resolve(raw)
12
+ try {
13
+ return fs.realpathSync(resolved)
14
+ } catch {
15
+ return resolved
16
+ }
17
+ }
18
+
19
+ /** Whether `cwd` is `scope` itself or a directory inside it — by path segment, never by string prefix. */
20
+ export function workspaceContains(scope, cwd) {
21
+ if (typeof scope !== 'string' || scope.length === 0 || typeof cwd !== 'string') return false
22
+ if (cwd === scope) return true
23
+ const base = /[\\/]$/.test(scope) ? scope.slice(0, -1) : scope
24
+ return cwd.startsWith(base + '/') || cwd.startsWith(base + '\\')
25
+ }
26
+
27
+ /** The last path segment of a workspace directory — what a person calls the project. */
28
+ export function workspaceLabel(cwd) {
29
+ if (typeof cwd !== 'string' || cwd.length === 0) return ''
30
+ const parts = cwd.split(/[\\/]+/).filter((part) => part.length > 0)
31
+ return parts.length > 0 ? parts[parts.length - 1] : ''
32
+ }
33
+
34
+ /**
35
+ * A distinct label per workspace, in insertion order: the basename, extended by
36
+ * one more parent segment at a time (`a/web`, `b/web`) while two of them would
37
+ * read the same, up to the full path.
38
+ */
39
+ export function workspaceLabels(cwds) {
40
+ const distinct = [...new Set((Array.isArray(cwds) ? cwds : []).filter((cwd) => typeof cwd === 'string' && cwd.length > 0))]
41
+ const segments = new Map(distinct.map((cwd) => [cwd, cwd.split(/[\\/]+/).filter((part) => part.length > 0)]))
42
+ const depth = new Map(distinct.map((cwd) => [cwd, 1]))
43
+ const labelOf = (cwd) => segments.get(cwd).slice(-depth.get(cwd)).join('/')
44
+ for (;;) {
45
+ const groups = new Map()
46
+ for (const cwd of distinct) {
47
+ const label = labelOf(cwd)
48
+ if (groups.has(label)) groups.get(label).push(cwd)
49
+ else groups.set(label, [cwd])
50
+ }
51
+ let grew = false
52
+ for (const group of groups.values()) {
53
+ if (group.length < 2) continue
54
+ for (const cwd of group) {
55
+ if (depth.get(cwd) >= segments.get(cwd).length) continue
56
+ depth.set(cwd, depth.get(cwd) + 1)
57
+ grew = true
58
+ }
59
+ }
60
+ if (!grew) return new Map(distinct.map((cwd) => [cwd, labelOf(cwd)]))
61
+ }
62
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tacit",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Tacit learns what you leave unsaid in your prompts — from messy turns and your own corrections, with zero clicks — and tells the agent how to compensate, on every turn, via a system-prompt section you can read and edit.",
5
5
  "author": "hackernotfound",
6
6
  "license": "MIT",