dsh-tacit 0.3.0 → 0.4.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/service.js CHANGED
@@ -80,12 +80,13 @@ import {
80
80
  DIRECTIVE_TOOL,
81
81
  DIRECTIVE_MAX_TOKENS,
82
82
  DIRECTIVE_TIMEOUT_MS,
83
- MAX_DIRECTIVES,
84
83
  buildDirectiveUserText,
85
84
  buildSteeringSection,
86
85
  renderSteeringSection,
87
86
  workspaceLabel,
88
- MAX_WORKSPACE_DIRECTIVES,
87
+ scopeOf,
88
+ capDirectives,
89
+ mergeDirectives,
89
90
  ENRICH_SYSTEM_PROMPT,
90
91
  ENRICH_TOOL,
91
92
  ENRICH_MAX_TOKENS,
@@ -96,6 +97,7 @@ import {
96
97
  buildEnrichUserText,
97
98
  normalizeEnrichNote,
98
99
  computeTrend,
100
+ markCorrections,
99
101
  } from './analyze.js'
100
102
 
101
103
  /** In-memory rewrite ledger bounds (never persisted). */
@@ -185,21 +187,6 @@ function listWorkspaces(service) {
185
187
  return [...seen.values()].sort((a, b) => a.label.localeCompare(b.label))
186
188
  }
187
189
 
188
- /** At most MAX_DIRECTIVES global directives and MAX_WORKSPACE_DIRECTIVES per workspace, order kept. */
189
- function capDirectives(list) {
190
- const counts = new Map()
191
- const out = []
192
- for (const entry of list) {
193
- const scope = typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : ''
194
- const limit = scope === '' ? MAX_DIRECTIVES : MAX_WORKSPACE_DIRECTIVES
195
- const n = counts.get(scope) ?? 0
196
- if (n >= limit) continue
197
- counts.set(scope, n + 1)
198
- out.push(entry)
199
- }
200
- return out
201
- }
202
-
203
190
  /** Short, secret-free context digest of a session's last two finished turns. */
204
191
  function recentContextOf(turns) {
205
192
  const finished = (Array.isArray(turns) ? turns : []).filter((turn) => turn?.finished === true).slice(-2)
@@ -265,6 +252,9 @@ function coachErrorCode(error) {
265
252
  const text = raw + ' ' + message
266
253
  if (/abort|timeout/i.test(text)) return 'timeout'
267
254
  if (/auth|401|403|api[ _-]?key|key not/i.test(text)) return 'no-api-key'
255
+ // Ahead of the rate-limit rule: an exhausted balance says "quota" too, but
256
+ // it will not clear by waiting, so it must not read as "try again shortly".
257
+ if (/\binsufficient[ _-]?(quota|balance|credit)|\bexceeded your current quota/i.test(text)) return 'no-credit'
268
258
  // Word-bounded: a bare /rate/ matches the "rate" inside "generate", and the
269
259
  // ladder now sees the raw code and message of every provider failure. A
270
260
  // trailing \b after "quota" would not do here — `_` is a word character, so
@@ -437,26 +427,32 @@ export function createCoachService(ctx, store, effectiveConfig) {
437
427
  handleVerification(sessionId, turns)
438
428
  }
439
429
 
440
- /** Finished turns already counted toward directive trials (sessionId:turn). */
430
+ /** Finished turns already counted toward directive trials, and turns already counted as corrected (sessionId:turn). */
441
431
  const seenFinished = new Set()
432
+ const seenCorrected = new Set()
442
433
 
443
434
  const pct = (rate) => String(Math.round(rate * 100)) + '%'
444
435
 
445
436
  /**
446
437
  * Directive trials ride the same free feed: every NEW finished turn counts
447
438
  * toward each candidate that was actually in that session's frozen steering
448
- * text; after `directiveTrialTurns` such turns the candidate is activated,
449
- * or retired when the messy rate rose past the baseline by more than
450
- * `directiveWorseBy`. A session whose steering was never assembled here
451
- * (started before the candidate existed, or before a restart) counts toward
452
- * nobody its turns say nothing about the candidate.
439
+ * text, and so does every correction of such a turn (the session's next
440
+ * prompt, known the moment it starts). After `directiveTrialTurns` turns the
441
+ * candidate is retired when its correction rate rose past the baseline by
442
+ * more than `directiveWorseBy` (or its messy rate by twice that), otherwise
443
+ * activated. A session whose steering was never assembled here (started
444
+ * before the candidate existed, or before a restart) counts toward nobody —
445
+ * its turns say nothing about the candidate.
453
446
  */
454
447
  const recordTrialTurns = (sessionId, turns) => {
455
- const fresh = (Array.isArray(turns) ? turns : []).filter((turn) => turn !== null && typeof turn === 'object'
456
- && turn.finished === true && typeof turn.turn === 'number' && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt
457
- && !seenFinished.has(sessionId + ':' + turn.turn))
458
- if (fresh.length === 0) return
459
- for (const turn of fresh) seenFinished.add(sessionId + ':' + turn.turn)
448
+ const key = (turn) => sessionId + ':' + turn.turn
449
+ const counted = markCorrections(turns).filter((turn) => typeof turn.turn === 'number' && turn.finished === true
450
+ && typeof turn.endedAt === 'number' && turn.endedAt >= pluginStartedAt)
451
+ const fresh = counted.filter((turn) => !seenFinished.has(key(turn)))
452
+ const corrected = counted.filter((turn) => turn.corrected && !seenCorrected.has(key(turn)))
453
+ for (const turn of fresh) seenFinished.add(key(turn))
454
+ for (const turn of corrected) seenCorrected.add(key(turn))
455
+ if (fresh.length === 0 && corrected.length === 0) return
460
456
  const steered = steeringIdsBySession.get(sessionId)
461
457
  if (steered === undefined || steered.length === 0) return
462
458
  const profile = safeProfile()
@@ -464,21 +460,33 @@ export function createCoachService(ctx, store, effectiveConfig) {
464
460
  if (candidates.length === 0) return
465
461
  const config = effectiveConfig()
466
462
  const messyCount = fresh.filter((turn) => isMessyTurn(turn, { minSteps: Number.POSITIVE_INFINITY })).length
463
+ let verdicts = 0
467
464
  for (const entry of candidates) {
468
- entry.trial.turns += fresh.length
469
- entry.trial.messy += messyCount
470
- if (entry.trial.turns < config.directiveTrialTurns) continue
471
- const rate = entry.trial.messy / entry.trial.turns
472
- if (rate > entry.trial.baselineRate + config.directiveWorseBy) {
465
+ const trial = entry.trial
466
+ if (trial.baselineCorrectionRate < 0) trial.baselineCorrectionRate = baselinesFor(scopeOf(entry)).baselineCorrectionRate
467
+ trial.turns += fresh.length
468
+ trial.messy += messyCount
469
+ trial.corrected += corrected.length
470
+ if (trial.turns < config.directiveTrialTurns) continue
471
+ const correctionRate = trial.corrected / trial.turns
472
+ const messyRate = trial.messy / trial.turns
473
+ const worse = correctionRate > trial.baselineCorrectionRate + config.directiveWorseBy
474
+ ? 'corrections ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate)
475
+ : messyRate > trial.baselineMessyRate + 2 * config.directiveWorseBy
476
+ ? 'messy turns ' + pct(trial.baselineMessyRate) + ' → ' + pct(messyRate)
477
+ : null
478
+ verdicts += 1
479
+ if (worse !== null) {
473
480
  entry.status = 'retired'
474
481
  entry.enabled = false
475
- entry.retiredReason = 'messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + ' while active'
482
+ entry.retiredReason = worse + ' while active'
476
483
  console.info('[tacit] retired directive (' + entry.retiredReason + '): ' + entry.text)
477
484
  } else {
478
485
  entry.status = 'active'
479
- console.info('[tacit] activated directive (messy turns ' + pct(entry.trial.baselineRate) + ' → ' + pct(rate) + '): ' + entry.text)
486
+ console.info('[tacit] activated directive (corrections ' + pct(trial.baselineCorrectionRate) + ' → ' + pct(correctionRate) + '): ' + entry.text)
480
487
  }
481
488
  }
489
+ if (verdicts > 0) startNextTrial(profile)
482
490
  capAndSaveProfile(profile)
483
491
  }
484
492
 
@@ -689,64 +697,28 @@ export function createCoachService(ctx, store, effectiveConfig) {
689
697
  return frozen.text
690
698
  }
691
699
 
692
- /**
693
- * Replace the distilled directives with the model's new complete set; user
694
- * entries are untouched, and a re-emitted directive keeps the enabled flag
695
- * the user gave its identical text. Capped at MAX_DIRECTIVES overall.
696
- */
697
- const scopeOf = (entry) => (typeof entry.workspace === 'string' && entry.workspace.length > 0 ? entry.workspace : '')
698
- const directiveKey = (scope, text) => scope + '\n' + text.trim().toLowerCase()
699
-
700
- /** Messy-turn baseline for a new candidate: the workspace's own turns when there are enough, else everything. */
701
- const baselineRateFor = (cwd) => {
702
- const scoped = cwd !== undefined ? allFinishedTurns({ cwd }) : []
700
+ /** Baselines for a new trial: the workspace's own turns when there are enough, else everything. */
701
+ const baselinesFor = (scope) => {
702
+ const scoped = scope !== '' ? allFinishedTurns({ cwd: scope }) : []
703
703
  const turns = scoped.length >= 20 ? scoped : allFinishedTurns()
704
- return computeTrend(turns, { window: 20 }).recent.messyRate
704
+ const { messyRate, correctionRate } = computeTrend(turns, { window: 20 }).recent
705
+ return { baselineMessyRate: messyRate, baselineCorrectionRate: correctionRate }
705
706
  }
706
707
 
707
708
  /**
708
- * Merge the model's new complete set of directives ({ text, workspace? }):
709
- * user entries are untouched; the global distilled set and the distilled set
710
- * of every workspace the model mentioned are replaced; distilled entries of
711
- * other workspaces are kept (their evidence was not in this batch). A
712
- * re-emitted directive keeps its identity, state and enabled flag.
709
+ * One trial per scope at a time: wherever no candidate is on trial, the
710
+ * oldest enabled queued directive of that scope starts its trial now, with
711
+ * baselines measured at this moment. Idempotent.
713
712
  */
714
- const mergeDirectives = (profile, items) => {
715
- const users = profile.directives.filter((entry) => entry.source === 'user')
716
- const userKeys = new Set(users.map((entry) => directiveKey(scopeOf(entry), entry.text)))
717
- const prior = profile.directives.filter((entry) => entry.source !== 'user')
718
- const previous = new Map(prior.map((entry) => [directiveKey(scopeOf(entry), entry.text), entry]))
719
- const mentioned = new Set([''])
720
- for (const item of items) mentioned.add(scopeOf(item))
721
- const untouched = prior.filter((entry) => !mentioned.has(scopeOf(entry)))
722
- const distilled = []
723
- const seen = new Set()
724
- const baselines = new Map()
725
- for (const item of items) {
726
- const scope = scopeOf(item)
727
- const key = directiveKey(scope, item.text)
728
- if (seen.has(key) || userKeys.has(key)) continue
729
- seen.add(key)
730
- const kept = previous.get(key)
731
- if (kept !== undefined) {
732
- distilled.push({ ...kept, text: item.text })
733
- continue
734
- }
735
- // A new distilled directive goes on trial against the current messy-turn rate.
736
- if (!baselines.has(scope)) baselines.set(scope, baselineRateFor(scope === '' ? undefined : scope))
737
- distilled.push({
738
- id: nextDirectiveId(),
739
- text: item.text,
740
- enabled: true,
741
- source: 'distilled',
742
- createdAt: Date.now(),
743
- status: 'candidate',
744
- trial: { turns: 0, messy: 0, baselineRate: baselines.get(scope), startedAt: Date.now() },
745
- ...(scope === '' ? {} : { workspace: scope }),
746
- })
713
+ const startNextTrial = (profile) => {
714
+ const busy = new Set(profile.directives.filter((entry) => entry.status === 'candidate').map(scopeOf))
715
+ for (const entry of profile.directives) {
716
+ if (entry.status !== 'queued' || entry.enabled === false || busy.has(scopeOf(entry))) continue
717
+ busy.add(scopeOf(entry))
718
+ entry.status = 'candidate'
719
+ entry.trial = { turns: 0, messy: 0, corrected: 0, ...baselinesFor(scopeOf(entry)), startedAt: Date.now() }
720
+ console.info('[tacit] directive on trial: ' + entry.text)
747
721
  }
748
- profile.directives = capDirectives([...users, ...distilled, ...untouched])
749
- return profile
750
722
  }
751
723
 
752
724
  /** ONE small call every `directiveEvery` new analyses (or forced). Soft-fails; never throws. */
@@ -785,10 +757,13 @@ export function createCoachService(ctx, store, effectiveConfig) {
785
757
  console.warn('[tacit] directive distillation returned nothing usable; will retry after the next analysis:', clipSafe(text, 300))
786
758
  return
787
759
  }
788
- const items = kept.map((item) => (item.workspace !== undefined && workspaces.has(item.workspace)
789
- ? { text: item.text, workspace: workspaces.get(item.workspace) }
790
- : { text: item.text }))
791
- profile = mergeDirectives(safeProfile(), items)
760
+ const items = kept.map((item) => ({
761
+ text: item.text,
762
+ ...(item.id === undefined ? {} : { id: item.id }),
763
+ ...(item.workspace !== undefined && workspaces.has(item.workspace) ? { workspace: workspaces.get(item.workspace) } : {}),
764
+ }))
765
+ profile = mergeDirectives(safeProfile(), items, { nextId: nextDirectiveId })
766
+ startNextTrial(profile)
792
767
  profile.analysesSinceDirectives = 0
793
768
  capAndSaveProfile(profile)
794
769
  const scoped = items.filter((item) => item.workspace !== undefined).length
@@ -858,7 +833,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
858
833
  if (session === null || typeof session !== 'object' || typeof session.id !== 'string') continue
859
834
  if (cwd !== undefined && cwdOf(session) !== cwd) continue
860
835
  const { turns } = turnsOf(svc, session.id)
861
- for (const turn of turns) if (turn?.finished === true) out.push(turn)
836
+ for (const turn of markCorrections(turns)) if (turn.finished === true) out.push(turn)
862
837
  }
863
838
  return out
864
839
  }
@@ -868,6 +843,19 @@ export function createCoachService(ctx, store, effectiveConfig) {
868
843
  * deliberately not named `tokens`, which everywhere else in the ledger is the five-bucket object. */
869
844
  const bootstrapState = { running: false, done: 0, total: 0, startedAt: 0, runId: '', billedCalls: 0, unpricedCalls: 0, usdKnown: 0, tokensTotal: 0 }
870
845
 
846
+ /** Back to "no bootstrap has run": every field, so a no-op never shows the previous batch's figures. */
847
+ const resetBootstrapState = () => {
848
+ bootstrapState.running = false
849
+ bootstrapState.done = 0
850
+ bootstrapState.total = 0
851
+ bootstrapState.startedAt = 0
852
+ bootstrapState.runId = ''
853
+ bootstrapState.billedCalls = 0
854
+ bootstrapState.unpricedCalls = 0
855
+ bootstrapState.usdKnown = 0
856
+ bootstrapState.tokensTotal = 0
857
+ }
858
+
871
859
  /** Mirror the bootstrap run's live counters into the state the panel polls. */
872
860
  const refreshBootstrapUsage = () => {
873
861
  const summary = bootstrapState.runId === '' ? null : usage.runSummary(bootstrapState.runId)
@@ -967,6 +955,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
967
955
  // Nothing to analyze is a no-op, not a run: an empty run would be written
968
956
  // to the ledger as `failed` (no attempts) and read as a broken bootstrap.
969
957
  if (eligible.length === 0) {
958
+ resetBootstrapState()
970
959
  return { ok: true, analyzed: 0, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: null }
971
960
  }
972
961
  const config = effectiveConfig()
@@ -980,15 +969,11 @@ export function createCoachService(ctx, store, effectiveConfig) {
980
969
  model: config.model,
981
970
  provider: scopedToSession ? providerForSession(sessionId) : COACH_PROVIDER,
982
971
  })
972
+ resetBootstrapState()
983
973
  bootstrapState.running = true
984
- bootstrapState.done = 0
985
974
  bootstrapState.total = eligible.length
986
975
  bootstrapState.startedAt = Date.now()
987
976
  bootstrapState.runId = runId
988
- bootstrapState.billedCalls = 0
989
- bootstrapState.unpricedCalls = 0
990
- bootstrapState.usdKnown = 0
991
- bootstrapState.tokensTotal = 0
992
977
  let analyzed = 0
993
978
  try {
994
979
  // A small worker pool: each worker pulls the next eligible turn until the
@@ -1019,6 +1004,9 @@ export function createCoachService(ctx, store, effectiveConfig) {
1019
1004
  bootstrapState.running = false
1020
1005
  closeRun(runId, { requested: limit, eligible: eligible.length, analyzed, skipped, directives: safeProfile().directives.length })
1021
1006
  refreshBootstrapUsage()
1007
+ // Mirror the final counters first, then drop the id: the tile keeps the
1008
+ // finished run's figures, but nothing may still call it the live run.
1009
+ bootstrapState.runId = ''
1022
1010
  }
1023
1011
  return { ok: true, analyzed, skipped, directives: safeProfile().directives.length, code: '', detail: '', run: usage.runSummary(runId) }
1024
1012
  }
@@ -1390,6 +1378,7 @@ export function createCoachService(ctx, store, effectiveConfig) {
1390
1378
  } else {
1391
1379
  profile.directives = profile.directives.filter((entry) => entry.id !== input.id)
1392
1380
  }
1381
+ startNextTrial(profile)
1393
1382
  const saved = capAndSaveProfile(profile)
1394
1383
  return { ok: true, profile: saved, steering: steeringStatus(), code: '', detail: '' }
1395
1384
  },
package/lib/store.js CHANGED
@@ -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)))
@@ -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,11 +23,9 @@
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']
32
30
  /** Finished runs kept addressable for `runSummary()` after they leave `live`. */
33
31
  const MAX_REMEMBERED_RUNS = 50
@@ -35,6 +33,8 @@ const MS_PER_DAY = 24 * 60 * 60 * 1000
35
33
  /** Report defaults for the wire-optional filter fields. */
36
34
  const DEFAULT_RANGE = '30d'
37
35
  const DEFAULT_PAGE_SIZE = 20
36
+ /** Summary day buckets kept. Far past the ~30 days any range reads, so nothing dropped is ever missed. */
37
+ const MAX_SUMMARY_DAYS = 400
38
38
  /** Days each `range` looks back over (`month`/`all` are computed instead). */
39
39
  const RANGE_DAYS = { today: 1, '7d': 7, '30d': 30 }
40
40
  /** Spend at or above this share of the limit is a warning (below the limit itself). */
@@ -42,6 +42,15 @@ const WARN_AT = 0.8
42
42
  /** The one honest claim the cost cards may make: real usage, list-price arithmetic. */
43
43
  const PRICING_LABEL = 'Measured usage · list-price cost'
44
44
 
45
+ /**
46
+ * Two random base36 characters. The clock and the per-tracker sequence alone
47
+ * repeat across trackers in one process, and `upsertDay` keys on the run id,
48
+ * so a repeat silently replaces the earlier run in its day file.
49
+ */
50
+ function idSalt() {
51
+ return Math.floor(Math.random() * 36 * 36).toString(36).padStart(2, '0')
52
+ }
53
+
45
54
  /** A non-negative finite number, or 0. */
46
55
  function count(value) {
47
56
  return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
@@ -51,6 +60,24 @@ function isPlainObject(value) {
51
60
  return value !== null && typeof value === 'object' && !Array.isArray(value)
52
61
  }
53
62
 
63
+ function isFiniteNumber(value) {
64
+ return typeof value === 'number' && Number.isFinite(value)
65
+ }
66
+
67
+ /**
68
+ * A `usageAttemptSchema.priced` object, or `null` for anything else. The
69
+ * schema is strict, so one malformed pricing result written into a day file
70
+ * would make that whole day unreadable on the way back in.
71
+ */
72
+ function narrowPriced(priced) {
73
+ if (!isPlainObject(priced) || !isPlainObject(priced.rates)) return null
74
+ const { source, tier, asOf, usd, rates } = priced
75
+ if (source !== 'bundled' && source !== 'costMeter') return null
76
+ if (typeof tier !== 'string' || typeof asOf !== 'string' || !isFiniteNumber(usd)) return null
77
+ if (!isFiniteNumber(rates.cacheHit) || !isFiniteNumber(rates.cacheMiss) || !isFiniteNumber(rates.output)) return null
78
+ return { source, tier, rates: { cacheHit: rates.cacheHit, cacheMiss: rates.cacheMiss, output: rates.output }, asOf, usd }
79
+ }
80
+
54
81
  function emptyTokens() {
55
82
  return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 }
56
83
  }
@@ -158,7 +185,7 @@ function safeTotals(value) {
158
185
  /** The totals delta one stored attempt contributed — the same arithmetic `recordAttempt` applied live. */
159
186
  function attemptDelta(attempt) {
160
187
  const usage = narrowUsage(attempt.usage)
161
- const priced = isPlainObject(attempt.priced) ? attempt.priced : null
188
+ const priced = narrowPriced(attempt.priced)
162
189
  return {
163
190
  attempts: 1,
164
191
  billedCalls: usage !== null ? 1 : 0,
@@ -202,22 +229,17 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
202
229
  let seq = 0
203
230
  /** '' until the first prune; then the day key it last ran on. */
204
231
  let lastPruneDay = ''
232
+ /** Unknown run types already warned about — warn once per type, not once per run. */
233
+ const warnedRunTypes = new Set()
205
234
 
235
+ const loaded = store.readUsageSummary()
206
236
  // Reassigned by `clear()`, which reloads the (fresh) summary the store wrote.
207
- let summary = store.readUsageSummary()
208
- if (isFreshSummary(summary)) {
237
+ let summary = loaded.summary
238
+ if (loaded.created) {
209
239
  summary.trackingSince = now()
210
240
  store.writeUsageSummary(summary)
211
241
  }
212
242
 
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
243
  /** The day file a run belongs to (its start, so a run never splits across two files). */
222
244
  function runDay(run) {
223
245
  return dayKey(run.startedAt)
@@ -245,22 +267,39 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
245
267
  store.writeUsageDay(day, { version: 1, day, runs: list })
246
268
  }
247
269
 
248
- /** Expire old day files at most once per calendar day. */
270
+ /** Expire old day files and summary day buckets, at most once per calendar day. */
249
271
  function pruneIfNewDay() {
250
272
  const today = dayKey(now())
251
273
  if (today === lastPruneDay) return
252
274
  lastPruneDay = today
253
- const keepDays = count(config?.().costHistoryDays)
254
- store.pruneUsageDays(keepDays > 0 ? keepDays : 30, today)
275
+ store.pruneUsageDays(retentionDays(), today)
276
+ pruneSummaryDays()
277
+ }
278
+
279
+ /** One bucket per calendar day would accumulate forever; nothing reads past `MAX_SUMMARY_DAYS`. */
280
+ function pruneSummaryDays() {
281
+ const oldest = dayKeysEnding(now(), MAX_SUMMARY_DAYS)[0]
282
+ let removed = 0
283
+ for (const day of Object.keys(summary.days ?? {})) {
284
+ if (day >= oldest) continue
285
+ delete summary.days[day]
286
+ removed += 1
287
+ }
288
+ if (removed === 0) return
289
+ summaryDirty = true
290
+ scheduleFlush()
255
291
  }
256
292
 
257
293
  function beginRun({ type, trigger = '', sessionId = '', turn = null, workspace = '', model = '', provider = '' } = {}) {
258
294
  const startedAt = now()
259
- const runId = `u${startedAt.toString(36)}-${(seq++).toString(36)}`
295
+ const runId = `u${startedAt.toString(36)}${idSalt()}-${(seq++).toString(36)}`
260
296
  if (!USAGE_RUN_TYPES.includes(type)) {
261
297
  // Never track a run the day-file schema would reject: one bad row makes
262
298
  // 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`)
299
+ if (!warnedRunTypes.has(type)) {
300
+ warnedRunTypes.add(type)
301
+ console.warn(`[tacit] usage: unknown run type ${JSON.stringify(type)}, not tracked`)
302
+ }
264
303
  return runId
265
304
  }
266
305
  live.set(runId, {
@@ -303,12 +342,12 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
303
342
  provider,
304
343
  reasoningEffort: typeof record.reasoningEffort === 'string' ? record.reasoningEffort : null,
305
344
  finish: typeof record.finish === 'string' ? record.finish : '',
306
- status: ATTEMPT_STATUSES.includes(record.status) ? record.status : (usage === null ? 'unmetered' : 'ok'),
345
+ status: USAGE_ATTEMPT_STATUSES.includes(record.status) ? record.status : (usage === null ? 'unmetered' : 'ok'),
307
346
  code: typeof record.code === 'string' ? record.code : '',
308
347
  sessionId: String(sessionId ?? ''),
309
348
  turn: typeof turn === 'number' && Number.isFinite(turn) ? turn : null,
310
349
  usage,
311
- priced: isPlainObject(priced) ? priced : null,
350
+ priced: narrowPriced(priced),
312
351
  }
313
352
  run.attempts.push(attempt)
314
353
 
@@ -344,7 +383,7 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
344
383
  const run = live.get(runId)
345
384
  if (run === undefined) return null
346
385
  run.endedAt = now()
347
- run.status = RUN_STATUSES.includes(status) ? status : deriveStatus(run.attempts)
386
+ run.status = USAGE_RUN_STATUSES.includes(status) ? status : deriveStatus(run.attempts)
348
387
  run.results = narrowResults(results)
349
388
  upsertDay(runDay(run), [run])
350
389
  live.delete(runId)
@@ -523,6 +562,18 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
523
562
  }
524
563
  }
525
564
 
565
+ /**
566
+ * A run belongs to a range if any day it billed on falls inside it. The row
567
+ * lives in its start day's file, but a run that crosses midnight bills into
568
+ * the next day's totals, so matching on the start day alone would make the
569
+ * `today` tile and the `today` run list disagree by one run.
570
+ */
571
+ function inRange(run, days) {
572
+ if (days.has(dayKey(count(run.startedAt)))) return true
573
+ if (!Array.isArray(run.attempts)) return false
574
+ return run.attempts.some((attempt) => isPlainObject(attempt) && days.has(dayKey(count(attempt.startedAt))))
575
+ }
576
+
526
577
  /** Every filter is an exact match; an absent filter matches everything. */
527
578
  function matchesFilters(run, filters) {
528
579
  if (filters.type !== undefined && run.type !== filters.type) return false
@@ -559,8 +610,11 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
559
610
  // The run list follows `range`; every fixed-window figure below reads the
560
611
  // union, so a narrow range can never shrink a 30-day breakdown or median.
561
612
  const listKeys = rangeKeys(range, keepDays, available)
613
+ // A run row lives in its start day's file, so the run that crossed midnight
614
+ // into the range is only reachable by opening the day before it too.
615
+ const scanKeys = listKeys.length === 0 ? [] : [...new Set([dayKeyBefore(listKeys[0], 1), ...listKeys])]
562
616
  const detailKeys = [...new Set([
563
- ...listKeys,
617
+ ...scanKeys,
564
618
  ...rangeKeys('30d', keepDays, available),
565
619
  ...rangeKeys('month', keepDays, available),
566
620
  ])].sort()
@@ -612,10 +666,11 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
612
666
  const dailyLimit = count(effective?.costWarnDailyUsd)
613
667
  const monthlyLimit = count(effective?.costWarnMonthlyUsd)
614
668
 
669
+ const listDays = new Set(listKeys)
615
670
  const listRuns = []
616
- for (const day of listKeys) listRuns.push(...(loaded.get(day) ?? []))
671
+ for (const day of scanKeys) listRuns.push(...(loaded.get(day) ?? []))
617
672
  const matched = listRuns
618
- .filter((run) => matchesFilters(run, filters))
673
+ .filter((run) => inRange(run, listDays) && matchesFilters(run, filters))
619
674
  .sort((a, b) => count(b.startedAt) - count(a.startedAt))
620
675
  const from = (page - 1) * pageSize
621
676
 
@@ -683,7 +738,7 @@ export function createUsageTracker({ store, config, pricing, now = Date.now, flu
683
738
  summaryDirty = false
684
739
  lastPruneDay = ''
685
740
  const { removed } = store.clearUsage()
686
- summary = store.readUsageSummary()
741
+ summary = store.readUsageSummary().summary
687
742
  // The store stamps the new window with its own `Date.now()`; the tracker's
688
743
  // injected clock is the one every other timestamp here comes from.
689
744
  summary.trackingSince = now()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tacit",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",