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/README.md +4 -4
- package/client/client.js +75 -29
- package/lib/analyze.js +100 -13
- package/lib/index.js +1 -1
- package/lib/schema.js +29 -15
- package/lib/service.js +86 -97
- package/lib/store.js +64 -3
- package/lib/usage.js +82 -27
- package/package.json +1 -1
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
|
-
|
|
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
|
|
449
|
-
*
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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
|
|
456
|
-
|
|
457
|
-
&&
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
694
|
-
|
|
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
|
-
|
|
704
|
+
const { messyRate, correctionRate } = computeTrend(turns, { window: 20 }).recent
|
|
705
|
+
return { baselineMessyRate: messyRate, baselineCorrectionRate: correctionRate }
|
|
705
706
|
}
|
|
706
707
|
|
|
707
708
|
/**
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
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
|
|
715
|
-
const
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
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) => (
|
|
789
|
-
|
|
790
|
-
: {
|
|
791
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
208
|
-
if (
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
-
...
|
|
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
|
|
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
|
+
"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",
|