@trazum/cli 1.41.0 → 1.42.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/src/index.ts CHANGED
@@ -19,6 +19,10 @@ import {
19
19
  CONNECTORS,
20
20
  normalizeAnthropicUsage,
21
21
  normalizeOpenAIUsage,
22
+ bucketsFromRecords,
23
+ pruneRecords,
24
+ recordsFromBuckets,
25
+ storeInventory,
22
26
  storedReportFrom,
23
27
  verifyPlan,
24
28
  cacheEconomics,
@@ -141,6 +145,7 @@ import {
141
145
  } from './git.js';
142
146
  import type { Revision } from './git.js';
143
147
  import { fetchProviderUsage } from './connect.js';
148
+ import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
144
149
  import { detectLocale, getCliMessages } from './i18n/index.js';
145
150
  import {
146
151
  MAX_SUMMARY_CHARS,
@@ -190,6 +195,7 @@ const VALUE_FLAGS = new Set([
190
195
  'from-log',
191
196
  'min-usd',
192
197
  'payload',
198
+ 'keep',
193
199
  // `route` takes a path here, and the flag is deliberately not `--prompt`:
194
200
  // everywhere else in this tool `--prompt` names a marked prompt *inside* a
195
201
  // source file, and reusing it for a path would be a trap laid for the reader.
@@ -492,8 +498,9 @@ const COMMAND_FLAGS: Record<string, string[]> = {
492
498
  profile: ['json', 'pricing', 'pricing-live', 'against', 'what-if', 'markdown-out', 'csv-out', 'csv-shape', 'max-usd', 'max-growth-usd', 'max-cache-loss-usd', 'max-day-usd', 'max-session-usd', 'label', 'since', 'until', 'dry-run', 'markdown-summary', 'by-source'],
493
499
  plan: ['json', 'out', 'markdown-out', 'min-usd', 'pricing', 'pricing-live'],
494
500
  verify: ['against', 'gate', 'json', 'markdown-out', 'pricing', 'pricing-live'],
495
- history: ['json', 'markdown-out'],
496
- connect: ['since', 'until', 'payload', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
501
+ history: ['store', 'json', 'markdown-out'],
502
+ connect: ['since', 'until', 'payload', 'store', 'json', 'out', 'markdown-out', 'pricing', 'pricing-live', 'dry-run'],
503
+ store: ['prune', 'keep', 'json', 'pricing', 'pricing-live', 'dry-run'],
497
504
  route: ['prompt-file', 'cases', 'label', 'concurrency', 'json', 'yes', 'pricing', 'pricing-live'],
498
505
  eval: ['cases', 'level', 'concurrency', 'export', 'out', 'o', 'model'],
499
506
  prune: ['cases', 'concurrency', 'json', 'yes'],
@@ -2275,6 +2282,162 @@ function parseWhen(
2275
2282
  throw new Error(t.profile.badWhen(flag, value));
2276
2283
  }
2277
2284
 
2285
+ /**
2286
+ * `trazum store` — what is kept, and what a prune would take.
2287
+ *
2288
+ * The store is the one thing in this product that *deletes* something, so the
2289
+ * errands around it are written to make that visible: the inventory says what
2290
+ * is there and how far back, and `--prune` names what went with the span it
2291
+ * covered. Retention with no policy written down is refused rather than
2292
+ * defaulted — deleting measurements on a guess is not something anybody
2293
+ * should receive by accident.
2294
+ */
2295
+ async function commandStore(
2296
+ args: Args,
2297
+ config: TrazumConfig,
2298
+ pricing: PricingCatalogue,
2299
+ t: CliMessages,
2300
+ ): Promise<void> {
2301
+ const root = process.cwd();
2302
+ const { resolved, unreadable, files } = await readStore(root);
2303
+ const inventory = storeInventory(resolved);
2304
+ const n = (value: number): string => value.toLocaleString(t.numberLocale);
2305
+ const day = (msValue: number): string => new Date(msValue).toISOString().slice(0, 10);
2306
+
2307
+ const priced = bucketedProfile(
2308
+ {
2309
+ provider: 'store',
2310
+ granularity: 'bucketed',
2311
+ buckets: bucketsFromRecords(resolved.records),
2312
+ window: inventory.span,
2313
+ gaps: [],
2314
+ unavailable: [],
2315
+ },
2316
+ { catalogue: pricing },
2317
+ );
2318
+
2319
+ if (boolFlag(args, 'prune')) {
2320
+ const keepFlag = stringFlag(args, 'keep');
2321
+ const keepDays = keepFlag !== undefined
2322
+ ? Number(/^(\d+)d?$/.exec(keepFlag)?.[1] ?? NaN)
2323
+ : config.store?.keepDays;
2324
+ if (keepDays === undefined || !Number.isFinite(keepDays) || keepDays <= 0) {
2325
+ throw new Error(t.store.pruneNeedsPolicy());
2326
+ }
2327
+ const cutoff = Date.now() - keepDays * 86_400_000;
2328
+ const result = pruneRecords(resolved.records, cutoff);
2329
+ const droppedUsd = bucketedProfile(
2330
+ {
2331
+ provider: 'store',
2332
+ granularity: 'bucketed',
2333
+ buckets: bucketsFromRecords(result.dropped),
2334
+ window: null,
2335
+ gaps: [],
2336
+ unavailable: [],
2337
+ },
2338
+ { catalogue: pricing },
2339
+ ).total.totalUsd;
2340
+
2341
+ if (boolFlag(args, 'dry-run')) {
2342
+ console.log(
2343
+ wrap(
2344
+ t.store.pruneDryRun(
2345
+ n(result.dropped.length),
2346
+ String(keepDays),
2347
+ result.droppedSpan === null
2348
+ ? null
2349
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`,
2350
+ formatUsd(droppedUsd),
2351
+ ),
2352
+ 76,
2353
+ ' ',
2354
+ ),
2355
+ );
2356
+ return;
2357
+ }
2358
+
2359
+ // The prune also collapses the append log to what the store resolves to,
2360
+ // which is the only moment a rewrite is safe: it is what the reader was
2361
+ // already seeing.
2362
+ await rewriteStore(root, result.kept);
2363
+ console.log(
2364
+ wrap(
2365
+ t.store.pruned(
2366
+ n(result.dropped.length),
2367
+ String(keepDays),
2368
+ result.droppedSpan === null
2369
+ ? null
2370
+ : `${day(result.droppedSpan.fromMs)} → ${day(result.droppedSpan.toMs)}`,
2371
+ formatUsd(droppedUsd),
2372
+ n(result.kept.length),
2373
+ ),
2374
+ 76,
2375
+ ' ',
2376
+ ),
2377
+ );
2378
+ return;
2379
+ }
2380
+
2381
+ if (boolFlag(args, 'json')) {
2382
+ console.log(JSON.stringify({ ...inventory, totalUsd: priced.total.totalUsd, unreadable }, null, 2));
2383
+ return;
2384
+ }
2385
+
2386
+ /**
2387
+ * Empty means *nothing at all* — not "nothing I could resolve".
2388
+ *
2389
+ * Records the store could not tell apart, lines it could not parse and
2390
+ * records from a newer schema are all real measurements sitting on disk.
2391
+ * Reporting an empty store over them would hide exactly what the reader
2392
+ * needs to see, which is the failure this whole module is written against.
2393
+ */
2394
+ const nothingAtAll =
2395
+ inventory.totalRecords === 0 &&
2396
+ inventory.possiblyDouble === 0 &&
2397
+ inventory.unknownVersion === 0 &&
2398
+ unreadable.length === 0;
2399
+ if (nothingAtAll) {
2400
+ console.log(wrap(t.store.empty(STORE_DIR), 76, ' '));
2401
+ return;
2402
+ }
2403
+
2404
+ console.log(
2405
+ c.bold(
2406
+ t.store.heading(
2407
+ n(inventory.totalRecords),
2408
+ formatUsd(priced.total.totalUsd),
2409
+ inventory.span === null ? '' : day(inventory.span.fromMs),
2410
+ inventory.span === null ? '' : day(inventory.span.toMs),
2411
+ ),
2412
+ ),
2413
+ );
2414
+ for (const provider of inventory.providers) {
2415
+ console.log(
2416
+ ` ${t.store.providerRow(
2417
+ provider.provider,
2418
+ n(provider.records),
2419
+ provider.span === null ? '' : `${day(provider.span.fromMs)} → ${day(provider.span.toMs)}`,
2420
+ n(provider.models.length),
2421
+ )}`,
2422
+ );
2423
+ }
2424
+ console.log();
2425
+ console.log(` ${c.dim(wrap(t.store.holds(n(files.length)), 74, ' '))}`);
2426
+ if (inventory.possiblyDouble > 0) {
2427
+ console.log(` ${c.yellow(wrap(t.store.possiblyDouble(n(inventory.possiblyDouble)), 74, ' '))}`);
2428
+ }
2429
+ if (inventory.unknownVersion > 0) {
2430
+ console.log(` ${c.yellow(wrap(t.store.unknownVersion(n(inventory.unknownVersion)), 74, ' '))}`);
2431
+ }
2432
+ for (const bad of unreadable) {
2433
+ console.log(` ${c.yellow(wrap(t.store.unreadable(bad.file, String(bad.line)), 74, ' '))}`);
2434
+ }
2435
+ const keepDays = config.store?.keepDays;
2436
+ console.log(
2437
+ ` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`,
2438
+ );
2439
+ }
2440
+
2278
2441
  /**
2279
2442
  * `trazum connect <provider>` — the bill, read from the provider.
2280
2443
  *
@@ -2348,6 +2511,17 @@ async function commandConnect(
2348
2511
  const cache = bucketedCacheEconomics(report);
2349
2512
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
2350
2513
 
2514
+ /**
2515
+ * `--store` keeps what was pulled, so the next run does not download it
2516
+ * again and `history` has a series without anybody curating a folder. Opt
2517
+ * in rather than automatic: a command that starts writing to a hidden
2518
+ * directory on its own is a command nobody trusts twice.
2519
+ */
2520
+ let stored = 0;
2521
+ if (boolFlag(args, 'store')) {
2522
+ stored = await appendRecords(process.cwd(), recordsFromBuckets(pull.provider, pull.buckets, Date.now()));
2523
+ }
2524
+
2351
2525
  const outPath = stringFlag(args, 'out');
2352
2526
  if (outPath !== undefined) {
2353
2527
  await writeFile(outPath, `${JSON.stringify({ ...report, pulledFrom: source.variable }, null, 2)}\n`);
@@ -2438,6 +2612,7 @@ async function commandConnect(
2438
2612
  else console.log(` ${wrap(row, 74, ' ')}`);
2439
2613
  }
2440
2614
  if (outPath !== undefined) console.log(c.dim(t.connect.wrote(outPath)));
2615
+ if (stored > 0) console.log(c.dim(t.store.appended(n(stored), STORE_DIR)));
2441
2616
  }
2442
2617
 
2443
2618
  /**
@@ -2449,40 +2624,103 @@ async function commandConnect(
2449
2624
  * same action planned twice — and no series, however long, becomes a
2450
2625
  * forecast.
2451
2626
  */
2452
- async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2453
- const path = args.positional[0];
2454
- if (path === undefined) throw new Error(t.history.noTarget());
2455
- const target = await stat(path).catch(() => null);
2456
- if (!target?.isDirectory()) throw new Error(t.history.noTarget());
2457
-
2458
- const entries = await readdir(path, { withFileTypes: true });
2459
- const files = entries
2460
- .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2461
- .map((entry) => join(path, entry.name))
2462
- .sort((a, b) => a.localeCompare(b));
2463
-
2627
+ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
2628
+ /**
2629
+ * `--store` builds the series from measured spend already on disk.
2630
+ *
2631
+ * Bucketed sources carry no label — a usage API groups by model and
2632
+ * workspace, never by workload — so the label series is *absent and named*
2633
+ * rather than empty and misread, the same discipline the connected report
2634
+ * uses for the findings a sum cannot support. The model-share and
2635
+ * cache-share series are exactly what a series exists for, and both work.
2636
+ */
2464
2637
  const reports: StoredReport[] = [];
2465
2638
  const plans: (PlanDocument & { createdAt?: string })[] = [];
2466
2639
  const unrecognized: string[] = [];
2467
- for (const file of files) {
2468
- let parsed: unknown;
2469
- try {
2470
- parsed = JSON.parse(await readFile(file, 'utf8'));
2471
- } catch {
2472
- unrecognized.push(file);
2473
- continue;
2474
- }
2475
- const report = storedReportFrom(file, parsed);
2476
- if (report !== null) {
2477
- reports.push(report);
2478
- continue;
2640
+ const fromStore = boolFlag(args, 'store');
2641
+
2642
+ if (fromStore) {
2643
+ const { resolved } = await readStore(process.cwd());
2644
+ if (resolved.records.length === 0) throw new Error(t.store.empty(STORE_DIR));
2645
+
2646
+ /**
2647
+ * One period per UTC day of stored measurement, priced exactly as a fresh
2648
+ * pull prices it.
2649
+ *
2650
+ * The label series is deliberately absent: a usage API groups by model
2651
+ * and workspace, never by workload, so there is no label to carry.
2652
+ * Rendering an empty label series would read as "no workload moved",
2653
+ * which is a statement about traffic rather than about the source, and
2654
+ * the footer says which it is.
2655
+ */
2656
+ const byDay = new Map<string, typeof resolved.records>();
2657
+ for (const record of resolved.records) {
2658
+ const key = new Date(record.fromMs).toISOString().slice(0, 10);
2659
+ const list = byDay.get(key) ?? [];
2660
+ list.push(record);
2661
+ byDay.set(key, list);
2662
+ }
2663
+ for (const [dayKey, records] of [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
2664
+ const day = bucketedProfile(
2665
+ {
2666
+ provider: 'store',
2667
+ granularity: 'bucketed',
2668
+ buckets: bucketsFromRecords(records),
2669
+ window: {
2670
+ fromMs: Math.min(...records.map((r) => r.fromMs)),
2671
+ toMs: Math.max(...records.map((r) => r.toMs)),
2672
+ },
2673
+ gaps: [],
2674
+ unavailable: [],
2675
+ },
2676
+ { catalogue: pricing },
2677
+ );
2678
+ const cacheTouched = day.total.cacheReadTokens + day.total.cacheWriteTokens;
2679
+ reports.push({
2680
+ name: dayKey,
2681
+ span: day.span,
2682
+ totalUsd: day.total.totalUsd,
2683
+ calls: day.total.calls,
2684
+ byLabel: new Map(),
2685
+ byModel: new Map(day.byModel.map((slice) => [slice.model, slice.totalUsd])),
2686
+ cacheReadShare:
2687
+ day.total.inputTokens + cacheTouched > 0
2688
+ ? day.total.cacheReadTokens / (day.total.inputTokens + cacheTouched)
2689
+ : null,
2690
+ });
2479
2691
  }
2480
- const maybePlan = parsed as PlanDocument & { createdAt?: string };
2481
- if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2482
- plans.push(maybePlan);
2483
- continue;
2692
+ } else {
2693
+ const path = args.positional[0];
2694
+ if (path === undefined) throw new Error(t.history.noTarget());
2695
+ const target = await stat(path).catch(() => null);
2696
+ if (!target?.isDirectory()) throw new Error(t.history.noTarget());
2697
+
2698
+ const entries = await readdir(path, { withFileTypes: true });
2699
+ const files = entries
2700
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
2701
+ .map((entry) => join(path, entry.name))
2702
+ .sort((a, b) => a.localeCompare(b));
2703
+
2704
+ for (const file of files) {
2705
+ let parsed: unknown;
2706
+ try {
2707
+ parsed = JSON.parse(await readFile(file, 'utf8'));
2708
+ } catch {
2709
+ unrecognized.push(file);
2710
+ continue;
2711
+ }
2712
+ const report = storedReportFrom(file, parsed);
2713
+ if (report !== null) {
2714
+ reports.push(report);
2715
+ continue;
2716
+ }
2717
+ const maybePlan = parsed as PlanDocument & { createdAt?: string };
2718
+ if (maybePlan?.schemaVersion === 1 && Array.isArray(maybePlan.actions)) {
2719
+ plans.push(maybePlan);
2720
+ continue;
2721
+ }
2722
+ unrecognized.push(file);
2484
2723
  }
2485
- unrecognized.push(file);
2486
2724
  }
2487
2725
 
2488
2726
  const history = buildHistory(reports, plans);
@@ -2516,7 +2754,7 @@ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2516
2754
  const row = t.history.periodRow(
2517
2755
  period.name,
2518
2756
  formatUsd(period.totalUsd),
2519
- n(period.calls),
2757
+ period.calls === null ? null : n(period.calls),
2520
2758
  ((period.toMs - period.fromMs) / 86_400_000).toFixed(1),
2521
2759
  );
2522
2760
  out.push(md ? `- ${row}` : ` ${row}`);
@@ -2544,6 +2782,11 @@ async function commandHistory(args: Args, t: CliMessages): Promise<void> {
2544
2782
  for (const name of unrecognized) {
2545
2783
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
2546
2784
  }
2785
+ if (fromStore) {
2786
+ out.push('');
2787
+ const note = t.history.storeNoLabels();
2788
+ out.push(md ? `_${note}_` : ` ${note}`);
2789
+ }
2547
2790
  out.push('');
2548
2791
  out.push(md ? `_${t.history.footer()}_` : ` ${t.history.footer()}`);
2549
2792
  return out;
@@ -6615,11 +6858,14 @@ async function main(): Promise<void> {
6615
6858
  await commandVerify(args, pricing, t);
6616
6859
  break;
6617
6860
  case 'history':
6618
- await commandHistory(args, t);
6861
+ await commandHistory(args, pricing, t);
6619
6862
  break;
6620
6863
  case 'connect':
6621
6864
  await commandConnect(args, pricing, t);
6622
6865
  break;
6866
+ case 'store':
6867
+ await commandStore(args, config, pricing, t);
6868
+ break;
6623
6869
  case 'route':
6624
6870
  await commandRoute(args, pricing, t);
6625
6871
  break;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Where the store actually lives.
3
+ *
4
+ * The core decides what a record is and when two are the same; this decides
5
+ * where the bytes go. Split that way for the reason every module here is:
6
+ * `@trazum/core` stays browser-safe, and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, one buffer per write.** A pull appends a single block and
9
+ * never rewrites what is already there. Two consequences worth stating: a
10
+ * crash during a write loses the tail of one block rather than a year of
11
+ * measurements, and two runs writing at once interleave whole blocks rather
12
+ * than half-lines. Compaction is a separate, explicit errand — `store
13
+ * --prune` — because collapsing a log is the one operation that destroys
14
+ * something, and it should never happen as a side effect of a pull.
15
+ *
16
+ * **A line that will not parse is kept, counted and skipped.** The store is a
17
+ * file a human may open, a backup may truncate and a merge may mangle. Losing
18
+ * the whole month because one line is broken would be the worst possible
19
+ * response; so would silently pretending the month is complete.
20
+ */
21
+
22
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
23
+ import { join } from 'node:path';
24
+ import { resolveStore } from '@trazum/core';
25
+ import type { ResolvedStore, StoreRecord } from '@trazum/core';
26
+
27
+ /** The directory name, relative to wherever the caller roots the store. */
28
+ export const STORE_DIR = '.trazum/store';
29
+
30
+ /** Records are filed by the UTC month their window starts in. */
31
+ function monthOf(record: StoreRecord): string {
32
+ return new Date(record.fromMs).toISOString().slice(0, 7);
33
+ }
34
+
35
+ export interface StoreReadResult {
36
+ resolved: ResolvedStore;
37
+ /** Lines that would not parse: counted and named by file, never dropped quietly. */
38
+ unreadable: { file: string; line: number }[];
39
+ /** Files read, so an empty store can be told from an unread one. */
40
+ files: string[];
41
+ }
42
+
43
+ /**
44
+ * Reads every record in the store.
45
+ *
46
+ * Returns an empty result rather than throwing when the store does not exist:
47
+ * "you have not stored anything yet" is a state, not an error, and the caller
48
+ * says so in a sentence that names `trazum connect`.
49
+ */
50
+ export async function readStore(root: string): Promise<StoreReadResult> {
51
+ const dir = join(root, STORE_DIR);
52
+ const records: StoreRecord[] = [];
53
+ const unreadable: { file: string; line: number }[] = [];
54
+ const files: string[] = [];
55
+
56
+ let providers: string[];
57
+ try {
58
+ const entries = await readdir(dir, { withFileTypes: true });
59
+ providers = entries.filter((e) => e.isDirectory()).map((e) => e.name);
60
+ } catch {
61
+ return { resolved: resolveStore([]), unreadable, files };
62
+ }
63
+
64
+ for (const provider of providers.sort()) {
65
+ const providerDir = join(dir, provider);
66
+ let months: string[];
67
+ try {
68
+ months = (await readdir(providerDir)).filter((name) => name.endsWith('.jsonl')).sort();
69
+ } catch {
70
+ continue;
71
+ }
72
+ for (const month of months) {
73
+ const path = join(providerDir, month);
74
+ files.push(join(STORE_DIR, provider, month));
75
+ const text = await readFile(path, 'utf8');
76
+ for (const [index, line] of text.split('\n').entries()) {
77
+ if (line.trim() === '') continue;
78
+ try {
79
+ const parsed = JSON.parse(line) as StoreRecord;
80
+ if (typeof parsed?.provider === 'string' && typeof parsed?.fromMs === 'number') {
81
+ records.push(parsed);
82
+ } else {
83
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
84
+ }
85
+ } catch {
86
+ unreadable.push({ file: join(STORE_DIR, provider, month), line: index + 1 });
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ return { resolved: resolveStore(records), unreadable, files };
93
+ }
94
+
95
+ /**
96
+ * Appends records, grouped into one write per month file.
97
+ *
98
+ * Nothing already on disk is read, rewritten or resolved here: convergence
99
+ * happens when the store is *read*, which is what keeps a write cheap enough
100
+ * to run on a schedule and impossible to corrupt by racing.
101
+ */
102
+ export async function appendRecords(root: string, records: readonly StoreRecord[]): Promise<number> {
103
+ if (records.length === 0) return 0;
104
+ const byFile = new Map<string, StoreRecord[]>();
105
+ for (const record of records) {
106
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
107
+ const list = byFile.get(key) ?? [];
108
+ list.push(record);
109
+ byFile.set(key, list);
110
+ }
111
+
112
+ for (const [relative, list] of byFile) {
113
+ const path = join(root, STORE_DIR, relative);
114
+ await mkdir(join(path, '..'), { recursive: true });
115
+ const block = `${list.map((record) => JSON.stringify(record)).join('\n')}\n`;
116
+ await writeFile(path, block, { flag: 'a', mode: 0o600 });
117
+ }
118
+ return records.length;
119
+ }
120
+
121
+ /**
122
+ * Rewrites the store with exactly the records given.
123
+ *
124
+ * The one operation that destroys something, so it is only ever reached from
125
+ * an explicit `--prune`. Each month file is written whole, and a month left
126
+ * with nothing is written empty rather than removed — a missing file and an
127
+ * empty one say different things to whoever looks next.
128
+ */
129
+ export async function rewriteStore(root: string, records: readonly StoreRecord[]): Promise<void> {
130
+ const dir = join(root, STORE_DIR);
131
+ const existing = new Set<string>();
132
+ try {
133
+ for (const provider of await readdir(dir)) {
134
+ for (const month of await readdir(join(dir, provider)).catch(() => [])) {
135
+ if (month.endsWith('.jsonl')) existing.add(join(provider, month));
136
+ }
137
+ }
138
+ } catch {
139
+ // Nothing stored yet: the writes below create what is needed.
140
+ }
141
+
142
+ const byFile = new Map<string, StoreRecord[]>();
143
+ for (const record of records) {
144
+ const key = join(record.provider, `${monthOf(record)}.jsonl`);
145
+ const list = byFile.get(key) ?? [];
146
+ list.push(record);
147
+ byFile.set(key, list);
148
+ }
149
+
150
+ for (const relative of new Set([...existing, ...byFile.keys()])) {
151
+ const list = byFile.get(relative) ?? [];
152
+ const path = join(dir, relative);
153
+ await mkdir(join(path, '..'), { recursive: true });
154
+ const block = list.length === 0 ? '' : `${list.map((r) => JSON.stringify(r)).join('\n')}\n`;
155
+ await writeFile(path, block, { mode: 0o600 });
156
+ }
157
+ }