pi-mega-compact 0.11.1 → 0.11.3

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.
Files changed (109) hide show
  1. package/dist/extensions/dashboard-server/api-contracts/endpoints.js +28 -1
  2. package/dist/extensions/dashboard-server/api-contracts/maintenance.js +7 -0
  3. package/dist/extensions/dashboard-server/api-contracts/provider-cache.js +7 -0
  4. package/dist/extensions/dashboard-server/api-contracts.test.js +3 -2
  5. package/dist/extensions/dashboard-server/index-reader.js +22 -0
  6. package/dist/extensions/dashboard-server/routes-cache.js +80 -0
  7. package/dist/extensions/dashboard-server/routes-cache.test.js +248 -0
  8. package/dist/extensions/dashboard-server/routes-maintenance.js +293 -0
  9. package/dist/extensions/dashboard-server/routes-repo.js +24 -0
  10. package/dist/extensions/dashboard-server/routes.js +2 -0
  11. package/dist/extensions/dashboard-server/server.js +5 -1
  12. package/dist/extensions/mega-cache-replay.test.js +233 -24
  13. package/dist/extensions/mega-compact-s38.test.js +201 -6
  14. package/dist/extensions/mega-config.js +3 -0
  15. package/dist/extensions/mega-events/agent-handlers.js +23 -7
  16. package/dist/extensions/mega-events/context-handler.js +37 -19
  17. package/dist/extensions/mega-events/error-classifier.js +27 -16
  18. package/dist/extensions/mega-runtime/runtime-snapshot.js +14 -4
  19. package/dist/extensions/mega-runtime/snapshot.js +1 -1
  20. package/dist/extensions/mega-runtime/snapshot.test.js +127 -0
  21. package/dist/extensions/mega-runtime/widget.js +4 -4
  22. package/dist/extensions/mega-runtime/widget.test.js +103 -12
  23. package/dist/src/pricing.js +78 -0
  24. package/dist/src/store/sqlite/maintenance.js +20 -1
  25. package/dist/src/store/sqlite/perf-samples.js +104 -0
  26. package/dist/src/store/sqlite/perf-samples.test.js +93 -1
  27. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js → AchievementTiles-vEo2MtE2.js} +2 -2
  28. package/extensions/dashboard-client/dist/assets/{AchievementTiles-DZp_xQOW.js.map → AchievementTiles-vEo2MtE2.js.map} +1 -1
  29. package/extensions/dashboard-client/dist/assets/AchievementsTab-DjxfYrDc.js +2 -0
  30. package/extensions/dashboard-client/dist/assets/{AchievementsTab-BzFHpAHM.js.map → AchievementsTab-DjxfYrDc.js.map} +1 -1
  31. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js +2 -0
  32. package/extensions/dashboard-client/dist/assets/CacheTab-Dnpv94wq.js.map +1 -0
  33. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js → ConfigTab-Cm6ymbmx.js} +2 -2
  34. package/extensions/dashboard-client/dist/assets/{ConfigTab-DQ4FG33J.js.map → ConfigTab-Cm6ymbmx.js.map} +1 -1
  35. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js → EventsTab-DXWl_YzI.js} +2 -2
  36. package/extensions/dashboard-client/dist/assets/{EventsTab-D5HoEVYo.js.map → EventsTab-DXWl_YzI.js.map} +1 -1
  37. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js → GameTab-BLDudcO5.js} +2 -2
  38. package/extensions/dashboard-client/dist/assets/{GameTab-Jzsc4yeY.js.map → GameTab-BLDudcO5.js.map} +1 -1
  39. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js +2 -0
  40. package/extensions/dashboard-client/dist/assets/MaintenanceTab-DLkKCztn.js.map +1 -0
  41. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js → MetricsTab-Cq4u-rZF.js} +2 -2
  42. package/extensions/dashboard-client/dist/assets/{MetricsTab-D_XnsEYX.js.map → MetricsTab-Cq4u-rZF.js.map} +1 -1
  43. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js → OverviewTab-D4kGvsiQ.js} +2 -2
  44. package/extensions/dashboard-client/dist/assets/{OverviewTab-BMREvAth.js.map → OverviewTab-D4kGvsiQ.js.map} +1 -1
  45. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js +2 -0
  46. package/extensions/dashboard-client/dist/assets/ReposTab-C0Wnrf2u.js.map +1 -0
  47. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js → SessionsTab-DZC2-GCn.js} +2 -2
  48. package/extensions/dashboard-client/dist/assets/{SessionsTab-mz_vz8i2.js.map → SessionsTab-DZC2-GCn.js.map} +1 -1
  49. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js +2 -0
  50. package/extensions/dashboard-client/dist/assets/TimeSavedCard-UqljSyP-.js.map +1 -0
  51. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js → TopicsTab-01G71hv1.js} +2 -2
  52. package/extensions/dashboard-client/dist/assets/{TopicsTab-DKuct4oD.js.map → TopicsTab-01G71hv1.js.map} +1 -1
  53. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js → TurnsTab-DLRg0LZT.js} +2 -2
  54. package/extensions/dashboard-client/dist/assets/{TurnsTab-11MGLxY8.js.map → TurnsTab-DLRg0LZT.js.map} +1 -1
  55. package/extensions/dashboard-client/dist/assets/index-BEdKlpAB.css +1 -0
  56. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js → index-BX0bcgLu.js} +10 -10
  57. package/extensions/dashboard-client/dist/assets/{index-CqLN7STl.js.map → index-BX0bcgLu.js.map} +1 -1
  58. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js → useSSE-BBYt5UPB.js} +2 -2
  59. package/extensions/dashboard-client/dist/assets/{useSSE-CXrQSbVY.js.map → useSSE-BBYt5UPB.js.map} +1 -1
  60. package/extensions/dashboard-client/dist/index.html +2 -2
  61. package/extensions/dashboard-client/src/App.tsx +5 -1
  62. package/extensions/dashboard-client/src/api/client.ts +29 -0
  63. package/extensions/dashboard-client/src/components/ActiveReposTable.tsx +13 -1
  64. package/extensions/dashboard-client/src/components/ProviderCacheCard.tsx +141 -0
  65. package/extensions/dashboard-client/src/components/SavingsByModelTable.tsx +31 -3
  66. package/extensions/dashboard-client/src/main.tsx +1 -0
  67. package/extensions/dashboard-client/src/styles/maintenance.css +267 -0
  68. package/extensions/dashboard-client/src/styles/overview-extra.css +9 -0
  69. package/extensions/dashboard-client/src/tabs/CacheTab.tsx +83 -29
  70. package/extensions/dashboard-client/src/tabs/MaintenanceTab.tsx +458 -0
  71. package/extensions/dashboard-client/src/utils/format.ts +42 -7
  72. package/extensions/dashboard-server/api-contracts/endpoints.ts +54 -1
  73. package/extensions/dashboard-server/api-contracts/index.ts +16 -0
  74. package/extensions/dashboard-server/api-contracts/maintenance.ts +84 -0
  75. package/extensions/dashboard-server/api-contracts/multi-repo.ts +6 -0
  76. package/extensions/dashboard-server/api-contracts/provider-cache.ts +30 -0
  77. package/extensions/dashboard-server/api-contracts.test.ts +3 -2
  78. package/extensions/dashboard-server/api-contracts.ts +9 -0
  79. package/extensions/dashboard-server/index-reader.ts +23 -0
  80. package/extensions/dashboard-server/routes-cache.test.ts +302 -0
  81. package/extensions/dashboard-server/routes-cache.ts +123 -0
  82. package/extensions/dashboard-server/routes-maintenance.ts +352 -0
  83. package/extensions/dashboard-server/routes-repo.ts +31 -4
  84. package/extensions/dashboard-server/routes.ts +2 -0
  85. package/extensions/dashboard-server/server.ts +4 -0
  86. package/extensions/mega-cache-replay.test.ts +481 -166
  87. package/extensions/mega-compact-s38.test.ts +210 -6
  88. package/extensions/mega-config.ts +6 -0
  89. package/extensions/mega-events/agent-handlers.ts +24 -6
  90. package/extensions/mega-events/context-handler.ts +59 -24
  91. package/extensions/mega-events/error-classifier.ts +30 -13
  92. package/extensions/mega-runtime/runtime-snapshot.ts +31 -9
  93. package/extensions/mega-runtime/snapshot.test.ts +139 -0
  94. package/extensions/mega-runtime/snapshot.ts +6 -4
  95. package/extensions/mega-runtime/widget.test.ts +290 -105
  96. package/extensions/mega-runtime/widget.ts +29 -6
  97. package/package.json +1 -1
  98. package/src/pricing.ts +95 -0
  99. package/src/store/sqlite/maintenance.ts +20 -1
  100. package/src/store/sqlite/perf-samples.test.ts +107 -3
  101. package/src/store/sqlite/perf-samples.ts +142 -6
  102. package/extensions/dashboard-client/dist/assets/AchievementsTab-BzFHpAHM.js +0 -2
  103. package/extensions/dashboard-client/dist/assets/CacheTab-DEQbTq2T.js +0 -2
  104. package/extensions/dashboard-client/dist/assets/CacheTab-DEQbTq2T.js.map +0 -1
  105. package/extensions/dashboard-client/dist/assets/ReposTab-DuyhOktG.js +0 -2
  106. package/extensions/dashboard-client/dist/assets/ReposTab-DuyhOktG.js.map +0 -1
  107. package/extensions/dashboard-client/dist/assets/TimeSavedCard-B8AIjNTY.js +0 -2
  108. package/extensions/dashboard-client/dist/assets/TimeSavedCard-B8AIjNTY.js.map +0 -1
  109. package/extensions/dashboard-client/dist/assets/index-gn66lYD2.css +0 -1
@@ -0,0 +1,123 @@
1
+ /**
2
+ * dashboard-server/routes-cache.ts — Provider prompt-cache stats route handler.
3
+ *
4
+ * GET /api/provider-cache — Lifetime provider prompt cache hit-rate aggregates
5
+ * plus dollar savings estimate (priced from latest model snapshot).
6
+ *
7
+ * Guardrails: PREVENT-PI-004 (loopback-only), PREVENT-001 (null-safe JSON parse),
8
+ * PREVENT-002 (parameterized SQL). Loopback-only localhost dashboard endpoint.
9
+ */
10
+
11
+ import { createRequire } from "node:module";
12
+ import type { IncomingMessage, ServerResponse } from "node:http";
13
+
14
+ import type { RouteContext } from "./routes-core.js";
15
+ import { computeCacheSavings } from "../../src/pricing.js";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // handleProviderCache — "/api/provider-cache"
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export function handleProviderCache(
22
+ req: IncomingMessage,
23
+ res: ServerResponse,
24
+ ctx: RouteContext,
25
+ ): boolean {
26
+ if (!req.url?.startsWith("/api/provider-cache")) return false;
27
+
28
+ const { stateDir } = ctx;
29
+
30
+ // /api/provider-cache — GET returns lifetime provider prompt cache aggregates
31
+ // from perf_samples (cache_hit_pct rows with meta JSON) plus an optional
32
+ // dollar savings estimate priced from the latest model snapshot. The dashboard
33
+ // server is a detached child with no MegaRuntime ref, so it reads via a
34
+ // require()'d sqlite helper (same pattern as /api/perf). Non-GET → 405.
35
+ // PREVENT-PI-004: loopback.
36
+ const pfReq = createRequire(import.meta.url);
37
+ const { readProviderCacheLifetime, latestModelSnapshot } = pfReq(
38
+ "../../src/store/sqlite.js",
39
+ ) as typeof import("../../src/store/sqlite.js");
40
+ if (req.method !== "GET") {
41
+ // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
42
+ res.writeHead(405, { "Content-Type": "application/json" });
43
+ res.end(JSON.stringify({ error: "method_not_allowed" }));
44
+ return true;
45
+ }
46
+ try {
47
+ const lifetime = readProviderCacheLifetime(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
48
+
49
+ // D3: compute dollar savings from the latest model snapshot. Null-safe:
50
+ // when no snapshot or zero input rate, savings stays null (UI shows "—").
51
+ let savings: {
52
+ cacheReadSaved: number;
53
+ cacheWriteCost: number;
54
+ netSaved: number;
55
+ model: string;
56
+ inputRate: number;
57
+ } | null = null;
58
+ try {
59
+ const snap = latestModelSnapshot(stateDir); // guardrails-allow PREVENT-PI-004: local SQLite read (loopback dashboard)
60
+ if (snap && snap.inputRate > 0) {
61
+ const s = computeCacheSavings(
62
+ lifetime.totalCacheRead,
63
+ lifetime.totalCacheWrite,
64
+ snap.inputRate,
65
+ );
66
+ savings = {
67
+ cacheReadSaved: s.cacheReadSaved,
68
+ cacheWriteCost: s.cacheWriteCost,
69
+ netSaved: s.netSaved,
70
+ model: snap.modelName ?? snap.modelId ?? "unknown",
71
+ inputRate: snap.inputRate,
72
+ };
73
+ }
74
+ } catch {
75
+ /* pricing unavailable — savings stays null */
76
+ }
77
+
78
+ const body: {
79
+ cache: {
80
+ avgHitPct: number;
81
+ turnCount: number;
82
+ totalCacheRead: number;
83
+ totalCacheWrite: number;
84
+ totalInput: number;
85
+ firstTurnAt: string | null;
86
+ latestTurnAt: string | null;
87
+ };
88
+ savings: {
89
+ cacheReadSaved: number;
90
+ cacheWriteCost: number;
91
+ netSaved: number;
92
+ model: string;
93
+ inputRate: number;
94
+ } | null;
95
+ updatedAt: string;
96
+ } = {
97
+ cache: {
98
+ avgHitPct: lifetime.avgHitPct,
99
+ turnCount: lifetime.sampleCount,
100
+ totalCacheRead: lifetime.totalCacheRead,
101
+ totalCacheWrite: lifetime.totalCacheWrite,
102
+ totalInput: lifetime.totalInput,
103
+ firstTurnAt: lifetime.firstSampleAt,
104
+ latestTurnAt: lifetime.latestSampleAt,
105
+ },
106
+ savings,
107
+ updatedAt: new Date().toISOString(),
108
+ };
109
+ // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
110
+ res.writeHead(200, { "Content-Type": "application/json" });
111
+ res.end(JSON.stringify(body));
112
+ } catch (e) {
113
+ // guardrails-allow PREVENT-PI-004: loopback dashboard response (local)
114
+ res.writeHead(500, { "Content-Type": "application/json" });
115
+ res.end(
116
+ JSON.stringify({
117
+ error: "provider_cache_unavailable",
118
+ detail: String(e),
119
+ }),
120
+ );
121
+ }
122
+ return true;
123
+ }
@@ -0,0 +1,352 @@
1
+ /**
2
+ * dashboard-server/routes-maintenance.ts — S49B Maintenance tab API routes.
3
+ *
4
+ * Handlers for:
5
+ * GET /api/maintenance — DB stats (table row counts + storage)
6
+ * GET /api/maintenance/schema-health — SCHEMA_VERSION + integrity audit
7
+ * POST /api/maintenance/action — Trigger a maintenance action
8
+ *
9
+ * All pi-agnostic; lazy-requires maintenance primitives to avoid a top-level
10
+ * import of the store from the dashboard server process. PREVENT-PI-004: local
11
+ * SQLite only; zero network calls.
12
+ */
13
+
14
+ import { createRequire } from "node:module";
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+
17
+ import type { RouteContext } from "./routes-core.js";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Lazy imports (via createRequire)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const lazyMaintenance = (() => {
24
+ let mod: typeof import("../../src/store/sqlite/maintenance.js") | null = null;
25
+ return () => {
26
+ if (!mod)
27
+ mod = createRequire(import.meta.url)(
28
+ "../../src/store/sqlite/maintenance.js",
29
+ ) as typeof import("../../src/store/sqlite/maintenance.js");
30
+ return mod;
31
+ };
32
+ })();
33
+
34
+ const lazyStoreUtils = (() => {
35
+ let mod: typeof import("../../src/store/sqlite/utils.js") | null = null;
36
+ return () => {
37
+ if (!mod)
38
+ mod = createRequire(import.meta.url)(
39
+ "../../src/store/sqlite/utils.js",
40
+ ) as typeof import("../../src/store/sqlite/utils.js");
41
+ return mod;
42
+ };
43
+ })();
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types for JSON deserialisation
47
+ // ---------------------------------------------------------------------------
48
+
49
+ interface MaintenanceActionBody {
50
+ action: string;
51
+ daysOld?: number | string;
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // GET /api/maintenance and sub-paths
56
+ // ---------------------------------------------------------------------------
57
+
58
+ export function handleMaintenance(
59
+ req: IncomingMessage,
60
+ res: ServerResponse,
61
+ ctx: RouteContext,
62
+ ): boolean {
63
+ const url = req.url ?? "";
64
+ if (!url.startsWith("/api/maintenance")) return false;
65
+
66
+ const method = (req.method ?? "GET").toUpperCase();
67
+
68
+ // ── GET /api/maintenance/schema-health ────────────────────────────
69
+ if (method === "GET" && url === "/api/maintenance/schema-health") {
70
+ sendSchemaHealth(res, ctx);
71
+ return true;
72
+ }
73
+
74
+ // ── POST /api/maintenance/action ──────────────────────────────────
75
+ if (method === "POST" && url === "/api/maintenance/action") {
76
+ handleMaintenanceAction(req, res, ctx);
77
+ return true;
78
+ }
79
+
80
+ // ── GET /api/maintenance ──────────────────────────────────────────
81
+ if (method === "GET" && url === "/api/maintenance") {
82
+ sendDbStats(res, ctx);
83
+ return true;
84
+ }
85
+
86
+ // ── Unknown sub-path ──────────────────────────────────────────────
87
+ sendJson(res, 404, { error: "not found" });
88
+ return true;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // GET /api/maintenance — DB stats
93
+ // ---------------------------------------------------------------------------
94
+
95
+ function sendDbStats(res: ServerResponse, ctx: RouteContext): void {
96
+ try {
97
+ const { getDbStats } = lazyMaintenance();
98
+ const raw = getDbStats(ctx.stateDir);
99
+
100
+ const tables = Object.entries(raw.tableCounts).map(([table, rowCount]) => ({
101
+ table,
102
+ rowCount,
103
+ }));
104
+
105
+ sendJson(res, 200, {
106
+ type: "db-stats" as const,
107
+ tables,
108
+ storage: {
109
+ files: {
110
+ dbBytes: raw.dbBytes,
111
+ walBytes: raw.walBytes,
112
+ shmBytes: raw.shmBytes,
113
+ },
114
+ pageSize: raw.pageSize,
115
+ pageCount: raw.pageCount,
116
+ freelistPages: raw.freelistPages,
117
+ },
118
+ });
119
+ } catch (err) {
120
+ sendJson(res, 500, { error: (err as Error).message });
121
+ }
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // GET /api/maintenance/schema-health — schema version + integrity audit
126
+ // ---------------------------------------------------------------------------
127
+
128
+ function sendSchemaHealth(res: ServerResponse, ctx: RouteContext): void {
129
+ try {
130
+ const { integrityCheck } = lazyMaintenance();
131
+ const integrity = integrityCheck(ctx.stateDir);
132
+
133
+ const { openStore } = lazyStoreUtils();
134
+ const db = openStore(ctx.stateDir);
135
+
136
+ // SCHEMA_VERSION from meta table
137
+ const metaRow = db
138
+ .prepare("SELECT value FROM meta WHERE key = ?")
139
+ .get("schema_version") as { value: string } | undefined;
140
+ const schemaVersion = metaRow ? Number(metaRow.value) : 0;
141
+
142
+ // PRAGMA foreign_key_check
143
+ const fkRows = db.prepare("PRAGMA foreign_key_check").all() as Array<
144
+ Record<string, unknown>
145
+ >;
146
+ const fkCheck = fkRows.map((r) => JSON.stringify(r));
147
+
148
+ // Per-column audit: every ensureColumn migration column declared in
149
+ // schema.ts, checked via PRAGMA table_info (read-only).
150
+ const expectedColumns: Array<{
151
+ table: string;
152
+ column: string;
153
+ decl: string;
154
+ }> = [
155
+ {
156
+ table: "context_chunks",
157
+ column: "original_token_estimate",
158
+ decl: "INTEGER",
159
+ },
160
+ { table: "raw_transcript", column: "content_ref", decl: "TEXT" },
161
+ { table: "memories", column: "category", decl: "TEXT" },
162
+ { table: "memories", column: "target", decl: "TEXT" },
163
+ { table: "memories", column: "last_referenced", decl: "INTEGER" },
164
+ { table: "memories", column: "source_turn", decl: "INTEGER" },
165
+ { table: "raptor_nodes", column: "built_at", decl: "INTEGER" },
166
+ { table: "raw_transcript", column: "turn_index", decl: "INTEGER" },
167
+ { table: "session_state", column: "conversation_id", decl: "TEXT" },
168
+ { table: "session_state", column: "last_turn_id", decl: "INTEGER" },
169
+ ];
170
+
171
+ const columns = expectedColumns.map((ec) => {
172
+ const cols = db.prepare(`PRAGMA table_info(${ec.table})`).all() as Array<{
173
+ name: string;
174
+ }>;
175
+ const present = cols.some((c) => c.name === ec.column);
176
+ return {
177
+ table: ec.table,
178
+ column: ec.column,
179
+ present,
180
+ expectedDecl: ec.decl,
181
+ };
182
+ });
183
+
184
+ const healthy =
185
+ integrity.length === 1 &&
186
+ integrity[0] === "ok" &&
187
+ columns.every((c) => c.present);
188
+
189
+ sendJson(res, 200, {
190
+ type: "schema-health" as const,
191
+ schemaVersion,
192
+ integrity,
193
+ fkCheck,
194
+ columns,
195
+ healthy,
196
+ });
197
+ } catch (err) {
198
+ sendJson(res, 500, { error: (err as Error).message });
199
+ }
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // POST /api/maintenance/action — Trigger a maintenance action
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function handleMaintenanceAction(
207
+ req: IncomingMessage,
208
+ res: ServerResponse,
209
+ ctx: RouteContext,
210
+ ): void {
211
+ let body = "";
212
+ req.setEncoding("utf8");
213
+ req.on("data", (chunk: string) => {
214
+ body += chunk;
215
+ if (body.length > 10_000) req.destroy();
216
+ });
217
+ req.on("end", () => {
218
+ let payload: MaintenanceActionBody;
219
+ try {
220
+ payload = JSON.parse(body) as MaintenanceActionBody;
221
+ if (!payload || !payload.action) {
222
+ sendJson(res, 400, { error: "missing 'action' field" });
223
+ return;
224
+ }
225
+ } catch {
226
+ sendJson(res, 400, { error: "invalid JSON" });
227
+ return;
228
+ }
229
+
230
+ try {
231
+ const m = lazyMaintenance();
232
+ const u = lazyStoreUtils();
233
+ // All destructive actions: temp backup first
234
+ if (payload.action !== "integrity-check") {
235
+ // Non-blocking — backup failure is non-fatal
236
+ m.backupDb(ctx.stateDir);
237
+ }
238
+
239
+ switch (payload.action) {
240
+ case "vacuum": {
241
+ const r = m.vacuumDb(ctx.stateDir);
242
+ sendJson(res, 200, {
243
+ operation: "vacuum",
244
+ success: true,
245
+ affected: r.affected,
246
+ reclaimedBytes: r.reclaimedBytes,
247
+ summary: r.summary,
248
+ });
249
+ return;
250
+ }
251
+ case "checkpoint": {
252
+ const r = m.checkpointWal(ctx.stateDir);
253
+ sendJson(res, 200, {
254
+ operation: "checkpoint",
255
+ success: true,
256
+ affected: r.affected,
257
+ reclaimedBytes: r.reclaimedBytes,
258
+ summary: r.summary,
259
+ });
260
+ return;
261
+ }
262
+ case "reindex": {
263
+ const db = u.openStore(ctx.stateDir);
264
+ db.exec("REINDEX");
265
+ sendJson(res, 200, {
266
+ operation: "reindex",
267
+ success: true,
268
+ affected: 0,
269
+ reclaimedBytes: 0,
270
+ summary: "REINDEX completed",
271
+ });
272
+ return;
273
+ }
274
+ case "fts5-rebuild": {
275
+ const db = u.openStore(ctx.stateDir);
276
+ db.exec(
277
+ "INSERT INTO context_chunks_trgm(context_chunks_trgm) VALUES('rebuild')",
278
+ );
279
+ sendJson(res, 200, {
280
+ operation: "fts5-rebuild",
281
+ success: true,
282
+ affected: 0,
283
+ reclaimedBytes: 0,
284
+ summary: "FTS5 rebuild triggered",
285
+ });
286
+ return;
287
+ }
288
+ case "reconcile-dedup": {
289
+ const r = m.reconcileDedupMirror(ctx.stateDir);
290
+ const lines: string[] = [];
291
+ if (r.fixedRefCount)
292
+ lines.push(`${r.fixedRefCount} ref_counts corrected`);
293
+ if (r.orphansDeleted)
294
+ lines.push(`${r.orphansDeleted} orphans deleted`);
295
+ if (r.refsBackfilled)
296
+ lines.push(`${r.refsBackfilled} refs backfilled`);
297
+ sendJson(res, 200, {
298
+ operation: "reconcile-dedup",
299
+ success: true,
300
+ affected: r.fixedRefCount + r.orphansDeleted + r.refsBackfilled,
301
+ reclaimedBytes: 0,
302
+ summary: lines.length ? lines.join(", ") : "nothing to fix",
303
+ });
304
+ return;
305
+ }
306
+ case "prune": {
307
+ const days = Number(payload.daysOld) || 30;
308
+ const r = m.pruneOldRows(ctx.stateDir, days);
309
+ sendJson(res, 200, {
310
+ operation: "prune",
311
+ success: true,
312
+ affected: r.affected,
313
+ reclaimedBytes: r.reclaimedBytes,
314
+ summary: r.summary,
315
+ });
316
+ return;
317
+ }
318
+ case "integrity-check": {
319
+ const lines = m.integrityCheck(ctx.stateDir);
320
+ sendJson(res, 200, {
321
+ operation: "integrity-check",
322
+ success: lines.length === 1 && lines[0] === "ok",
323
+ affected: 0,
324
+ reclaimedBytes: 0,
325
+ summary: lines.join("; "),
326
+ detail: lines,
327
+ });
328
+ return;
329
+ }
330
+ default:
331
+ sendJson(res, 400, {
332
+ error: `unknown action: ${payload.action}`,
333
+ });
334
+ }
335
+ } catch (err) {
336
+ sendJson(res, 500, { error: (err as Error).message });
337
+ }
338
+ });
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // JSON helper
343
+ // ---------------------------------------------------------------------------
344
+
345
+ function sendJson(res: ServerResponse, status: number, data: unknown): void {
346
+ const body = JSON.stringify(data);
347
+ res.writeHead(status, {
348
+ "Content-Type": "application/json",
349
+ "Content-Length": Buffer.byteLength(body),
350
+ });
351
+ res.end(body);
352
+ }
@@ -12,6 +12,9 @@ import { readIndex, getIndexDir } from "./index-reader.js";
12
12
  import { ACTIVE_WINDOW_SEC } from "./types.js";
13
13
  import type { RouteContext } from "./routes-core.js";
14
14
  import type { LiveSnapshot } from "./types.js";
15
+ import { readProviderCacheForRepo } from "../../src/store/sqlite/perf-samples.js";
16
+ import { computeCacheSavings, lookupModelInputRate } from "../../src/pricing.js";
17
+ import { latestModelSnapshot } from "../../src/store/sqlite/model-snapshots.js";
15
18
 
16
19
  // ---------------------------------------------------------------------------
17
20
  // handleIndex — "/" | "/index.html" | "/api/snapshot" | "/api/version"
@@ -160,10 +163,34 @@ export function handleRepoIndex(
160
163
  ? snap.context.percent
161
164
  : null;
162
165
  out.state = (snap.session && snap.session.state) || null;
163
- out.cacheHits = snap.cacheHits ?? null;
164
- out.compacts = snap.compacts ?? null;
165
- out.timeSaved = snap.timeSaved ?? null;
166
- out.updatedAt = snap.updatedAt ?? null;
166
+ out.cacheHits = snap.cacheHits ?? null;
167
+ out.compacts = snap.compacts ?? null;
168
+ out.timeSaved = snap.timeSaved ?? null;
169
+ out.updatedAt = snap.updatedAt ?? null;
170
+ // Provider cache per-repo lifetime (E.2)
171
+ try {
172
+ const pc = readProviderCacheForRepo(r.stateDir);
173
+ // Price from model snapshot if available
174
+ const modelSnap = latestModelSnapshot(r.stateDir);
175
+ const inputRate = modelSnap
176
+ ? modelSnap.inputRate
177
+ : lookupModelInputRate(r.modelName ?? "") ?? 0;
178
+ const savings = computeCacheSavings(
179
+ pc.totalCacheRead,
180
+ pc.totalCacheWrite,
181
+ inputRate,
182
+ );
183
+ out.providerCache = {
184
+ avgHitPct: pc.avgHitPct,
185
+ cacheRead: pc.totalCacheRead,
186
+ cacheWrite: pc.totalCacheWrite,
187
+ totalInput: pc.totalInput,
188
+ sampleCount: pc.sampleCount,
189
+ estimatedSaved: savings.netSaved > 0 ? savings.netSaved : null,
190
+ };
191
+ } catch {
192
+ /* best-effort — repo may not have perf_samples */
193
+ }
167
194
  }
168
195
  } catch {
169
196
  /* best-effort */
@@ -18,3 +18,5 @@ export {
18
18
  export { handleEvents, handleSessions } from "./routes-sessions.js";
19
19
  export { handleTopics } from "./routes-topics.js";
20
20
  export { handleTurns } from "./routes-turns.js";
21
+ export { handleMaintenance } from "./routes-maintenance.js";
22
+ export { handleProviderCache } from "./routes-cache.js";
@@ -36,6 +36,8 @@ import {
36
36
  handleSessions,
37
37
  handleTopics,
38
38
  handleTurns,
39
+ handleMaintenance,
40
+ handleProviderCache,
39
41
  handleStatic,
40
42
  } from "./routes.js";
41
43
 
@@ -223,6 +225,8 @@ export async function launchDashboardServer(
223
225
  if (handleSessions(req, res, ctx)) return;
224
226
  if (handleTopics(req, res, ctx)) return;
225
227
  if (handleTurns(req, res, ctx)) return;
228
+ if (handleMaintenance(req, res, ctx)) return;
229
+ if (handleProviderCache(req, res, ctx)) return;
226
230
  handleStatic(req, res, ctx);
227
231
  });
228
232