dsh-tacit 0.2.3 → 0.3.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 +3 -3
- package/client/client.js +1739 -91
- package/docs/README.zh.md +3 -3
- package/lib/analyze.js +114 -30
- package/lib/index.js +3 -1
- package/lib/pricing-source.js +133 -0
- package/lib/pricing.js +311 -0
- package/lib/routes.js +14 -1
- package/lib/schema.js +170 -0
- package/lib/service.js +349 -60
- package/lib/store.js +163 -3
- package/lib/usage.js +708 -0
- package/package.json +1 -1
package/lib/store.js
CHANGED
|
@@ -7,18 +7,55 @@
|
|
|
7
7
|
* config.patch.json UI-written config fields (loader/YAML config is the base)
|
|
8
8
|
* profile.json persistent user mistake profile
|
|
9
9
|
* reports/<sessionId>/<turn>.json analysis reports
|
|
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
|
|
10
12
|
*
|
|
11
13
|
* Safety rules (hard constraints):
|
|
12
14
|
* - writes are atomic (temp file + rename) and never truncate an existing
|
|
13
15
|
* file in place;
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* - this plugin deletes files down exactly two paths, both restricted to
|
|
17
|
+
* its own files: `clearReports()`, which unlinks files matching
|
|
18
|
+
* /^\d+\.json$/ inside its own reports directory (then removes that
|
|
19
|
+
* directory only if empty); and usage-day expiry / `clearUsage()`, which
|
|
20
|
+
* unlink only files matching /^\d{4}-\d{2}-\d{2}\.json$/ inside `usage/`
|
|
21
|
+
* and never remove the `usage/` directory itself — nothing else on disk
|
|
22
|
+
* is ever touched;
|
|
17
23
|
* - session ids are sanitized before touching the filesystem (no traversal).
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import fs from 'node:fs'
|
|
21
27
|
import path from 'node:path'
|
|
28
|
+
import { usageDayFileSchema, usageSummarySchema } from './schema.js'
|
|
29
|
+
|
|
30
|
+
/** Strictly the plugin's own usage-day file naming — nothing else may ever be unlinked from `usage/`. */
|
|
31
|
+
const USAGE_DAY_FILE_RE = /^\d{4}-\d{2}-\d{2}\.json$/
|
|
32
|
+
const USAGE_DAY_RE = /^\d{4}-\d{2}-\d{2}$/
|
|
33
|
+
|
|
34
|
+
/** Local calendar day key, `YYYY-MM-DD` (auto-analysis daily budget and the usage ledger share this). */
|
|
35
|
+
export function dayKey(now = Date.now()) {
|
|
36
|
+
const date = new Date(now)
|
|
37
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
38
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `today`'s day key moved back `days` **calendar** days, anchored at local noon.
|
|
43
|
+
* Subtracting a fixed `days * 86_400_000` instead would land a day early across
|
|
44
|
+
* a spring-forward (a 23 h day), silently keeping one day more than asked for.
|
|
45
|
+
*/
|
|
46
|
+
function dayKeyBefore(today, days) {
|
|
47
|
+
const match = USAGE_DAY_RE.exec(String(today))
|
|
48
|
+
const at = match !== null
|
|
49
|
+
? new Date(Number(match[0].slice(0, 4)), Number(match[0].slice(5, 7)) - 1, Number(match[0].slice(8, 10)))
|
|
50
|
+
: new Date(today)
|
|
51
|
+
at.setHours(12, 0, 0, 0)
|
|
52
|
+
at.setDate(at.getDate() - days)
|
|
53
|
+
return dayKey(at.getTime())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function emptyUsageSummaryRaw() {
|
|
57
|
+
return { version: 1, trackingSince: Date.now(), lifetime: {}, byType: {}, byModel: {}, days: {} }
|
|
58
|
+
}
|
|
22
59
|
|
|
23
60
|
export function emptyProfile() {
|
|
24
61
|
return {
|
|
@@ -36,6 +73,8 @@ export function emptyProfile() {
|
|
|
36
73
|
export class CoachStore {
|
|
37
74
|
constructor(root) {
|
|
38
75
|
this.root = root
|
|
76
|
+
/** Absolute paths already warned about (corrupt usage JSON) — warn once per file, not once per read. */
|
|
77
|
+
this.warnedUsageFiles = new Set()
|
|
39
78
|
}
|
|
40
79
|
|
|
41
80
|
ensureDir(dir) {
|
|
@@ -217,4 +256,125 @@ export class CoachStore {
|
|
|
217
256
|
}
|
|
218
257
|
return removed
|
|
219
258
|
}
|
|
259
|
+
|
|
260
|
+
/** Warn once (not once per read) about one corrupt usage JSON file. */
|
|
261
|
+
warnCorruptUsageFile(file) {
|
|
262
|
+
if (this.warnedUsageFiles.has(file)) return
|
|
263
|
+
this.warnedUsageFiles.add(file)
|
|
264
|
+
console.warn(`[tacit] corrupt usage file, using defaults: ${file}`)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Read+parse a usage JSON file. Distinguishes "absent" (silent) from "corrupt" (warn once) from `readJson`, which cannot. */
|
|
268
|
+
readUsageJson(file, schema, fallback) {
|
|
269
|
+
let raw
|
|
270
|
+
try {
|
|
271
|
+
raw = fs.readFileSync(file, 'utf8')
|
|
272
|
+
} catch {
|
|
273
|
+
return fallback // file simply doesn't exist yet — not corrupt
|
|
274
|
+
}
|
|
275
|
+
let value
|
|
276
|
+
try {
|
|
277
|
+
value = JSON.parse(raw)
|
|
278
|
+
} catch {
|
|
279
|
+
this.warnCorruptUsageFile(file)
|
|
280
|
+
return fallback
|
|
281
|
+
}
|
|
282
|
+
const parsed = schema.safeParse(value)
|
|
283
|
+
if (parsed.success) return parsed.data
|
|
284
|
+
this.warnCorruptUsageFile(file)
|
|
285
|
+
return fallback
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
usageDir() {
|
|
289
|
+
return path.join(this.root, 'usage')
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
usageSummaryFile() {
|
|
293
|
+
return path.join(this.usageDir(), 'summary.json')
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** `<usageDir>/<day>.json`; throws on anything but a strict YYYY-MM-DD day (never touches the filesystem with a bad path). */
|
|
297
|
+
usageDayFile(day) {
|
|
298
|
+
if (!USAGE_DAY_RE.test(day)) throw new Error(`invalid usage day: ${JSON.stringify(day)}`)
|
|
299
|
+
return path.join(this.usageDir(), `${day}.json`)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
readUsageDay(day) {
|
|
303
|
+
return this.readUsageJson(this.usageDayFile(day), usageDayFileSchema, { version: 1, day, runs: [] })
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Atomic write; caps `runs` to the newest 500 by `startedAt` before writing. */
|
|
307
|
+
writeUsageDay(day, file) {
|
|
308
|
+
const runs = Array.isArray(file?.runs) ? [...file.runs] : []
|
|
309
|
+
runs.sort((a, b) => (Number(a?.startedAt) || 0) - (Number(b?.startedAt) || 0))
|
|
310
|
+
this.writeJsonAtomic(this.usageDayFile(day), { version: 1, day, runs: runs.slice(-500) })
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Ascending day keys (`YYYY-MM-DD`, chronological under plain string sort)
|
|
315
|
+
* for every file matching the plugin's own usage-day naming — atomic-write
|
|
316
|
+
* temp files (`<file>.tmp-<pid>-<ts>`), `summary.json`, and anything else
|
|
317
|
+
* are ignored. `[]` when `usage/` doesn't exist yet.
|
|
318
|
+
*/
|
|
319
|
+
listUsageDays() {
|
|
320
|
+
let names = []
|
|
321
|
+
try {
|
|
322
|
+
names = fs.readdirSync(this.usageDir())
|
|
323
|
+
} catch {
|
|
324
|
+
return []
|
|
325
|
+
}
|
|
326
|
+
return names
|
|
327
|
+
.filter((name) => USAGE_DAY_FILE_RE.test(name))
|
|
328
|
+
.map((name) => name.slice(0, -'.json'.length))
|
|
329
|
+
.sort()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
readUsageSummary() {
|
|
333
|
+
return this.readUsageJson(this.usageSummaryFile(), usageSummarySchema, usageSummarySchema.parse(emptyUsageSummaryRaw()))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
writeUsageSummary(summary) {
|
|
337
|
+
this.writeJsonAtomic(this.usageSummaryFile(), summary)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Remove day files older than `keepDays` relative to `today` (default:
|
|
342
|
+
* today's own `dayKey()`), by plain string comparison of `YYYY-MM-DD`
|
|
343
|
+
* (chronological for same-length zero-padded dates). Returns the count
|
|
344
|
+
* removed; an unlink failure is swallowed per file, same as `clearReports`.
|
|
345
|
+
*/
|
|
346
|
+
pruneUsageDays(keepDays, today = dayKey()) {
|
|
347
|
+
const days = Math.max(0, Math.round(Number(keepDays) || 0))
|
|
348
|
+
const cutoff = dayKeyBefore(today, days)
|
|
349
|
+
let removed = 0
|
|
350
|
+
for (const day of this.listUsageDays()) {
|
|
351
|
+
if (day >= cutoff) continue
|
|
352
|
+
try {
|
|
353
|
+
fs.unlinkSync(this.usageDayFile(day))
|
|
354
|
+
removed += 1
|
|
355
|
+
} catch {
|
|
356
|
+
// Keep going: one unreadable file must not block the rest.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return removed
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Remove every plugin-named usage day file and start a fresh summary
|
|
364
|
+
* (`trackingSince: Date.now()`). Never removes the `usage/` directory or
|
|
365
|
+
* any file that doesn't match the plugin's own day-file naming.
|
|
366
|
+
*/
|
|
367
|
+
clearUsage() {
|
|
368
|
+
let removed = 0
|
|
369
|
+
for (const day of this.listUsageDays()) {
|
|
370
|
+
try {
|
|
371
|
+
fs.unlinkSync(this.usageDayFile(day))
|
|
372
|
+
removed += 1
|
|
373
|
+
} catch {
|
|
374
|
+
// Keep going: one unreadable file must not block the rest.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
this.writeUsageSummary(usageSummarySchema.parse({ ...emptyUsageSummaryRaw(), trackingSince: Date.now() }))
|
|
378
|
+
return { removed }
|
|
379
|
+
}
|
|
220
380
|
}
|