dsh-tacit 0.2.3 → 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/store.js CHANGED
@@ -7,18 +7,63 @@
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
- * - the ONLY deletion this plugin ever performs is `clearReports()`, which
15
- * unlinks files matching /^\d+\.json$/ inside its own reports directory
16
- * (then removes that directory only if empty) — nothing else on disk;
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
+ /**
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
+
42
+ /** Local calendar day key, `YYYY-MM-DD` (auto-analysis daily budget and the usage ledger share this). */
43
+ export function dayKey(now = Date.now()) {
44
+ const date = new Date(now)
45
+ const pad = (value) => String(value).padStart(2, '0')
46
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
47
+ }
48
+
49
+ /**
50
+ * `today`'s day key moved back `days` **calendar** days, anchored at local noon.
51
+ * Subtracting a fixed `days * 86_400_000` instead would land a day early across
52
+ * a spring-forward (a 23 h day), silently keeping one day more than asked for.
53
+ */
54
+ export function dayKeyBefore(today, days) {
55
+ const match = USAGE_DAY_RE.exec(String(today))
56
+ const at = match !== null
57
+ ? new Date(Number(match[0].slice(0, 4)), Number(match[0].slice(5, 7)) - 1, Number(match[0].slice(8, 10)))
58
+ : new Date(today)
59
+ at.setHours(12, 0, 0, 0)
60
+ at.setDate(at.getDate() - days)
61
+ return dayKey(at.getTime())
62
+ }
63
+
64
+ function emptyUsageSummaryRaw() {
65
+ return { version: 1, trackingSince: Date.now(), lifetime: {}, byType: {}, byModel: {}, days: {} }
66
+ }
22
67
 
23
68
  export function emptyProfile() {
24
69
  return {
@@ -36,6 +81,11 @@ export function emptyProfile() {
36
81
  export class CoachStore {
37
82
  constructor(root) {
38
83
  this.root = root
84
+ /** Absolute paths already warned about (corrupt usage JSON) — warn once per file, not once per read. */
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
39
89
  }
40
90
 
41
91
  ensureDir(dir) {
@@ -217,4 +267,175 @@ export class CoachStore {
217
267
  }
218
268
  return removed
219
269
  }
270
+
271
+ /** Warn once (not once per read) about one corrupt usage JSON file. */
272
+ warnCorruptUsageFile(file) {
273
+ if (this.warnedUsageFiles.has(file)) return
274
+ this.warnedUsageFiles.add(file)
275
+ console.warn(`[tacit] corrupt usage file, using defaults: ${file}`)
276
+ }
277
+
278
+ /** Read+parse a usage JSON file. Distinguishes "absent" (silent) from "corrupt" (warn once) from `readJson`, which cannot. */
279
+ readUsageJson(file, schema, fallback) {
280
+ let raw
281
+ try {
282
+ raw = fs.readFileSync(file, 'utf8')
283
+ } catch {
284
+ return fallback // file simply doesn't exist yet — not corrupt
285
+ }
286
+ let value
287
+ try {
288
+ value = JSON.parse(raw)
289
+ } catch {
290
+ this.warnCorruptUsageFile(file)
291
+ return fallback
292
+ }
293
+ const parsed = schema.safeParse(value)
294
+ if (parsed.success) return parsed.data
295
+ this.warnCorruptUsageFile(file)
296
+ return fallback
297
+ }
298
+
299
+ usageDir() {
300
+ return path.join(this.root, 'usage')
301
+ }
302
+
303
+ usageSummaryFile() {
304
+ return path.join(this.usageDir(), 'summary.json')
305
+ }
306
+
307
+ /** `<usageDir>/<day>.json`; throws on anything but a strict YYYY-MM-DD day (never touches the filesystem with a bad path). */
308
+ usageDayFile(day) {
309
+ if (!USAGE_DAY_RE.test(day)) throw new Error(`invalid usage day: ${JSON.stringify(day)}`)
310
+ return path.join(this.usageDir(), `${day}.json`)
311
+ }
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
+ */
319
+ readUsageDay(day) {
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
355
+ }
356
+
357
+ /** Atomic write; caps `runs` to the newest 500 by `startedAt` before writing. */
358
+ writeUsageDay(day, file) {
359
+ const runs = Array.isArray(file?.runs) ? [...file.runs] : []
360
+ runs.sort((a, b) => (Number(a?.startedAt) || 0) - (Number(b?.startedAt) || 0))
361
+ this.forgetUsageDay(this.usageDayFile(day))
362
+ this.writeJsonAtomic(this.usageDayFile(day), { version: 1, day, runs: runs.slice(-500) })
363
+ }
364
+
365
+ /**
366
+ * Ascending day keys (`YYYY-MM-DD`, chronological under plain string sort)
367
+ * for every file matching the plugin's own usage-day naming — atomic-write
368
+ * temp files (`<file>.tmp-<pid>-<ts>`), `summary.json`, and anything else
369
+ * are ignored. `[]` when `usage/` doesn't exist yet.
370
+ */
371
+ listUsageDays() {
372
+ let names = []
373
+ try {
374
+ names = fs.readdirSync(this.usageDir())
375
+ } catch {
376
+ return []
377
+ }
378
+ return names
379
+ .filter((name) => USAGE_DAY_FILE_RE.test(name))
380
+ .map((name) => name.slice(0, -'.json'.length))
381
+ .sort()
382
+ }
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
+ */
389
+ readUsageSummary() {
390
+ const fallback = usageSummarySchema.parse(emptyUsageSummaryRaw())
391
+ const summary = this.readUsageJson(this.usageSummaryFile(), usageSummarySchema, fallback)
392
+ return { summary, created: summary === fallback }
393
+ }
394
+
395
+ writeUsageSummary(summary) {
396
+ this.writeJsonAtomic(this.usageSummaryFile(), summary)
397
+ }
398
+
399
+ /**
400
+ * Remove day files older than `keepDays` relative to `today` (default:
401
+ * today's own `dayKey()`), by plain string comparison of `YYYY-MM-DD`
402
+ * (chronological for same-length zero-padded dates). Returns the count
403
+ * removed; an unlink failure is swallowed per file, same as `clearReports`.
404
+ */
405
+ pruneUsageDays(keepDays, today = dayKey()) {
406
+ const days = Math.max(0, Math.round(Number(keepDays) || 0))
407
+ const cutoff = dayKeyBefore(today, days)
408
+ let removed = 0
409
+ for (const day of this.listUsageDays()) {
410
+ if (day >= cutoff) continue
411
+ try {
412
+ this.forgetUsageDay(this.usageDayFile(day))
413
+ fs.unlinkSync(this.usageDayFile(day))
414
+ removed += 1
415
+ } catch {
416
+ // Keep going: one unreadable file must not block the rest.
417
+ }
418
+ }
419
+ return removed
420
+ }
421
+
422
+ /**
423
+ * Remove every plugin-named usage day file and start a fresh summary
424
+ * (`trackingSince: Date.now()`). Never removes the `usage/` directory or
425
+ * any file that doesn't match the plugin's own day-file naming.
426
+ */
427
+ clearUsage() {
428
+ let removed = 0
429
+ for (const day of this.listUsageDays()) {
430
+ try {
431
+ this.forgetUsageDay(this.usageDayFile(day))
432
+ fs.unlinkSync(this.usageDayFile(day))
433
+ removed += 1
434
+ } catch {
435
+ // Keep going: one unreadable file must not block the rest.
436
+ }
437
+ }
438
+ this.writeUsageSummary(usageSummarySchema.parse({ ...emptyUsageSummaryRaw(), trackingSince: Date.now() }))
439
+ return { removed }
440
+ }
220
441
  }