pi-context-engine 0.5.3 → 0.7.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/engine.ts CHANGED
@@ -20,7 +20,7 @@ import { SessionCapture } from "./memory/session-capture.js";
20
20
  import { drainOne, enqueueSessionRollup, enqueueTurn, type CompressionDeps } from "./memory/compressor.js";
21
21
  import { compress, expand, compressWithCounts, DEFAULT_LEVEL } from "./memory/grammar.js";
22
22
  import { cosine } from "./memory/extractive.js";
23
- import { AutomaticIndexFileLimitError, runIndexing } from "./indexer/pipeline.js";
23
+ import { AutomaticIndexFileLimitError, runIndexing, withPipelineLock } from "./indexer/pipeline.js";
24
24
  import { getModelPricing } from "./pricing.js";
25
25
  import type { Chunk } from "./types.js";
26
26
 
@@ -125,8 +125,10 @@ export interface SessionStats {
125
125
  export interface EngineOptions {
126
126
  projectDir: string;
127
127
  config?: Config;
128
- /** Test seam: reuse an existing backend/embedder/reranker instead of auto-detecting. */
129
- runtime?: { backend: LocalBackend; embedder: Embedder; reranker?: LocalReranker };
128
+ /** Test seam: reuse an existing backend/embedder/reranker instead of
129
+ * auto-detecting. `backend` may be nulled by resetIndex — the pipeline
130
+ * then constructs a fresh one. */
131
+ runtime?: { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker };
130
132
  }
131
133
 
132
134
  /** Issue #11 (upstream warmup): what /context-warmup resolved + cached. */
@@ -161,8 +163,8 @@ export class ContextEngine {
161
163
  readonly projectName: string;
162
164
  readonly storageBase: string;
163
165
  config: Config;
164
- private readonly injectedRuntime:
165
- | { backend: LocalBackend; embedder: Embedder; reranker?: LocalReranker }
166
+ private injectedRuntime:
167
+ | { backend?: LocalBackend | null; embedder: Embedder; reranker?: LocalReranker }
166
168
  | undefined;
167
169
  private backendInstance: LocalBackend | null = null;
168
170
  private embedderInstance: Embedder | null = null;
@@ -188,7 +190,7 @@ export class ContextEngine {
188
190
  this.config = options.config ?? loadConfigForProject(this.projectDir);
189
191
  this.injectedRuntime = options.runtime;
190
192
  if (options.runtime) {
191
- this.backendInstance = options.runtime.backend;
193
+ this.backendInstance = options.runtime.backend ?? null;
192
194
  this.embedderInstance = options.runtime.embedder;
193
195
  }
194
196
  this.storageBase = projectStorageDir(this.config.storagePath, this.projectDir);
@@ -358,6 +360,93 @@ export class ContextEngine {
358
360
  return this.backend.countChunks();
359
361
  }
360
362
 
363
+ /**
364
+ * Delete this project's index (manifest, vector/FTS/graph stores, embedding
365
+ * cache) while keeping memory, sessions and the savings ledger — those are
366
+ * user history, not index state. Handles are closed BEFORE deletion:
367
+ * Windows refuses to delete files with open sqlite handles, which is why
368
+ * manually deleting the storage dir fails while a session is running.
369
+ * Queues behind in-flight and queued pipeline runs, and holds the same lock
370
+ * during deletion so a later reindex cannot race the reset.
371
+ * Errors are reported per target (e.g. another session holding a handle)
372
+ * instead of failing the whole reset.
373
+ */
374
+ async resetIndex(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
375
+ return withPipelineLock(this.storageBase, () => this.resetIndexLocked());
376
+ }
377
+
378
+ private async resetIndexLocked(): Promise<{ deletedFiles: number; freedBytes: number; errors: string[] }> {
379
+ try {
380
+ this.backendInstance?.close();
381
+ } catch {
382
+ // already closed
383
+ }
384
+ this.backendInstance = null;
385
+ this.retrieverInstance = null;
386
+ // Mutate the runtime object rather than replacing it: reindex calls that
387
+ // were queued behind this reset captured the same object and must see the
388
+ // closed backend dropped before they execute. Embedder and reranker
389
+ // survive: they hold no files.
390
+ if (this.injectedRuntime) this.injectedRuntime.backend = null;
391
+ // A reset index may be rebuilt by the next automatic kick.
392
+ this.autoIndexDone = false;
393
+ let deletedFiles = 0;
394
+ let freedBytes = 0;
395
+ const errors: string[] = [];
396
+ const countAndDelete = (target: string): void => {
397
+ let stat: fs.Stats;
398
+ try {
399
+ stat = fs.statSync(target);
400
+ } catch {
401
+ return; // not there — nothing to free
402
+ }
403
+ let files = 0;
404
+ let bytes = 0;
405
+ const measure = (p: string, s: fs.Stats): void => {
406
+ if (s.isDirectory()) {
407
+ for (const entry of fs.readdirSync(p)) {
408
+ const child = path.join(p, entry);
409
+ try {
410
+ measure(child, fs.statSync(child));
411
+ } catch {
412
+ // raced away between readdir and stat
413
+ }
414
+ }
415
+ return;
416
+ }
417
+ files++;
418
+ bytes += s.size;
419
+ };
420
+ try {
421
+ measure(target, stat);
422
+ fs.rmSync(target, { recursive: true, force: true });
423
+ // Only report bytes actually released; a locked target must not
424
+ // inflate the success report when rmSync fails.
425
+ deletedFiles += files;
426
+ freedBytes += bytes;
427
+ } catch (err) {
428
+ // Locked file (another session?) — report instead of failing the reset.
429
+ errors.push(`${path.relative(this.storageBase, target) || path.basename(target)}: ${(err as Error).message.slice(0, 80)}`);
430
+ }
431
+ };
432
+ const graphDb = path.join(this.storageBase, "graph.db");
433
+ const embeddingCache = path.join(this.storageBase, "embedding_cache.db");
434
+ for (const target of [
435
+ path.join(this.storageBase, "manifest.json"),
436
+ path.join(this.storageBase, "vectors"),
437
+ path.join(this.storageBase, "fts"),
438
+ graphDb,
439
+ `${graphDb}-shm`,
440
+ `${graphDb}-wal`,
441
+ embeddingCache,
442
+ `${embeddingCache}-shm`,
443
+ `${embeddingCache}-wal`,
444
+ ]) {
445
+ countAndDelete(target);
446
+ }
447
+ return { deletedFiles, freedBytes, errors };
448
+ }
449
+
361
450
  async reindex(options: { full?: boolean; targetPath?: string | null; automatic?: boolean; onProgress?: (msg: string) => void } = {}): Promise<{
362
451
  indexed: number;
363
452
  chunks: number;
package/src/extension.ts CHANGED
@@ -10,10 +10,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { Type } from "typebox";
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
+ import { spawn } from "node:child_process";
13
14
  import { ContextEngine, isPceConfig } from "./engine.js";
14
15
  import { applyContextSearchGuidance } from "./guidance.js";
15
16
  import { ProjectWatcher } from "./indexer/watcher.js";
16
17
  import { AutomaticIndexFileLimitError } from "./indexer/pipeline.js";
18
+ import { DashboardServer } from "./dashboard/server.js";
17
19
 
18
20
  /** Tools whose payloads are worth capturing for session timelines. */
19
21
  const CAPTURED_TOOLS = new Set(["bash", "read", "edit", "write", "grep", "find", "ls"]);
@@ -66,6 +68,14 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
66
68
  let watcherKickPending = false;
67
69
  let watcherNotify: ((msg: string, level: "info" | "warning") => void) | undefined;
68
70
 
71
+ /**
72
+ * Read-only web dashboard (config `dashboard.port`, 0 = free port). Owned
73
+ * by this closure like the watcher: it must outlive individual command
74
+ * invocations, reference the LIVE engine on every request (engine
75
+ * instances are replaced per session), and stop on session_shutdown.
76
+ */
77
+ let dashboard: DashboardServer | null = null;
78
+
69
79
  /**
70
80
  * Watcher-triggered incremental reindex. Unlike `kickBackgroundIndex` this
71
81
  * calls `reindex({ automatic: true })` directly — the watcher must re-fire
@@ -245,6 +255,20 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
245
255
  return engine;
246
256
  };
247
257
 
258
+ /** Human-readable byte count for the reset report (explicit user action —
259
+ * the automatic guard never measures bytes by design). */
260
+ const formatBytes = (n: number): string => {
261
+ if (n < 1024) return `${n} B`;
262
+ const units = ["KiB", "MiB", "GiB"];
263
+ let v = n / 1024;
264
+ let u = 0;
265
+ while (v >= 1024 && u < units.length - 1) {
266
+ v /= 1024;
267
+ u++;
268
+ }
269
+ return `${v.toFixed(v >= 10 ? 0 : 1)} ${units[u]}`;
270
+ };
271
+
248
272
  const text = (body: string, details?: unknown) => ({
249
273
  content: [{ type: "text" as const, text: body }],
250
274
  details: details ?? {},
@@ -495,9 +519,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
495
519
  /* ── Commands ────────────────────────────────────────────────────────── */
496
520
 
497
521
  pi.registerCommand("context-index", {
498
- description: "Build or refresh the semantic code index: /context-index [--full]",
522
+ description: "Build or refresh the semantic code index: /context-index [--full] [--reset]",
499
523
  handler: async (args, ctx) => {
500
524
  const eng = getEngine(ctx.cwd);
525
+ // --reset: delete the index through the engine (handles closed first, so
526
+ // it works while THIS session runs — manual deletion hits locked-file
527
+ // errors). Memory, sessions and savings survive. No auto-rebuild: the
528
+ // next search or /context-index decides, so an oversized workspace is
529
+ // not ambushed with a multi-minute build it just escaped.
530
+ if (args.includes("--reset")) {
531
+ if (indexInFlight) {
532
+ ctx.ui.notify(
533
+ "Context index: indexing is in progress — run /context-index --reset again once it finishes.",
534
+ "warning",
535
+ );
536
+ return;
537
+ }
538
+ // Reset also cancels the delayed startup kick. Otherwise its timer
539
+ // would rebuild the index immediately after the explicit delete.
540
+ if (autoIndexTimer) {
541
+ clearTimeout(autoIndexTimer);
542
+ autoIndexTimer = null;
543
+ }
544
+ const report = await eng.resetIndex();
545
+ // Free the lazy kick so the next search can rebuild (guarded by the
546
+ // same file budget as every automatic run).
547
+ lazyIndexStarted = false;
548
+ if (report.errors.length > 0) {
549
+ ctx.ui.notify(
550
+ `⚠ Index reset incomplete: ${report.errors.length} target(s) locked (${report.errors[0]}). Close other sessions on this project and run /context-index --reset again.`,
551
+ "warning",
552
+ );
553
+ } else {
554
+ ctx.ui.notify(
555
+ `✓ Index deleted: ${report.deletedFiles} file(s), ${formatBytes(report.freedBytes)} freed — memory, sessions and savings kept. ` +
556
+ "Run /context-index to build a fresh index.",
557
+ "info",
558
+ );
559
+ }
560
+ return;
561
+ }
501
562
  const full = args.includes("--full");
502
563
  ctx.ui.notify(full ? "Re-indexing everything…" : "Indexing (incremental)…", "info");
503
564
  const result = await eng.reindex({ full });
@@ -553,6 +614,46 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
553
614
  },
554
615
  });
555
616
 
617
+ pi.registerCommand("context-dashboard", {
618
+ description: "Open the read-only web dashboard (index health, files, sessions, memory, savings) in your browser",
619
+ handler: async (_args, ctx) => {
620
+ const openBrowser = (url: string): void => {
621
+ const cmd = process.platform === "win32" ? "cmd.exe" : process.platform === "darwin" ? "open" : "xdg-open";
622
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
623
+ try {
624
+ spawn(cmd, args, { detached: true, stdio: "ignore" }).unref();
625
+ } catch {
626
+ // best-effort: the URL is printed either way
627
+ }
628
+ };
629
+ let url = dashboard?.url ?? null;
630
+ if (!dashboard || !url) {
631
+ const eng = getEngine(ctx.cwd);
632
+ dashboard = new DashboardServer();
633
+ try {
634
+ url = await dashboard.start({
635
+ engine: () => engine,
636
+ indexing: () => indexInFlight,
637
+ port: eng.config.dashboardPort,
638
+ });
639
+ } catch (err) {
640
+ dashboard = null;
641
+ ctx.ui.notify(
642
+ `⚠ Dashboard failed to start: ${(err as Error).message}. ` +
643
+ `If dashboard.port is set, another process may be using it.`,
644
+ "warning",
645
+ );
646
+ return;
647
+ }
648
+ }
649
+ openBrowser(url);
650
+ ctx.ui.notify(
651
+ `✓ Dashboard: ${url} (read-only, localhost only — stops when this session ends)`,
652
+ "info",
653
+ );
654
+ },
655
+ });
656
+
556
657
  /* ── Lifecycle hooks (replace upstream's curl-based hooks) ───────────── */
557
658
 
558
659
  pi.on("session_start", async (_event, ctx) => {
@@ -654,6 +755,11 @@ export default function contextEngineExtension(pi: ExtensionAPI) {
654
755
  stopWatcher();
655
756
  watcherNotify = undefined;
656
757
  automaticIndexBlock = null;
758
+ if (dashboard) {
759
+ const d = dashboard;
760
+ dashboard = null;
761
+ await d.stop().catch(() => undefined);
762
+ }
657
763
  await engine?.shutdown().catch(() => undefined);
658
764
  engine = null;
659
765
  });
package/src/guidance.ts CHANGED
@@ -100,6 +100,7 @@ If the first search misses:
100
100
  - If a search says the index is empty, a background index is already running - retry shortly, or run \`/context-index\` for a synchronous build
101
101
  - After large refactors, \`reindex\` (or \`/context-index\`) keeps results current; \`index_status\` reports freshness and measured token savings
102
102
  - Incremental re-index only re-reads changed files, so it is cheap
103
+ - To wipe the index (stale or corrupted results), \`/context-index --reset\` deletes it safely mid-session - memory and sessions survive; the next search rebuilds
103
104
  `;
104
105
 
105
106
  /**
@@ -407,6 +407,22 @@ export async function indexCommits(
407
407
  */
408
408
  const pipelineLocks = new Map<string, Promise<unknown>>();
409
409
 
410
+ /**
411
+ * Queue work behind every indexing run already registered for this storage
412
+ * dir, and keep later runs queued until the work finishes. Reset uses this
413
+ * instead of a one-shot idle wait: a reindex queued during that wait must not
414
+ * open the stores while deletion is in progress.
415
+ */
416
+ export async function withPipelineLock<T>(storageBase: string, work: () => T | Promise<T>): Promise<T> {
417
+ const previous = pipelineLocks.get(storageBase) ?? Promise.resolve();
418
+ const current = previous.then(() => work());
419
+ pipelineLocks.set(
420
+ storageBase,
421
+ current.catch(() => undefined),
422
+ );
423
+ return current;
424
+ }
425
+
410
426
  export async function runIndexing(
411
427
  config: Config,
412
428
  projectDirInput: string,
@@ -416,23 +432,18 @@ export async function runIndexing(
416
432
  /** Automatic full-project runs only. Stop before migrations when the workspace exceeds this budget. */
417
433
  maxAutoFiles?: number;
418
434
  callbacks?: IndexCallbacks;
419
- /** Reuse an already-constructed backend/embedder (the long-lived server path). */
420
- runtime?: { backend: LocalBackend; embedder: Embedder };
435
+ /** Reuse an already-constructed backend/embedder (the long-lived server path);
436
+ * a null backend falls through to a fresh LocalBackend (used by resetIndex). */
437
+ runtime?: { backend?: LocalBackend | null; embedder: Embedder };
421
438
  } = {},
422
439
  ): Promise<IndexResult> {
423
440
  const projectDir = fs.realpathSync(path.resolve(projectDirInput));
424
441
  const storageBase = projectStorageDir(config.storagePath, projectDir);
425
442
  fs.mkdirSync(storageBase, { recursive: true });
426
443
 
427
- const previous = pipelineLocks.get(storageBase) ?? Promise.resolve();
428
- const current = previous.then(() =>
444
+ return withPipelineLock(storageBase, () =>
429
445
  runIndexingLocked(config, projectDir, storageBase, options, options.runtime),
430
446
  );
431
- pipelineLocks.set(
432
- storageBase,
433
- current.catch(() => undefined),
434
- );
435
- return current;
436
447
  }
437
448
 
438
449
  async function runIndexingLocked(
@@ -440,13 +451,16 @@ async function runIndexingLocked(
440
451
  projectDir: string,
441
452
  storageBase: string,
442
453
  options: { full?: boolean; targetPath?: string | null; maxAutoFiles?: number; callbacks?: IndexCallbacks },
443
- runtime?: { backend: LocalBackend; embedder: Embedder },
454
+ runtime?: { backend?: LocalBackend | null; embedder: Embedder },
444
455
  ): Promise<IndexResult> {
445
456
  let { full = false, targetPath = null } = options;
446
457
  const { callbacks = {} } = options;
447
458
  const { log, progress, phase } = callbacks;
448
459
 
449
- const ownsBackend = runtime === undefined;
460
+ // The caller only owns the backend it actually passed in; a null backend
461
+ // (resetIndex dropped its closed handle) means the pipeline built its own
462
+ // and must close it — otherwise its sqlite handles leak until exit.
463
+ const ownsBackend = !runtime?.backend;
450
464
  const chunker = new Chunker();
451
465
  const manifest = new Manifest(path.join(storageBase, "manifest.json"));
452
466
  const result: IndexResult = {
package/src/memory/db.ts CHANGED
@@ -571,6 +571,168 @@ CREATE VIRTUAL TABLE IF NOT EXISTS turn_summaries_vec USING vec0(embedding float
571
571
  return out;
572
572
  }
573
573
 
574
+ // ── Read-only views (dashboard) ───────────────────────────────────────
575
+ // These never write and never touch the savings ledger — they exist so the
576
+ // web dashboard can render history without going through the text renderers
577
+ // (which record progressive_disclosure savings as a side effect).
578
+
579
+ /** Recent sessions, newest first, with prompt/turn counts. */
580
+ listSessions(limit = 50): {
581
+ id: string;
582
+ project: string;
583
+ started_at: string;
584
+ ended_at: string | null;
585
+ status: string;
586
+ promptCount: number;
587
+ rollup: string | null;
588
+ }[] {
589
+ try {
590
+ const rows = this.db
591
+ .prepare(
592
+ `SELECT s.id, s.project, s.started_at, s.ended_at, s.status, s.rollup_summary,
593
+ (SELECT COUNT(*) FROM prompts p WHERE p.session_id = s.id) AS prompt_count
594
+ FROM sessions s ORDER BY s.started_at_epoch DESC LIMIT ?`,
595
+ )
596
+ .all(Math.max(1, Math.min(limit, 500))) as {
597
+ id: string;
598
+ project: string;
599
+ started_at: string;
600
+ ended_at: string | null;
601
+ status: string;
602
+ rollup_summary: string | null;
603
+ prompt_count: number | null;
604
+ }[];
605
+ return rows.map((r) => ({
606
+ id: r.id,
607
+ project: r.project,
608
+ started_at: r.started_at,
609
+ ended_at: r.ended_at,
610
+ status: r.status,
611
+ promptCount: Number(r.prompt_count ?? 0),
612
+ rollup: r.rollup_summary,
613
+ }));
614
+ } catch {
615
+ return [];
616
+ }
617
+ }
618
+
619
+ /** Turn summaries + tool-event activity for one session, in turn order. */
620
+ sessionDetail(
621
+ sessionId: string,
622
+ turnLimit = 200,
623
+ ): {
624
+ turns: { promptNumber: number; tier: string; summary: string }[];
625
+ events: { id: number; tool: string; promptNumber: number; createdAt: string; hasPayload: boolean }[];
626
+ } {
627
+ const empty = { turns: [], events: [] as { id: number; tool: string; promptNumber: number; createdAt: string; hasPayload: boolean }[] };
628
+ try {
629
+ const turns = this.db
630
+ .prepare(
631
+ "SELECT prompt_number, tier, summary FROM turn_summaries WHERE session_id = ? ORDER BY prompt_number ASC LIMIT ?",
632
+ )
633
+ .all(sessionId, Math.max(1, Math.min(turnLimit, 500))) as {
634
+ prompt_number: number;
635
+ tier: string;
636
+ summary: string;
637
+ }[];
638
+ const events = this.db
639
+ .prepare(
640
+ `SELECT te.id, te.tool_name, te.prompt_number, te.created_at, (te.payload_id IS NOT NULL) AS has_payload
641
+ FROM tool_events te WHERE te.session_id = ? ORDER BY te.id ASC LIMIT ?`,
642
+ )
643
+ .all(sessionId, Math.max(1, Math.min(turnLimit * 20, 2000))) as {
644
+ id: number;
645
+ tool_name: string;
646
+ prompt_number: number;
647
+ created_at: string;
648
+ has_payload: number;
649
+ }[];
650
+ return {
651
+ turns: turns.map((t) => ({ promptNumber: t.prompt_number, tier: t.tier, summary: t.summary })),
652
+ events: events.map((e) => ({
653
+ id: e.id,
654
+ tool: e.tool_name,
655
+ promptNumber: e.prompt_number,
656
+ createdAt: e.created_at,
657
+ hasPayload: Number(e.has_payload) === 1,
658
+ })),
659
+ };
660
+ } catch {
661
+ return empty;
662
+ }
663
+ }
664
+
665
+ /** Recent decisions, newest first. */
666
+ listDecisions(limit = 100): { id: number; decision: string; reason: string; source: string; createdAt: string }[] {
667
+ try {
668
+ const rows = this.db
669
+ .prepare("SELECT id, decision, reason, source, created_at FROM decisions ORDER BY created_at_epoch DESC LIMIT ?")
670
+ .all(Math.max(1, Math.min(limit, 500))) as {
671
+ id: number;
672
+ decision: string;
673
+ reason: string;
674
+ source: string;
675
+ created_at: string;
676
+ }[];
677
+ return rows.map((r) => ({ id: r.id, decision: r.decision, reason: r.reason, source: r.source, createdAt: r.created_at }));
678
+ } catch {
679
+ return [];
680
+ }
681
+ }
682
+
683
+ /** Recent code areas, newest first. */
684
+ listCodeAreas(limit = 100): { id: number; filePath: string; description: string; source: string; createdAt: string }[] {
685
+ try {
686
+ const rows = this.db
687
+ .prepare("SELECT id, file_path, description, source, created_at FROM code_areas ORDER BY created_at_epoch DESC LIMIT ?")
688
+ .all(Math.max(1, Math.min(limit, 500))) as {
689
+ id: number;
690
+ file_path: string;
691
+ description: string;
692
+ source: string;
693
+ created_at: string;
694
+ }[];
695
+ return rows.map((r) => ({ id: r.id, filePath: r.file_path, description: r.description, source: r.source, createdAt: r.created_at }));
696
+ } catch {
697
+ return [];
698
+ }
699
+ }
700
+
701
+ /** Raw savings events, newest first. */
702
+ recentSavings(limit = 200): { bucket: string; baseline: number; served: number; ts: number }[] {
703
+ try {
704
+ const rows = this.db
705
+ .prepare("SELECT bucket, baseline, served, ts FROM savings_log ORDER BY ts DESC, id DESC LIMIT ?")
706
+ .all(Math.max(1, Math.min(limit, 2000))) as { bucket: string; baseline: number; served: number; ts: number }[];
707
+ return rows.map((r) => ({ bucket: r.bucket, baseline: r.baseline, served: r.served, ts: r.ts }));
708
+ } catch {
709
+ return [];
710
+ }
711
+ }
712
+
713
+ /** Savings per local calendar day (oldest → newest) for trend charts. */
714
+ savingsByDay(days = 30): { day: string; baseline: number; served: number }[] {
715
+ const out: { day: string; baseline: number; served: number }[] = [];
716
+ try {
717
+ const cutoff = epochSeconds() - Math.max(1, Math.min(days, 365)) * 86400;
718
+ const rows = this.db
719
+ .prepare("SELECT ts, baseline, served FROM savings_log WHERE ts >= ? ORDER BY ts ASC")
720
+ .all(cutoff) as { ts: number; baseline: number; served: number }[];
721
+ const byDay = new Map<string, { baseline: number; served: number }>();
722
+ for (const r of rows) {
723
+ const day = new Date(r.ts * 1000).toISOString().slice(0, 10);
724
+ const acc = byDay.get(day) ?? { baseline: 0, served: 0 };
725
+ acc.baseline += r.baseline;
726
+ acc.served += r.served;
727
+ byDay.set(day, acc);
728
+ }
729
+ for (const [day, v] of [...byDay.entries()].sort()) out.push({ day, ...v });
730
+ } catch {
731
+ // empty series
732
+ }
733
+ return out;
734
+ }
735
+
574
736
  // ── Retention ─────────────────────────────────────────────────────────
575
737
 
576
738
  pruneOldPayloads(days = 30): { payloadsPruned: number; bytesFreedEstimate: number } {