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/README.md +7 -5
- package/client/client.js +367 -74
- package/docs/README.zh.md +2 -1
- package/lib/analyze.js +215 -24
- package/lib/fold.js +11 -5
- package/lib/index.js +1 -1
- package/lib/redact.js +53 -0
- package/lib/routes.js +1 -0
- package/lib/schema.js +98 -25
- package/lib/service.js +282 -134
- package/lib/store.js +66 -5
- package/lib/usage.js +139 -41
- package/lib/workspace.js +62 -0
- package/package.json +1 -1
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
|
-
|
|
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,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 =
|
|
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 =
|
|
208
|
-
if (
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
594
|
-
// folded from the loaded
|
|
595
|
-
//
|
|
596
|
-
//
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
|
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()
|
package/lib/workspace.js
ADDED
|
@@ -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
|
+
"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",
|