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/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
- * - 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
+ /** 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
  }