@cliftonc/finius 0.1.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/dist/branding.js +28 -0
  4. package/dist/cli/backfill.js +122 -0
  5. package/dist/cli/claude-settings.js +54 -0
  6. package/dist/cli/codex-config.js +60 -0
  7. package/dist/cli/codex.js +97 -0
  8. package/dist/cli/config.js +41 -0
  9. package/dist/cli/doctor.js +159 -0
  10. package/dist/cli/hook.js +70 -0
  11. package/dist/cli/identity.js +163 -0
  12. package/dist/cli/import.js +61 -0
  13. package/dist/cli/index.js +70 -0
  14. package/dist/cli/install.js +23 -0
  15. package/dist/cli/password.js +14 -0
  16. package/dist/cli/serve.js +63 -0
  17. package/dist/cli/setup.js +314 -0
  18. package/dist/cli/ui.js +15 -0
  19. package/dist/client/assets/TranscriptView-CBf7-4Bo.css +1 -0
  20. package/dist/client/assets/TranscriptView-CLCPX5bI.js +194 -0
  21. package/dist/client/assets/TranscriptView-D056GDHO.js +194 -0
  22. package/dist/client/assets/TranscriptView-MIgsAwMN.js +194 -0
  23. package/dist/client/assets/index-6OIY_8fO.css +1 -0
  24. package/dist/client/assets/index-9aN8py7_.js +1 -0
  25. package/dist/client/assets/index-B-sjMmTS.js +1636 -0
  26. package/dist/client/assets/index-B4HbP3X6.js +1 -0
  27. package/dist/client/assets/index-B9wgN1BV.js +1636 -0
  28. package/dist/client/assets/index-BHlFz1Th.js +1652 -0
  29. package/dist/client/assets/index-BJyvYca7.js +1636 -0
  30. package/dist/client/assets/index-BKBTeJLz.js +1 -0
  31. package/dist/client/assets/index-BN6CbirS.js +1444 -0
  32. package/dist/client/assets/index-BW4_7xR6.js +1460 -0
  33. package/dist/client/assets/index-BaLElA30.js +1 -0
  34. package/dist/client/assets/index-BaQ02V5d.css +1 -0
  35. package/dist/client/assets/index-Bh0dgUU-.js +1636 -0
  36. package/dist/client/assets/index-Bie86XRc.js +1 -0
  37. package/dist/client/assets/index-Bijt5al-.css +1 -0
  38. package/dist/client/assets/index-BikJP2HS.js +1636 -0
  39. package/dist/client/assets/index-BkwrvP-J.js +1 -0
  40. package/dist/client/assets/index-BwVuUJSv.js +1 -0
  41. package/dist/client/assets/index-BweXI4-D.css +1 -0
  42. package/dist/client/assets/index-BwqdHcDE.js +1 -0
  43. package/dist/client/assets/index-C-Z0w-tQ.js +1652 -0
  44. package/dist/client/assets/index-C2RmKzem.js +1636 -0
  45. package/dist/client/assets/index-CHz-iKIQ.js +1 -0
  46. package/dist/client/assets/index-CIGl5oW_.js +1646 -0
  47. package/dist/client/assets/index-CVYmd4Bm.js +1465 -0
  48. package/dist/client/assets/index-Ca9UVGK1.js +1 -0
  49. package/dist/client/assets/index-CeWDkmJN.js +1 -0
  50. package/dist/client/assets/index-CpsNq0zm.css +1 -0
  51. package/dist/client/assets/index-CrUS6abD.css +1 -0
  52. package/dist/client/assets/index-Ctq8vj2Z.js +1 -0
  53. package/dist/client/assets/index-D1ktp0pp.js +1 -0
  54. package/dist/client/assets/index-D3BoYpFi.css +1 -0
  55. package/dist/client/assets/index-D59GxlrT.js +1636 -0
  56. package/dist/client/assets/index-D5Wkww8x.css +1 -0
  57. package/dist/client/assets/index-DC94jMGe.js +1 -0
  58. package/dist/client/assets/index-DFcIBkv1.js +1652 -0
  59. package/dist/client/assets/index-DmKj5Jqc.css +1 -0
  60. package/dist/client/assets/index-Dx52i05H.js +1465 -0
  61. package/dist/client/assets/index-L3GnPzmU.css +1 -0
  62. package/dist/client/assets/index-OZADsKet.js +1652 -0
  63. package/dist/client/assets/index-Qt124kj1.js +1652 -0
  64. package/dist/client/assets/index-nHzwQ3EM.js +1 -0
  65. package/dist/client/assets/index-s9Mg6LTO.js +1 -0
  66. package/dist/client/assets/index-ye8oxz8P.js +1 -0
  67. package/dist/client/assets/index-yqJS7tUY.css +1 -0
  68. package/dist/client/favicon.svg +35 -0
  69. package/dist/client/finius-dashboard.png +0 -0
  70. package/dist/client/index.html +38 -0
  71. package/dist/server/app.js +285 -0
  72. package/dist/server/claude.js +124 -0
  73. package/dist/server/codex.js +94 -0
  74. package/dist/server/events.js +12 -0
  75. package/dist/server/index.js +119 -0
  76. package/dist/server/otel.js +231 -0
  77. package/dist/server/pricing-backfill.js +41 -0
  78. package/dist/server/pricing.js +138 -0
  79. package/dist/server/queue.js +35 -0
  80. package/dist/server/storage/blob.js +17 -0
  81. package/dist/server/storage/query-helpers.js +104 -0
  82. package/dist/server/storage/sqlite.js +1167 -0
  83. package/dist/server/transcripts.js +46 -0
  84. package/dist/server/types.js +1 -0
  85. package/dist/shared/api-types.js +1 -0
  86. package/package.json +72 -0
@@ -0,0 +1,35 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <!-- "Money with wings" emoji from OpenMoji (https://openmoji.org) — License: CC BY-SA 4.0 -->
3
+ <svg width="72" height="72" viewBox="0 0 72 72" id="emoji" xmlns="http://www.w3.org/2000/svg">
4
+ <g id="color">
5
+ <rect x="29.1449" y="23.3542" width="18" height="28.5815" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -15.4466 37.9985)" fill="#FFFFFF" stroke="none"/>
6
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M13.9792,56.7778 c0.3645,0.3645,0.8533,0.5189,1.325,0.4674c0,0,8.9017,0.0515,13.9615-4.8025C29.33,52.387,29.39,52.327,29.45,52.2669 c1.8352-1.8352,1.8309-4.8068,0-6.6377c-1.831-1.831-4.8025-1.8352-6.6377,0l-8.8331,8.8331 C13.3403,55.1013,13.3403,56.1389,13.9792,56.7778z"/>
7
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M22.1465,53.0191 c0.12,0.81-0.36,1.98-1.31,2.98c-0.29,0.3-0.59,0.56-0.9,0.76"/>
8
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M17.9665,55.6591 c-0.08,0.58-0.36,1.11-0.8,1.49"/>
9
+ <ellipse cx="34.4281" cy="24.1507" rx="2.328" ry="2.328" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -6.9934 31.4179)" fill="#5C9E31" stroke="none"/>
10
+ <ellipse cx="52.0767" cy="41.3611" rx="2.328" ry="2.328" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -13.9938 48.9382)" fill="#5C9E31" stroke="none"/>
11
+ <ellipse cx="24.3188" cy="34.2599" rx="2.328" ry="2.328" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -17.1026 27.2305)" fill="#5C9E31" stroke="none"/>
12
+ <ellipse cx="41.5796" cy="51.5206" rx="2.328" ry="2.328" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -24.2522 44.4912)" fill="#5C9E31" stroke="none"/>
13
+ <rect x="42.8977" y="18.1717" width="1.67" height="27.3291" transform="matrix(-0.7071 0.7071 -0.7071 -0.7071 97.1681 23.4242)" fill="#5C9E31" stroke="none"/>
14
+ <rect x="31.153" y="29.4453" width="1.67" height="27.3291" transform="matrix(-0.7071 0.7071 -0.7071 -0.7071 85.0901 50.9742)" fill="#5C9E31" stroke="none"/>
15
+ <rect x="27.9561" y="19.0196" width="1.6895" height="18.295" transform="matrix(0.7071 0.7071 -0.7071 0.7071 28.3527 -12.1153)" fill="#5C9E31" stroke="none"/>
16
+ <rect x="46.7884" y="37.8519" width="1.6895" height="18.295" transform="matrix(0.7071 0.7071 -0.7071 0.7071 47.185 -19.9159)" fill="#5C9E31" stroke="none"/>
17
+ <ellipse cx="38.1014" cy="37.4676" rx="5.7418" ry="9.277" transform="matrix(0.7071 -0.7071 0.7071 0.7071 -15.334 37.9158)" fill="#5C9E31" stroke="none"/>
18
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M47.425,35.1923L35.6923,46.925c-0.0422,0.0422-0.0886,0.0725-0.1392,0.0987c-0.2062,0.1007-0.4716,0.0578-0.6501-0.1206 l-6.2227-6.2227c-0.1785-0.1785-0.2214-0.4439-0.1206-0.6501c0.0262-0.0505,0.0565-0.097,0.0987-0.1392L40.391,28.1583 c0.0422-0.0422,0.0887-0.0725,0.1392-0.0987c0.2062-0.1007,0.4716-0.0578,0.6501,0.1206l6.2227,6.2227 c0.1785,0.1785,0.2214,0.4439,0.1206,0.6501C47.4974,35.1036,47.4672,35.1501,47.425,35.1923z"/>
19
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M57.1856,13.5714 c0.3645,0.3645,0.5189,0.8533,0.4674,1.325c0,0,0.0515,8.9017-4.8025,13.9615c-0.0557,0.0643-0.1158,0.1243-0.1758,0.1844 c-1.8352,1.8352-4.8068,1.8309-6.6377,0c-1.831-1.831-1.8352-4.8025,0-6.6377l8.8331-8.8331 C55.5091,12.9325,56.5467,12.9325,57.1856,13.5714z"/>
20
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M53.4268,21.7387 c0.81,0.12,1.98-0.36,2.98-1.31c0.3-0.29,0.56-0.59,0.76-0.9"/>
21
+ <path fill="#FFFFFF" stroke="none" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M56.0668,17.5587 c0.58-0.08,1.11-0.36,1.49-0.8"/>
22
+ </g>
23
+ <g id="line">
24
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M13.9792,56.7778 c0.3645,0.3645,0.8533,0.5189,1.325,0.4674c0,0,8.9017,0.0515,13.9615-4.8025C29.33,52.387,29.39,52.327,29.45,52.2669 c1.8352-1.8352,1.8309-4.8068,0-6.6377c-1.831-1.831-4.8025-1.8352-6.6377,0l-8.8331,8.8331 C13.3403,55.1013,13.3403,56.1389,13.9792,56.7778z"/>
25
+ <line x1="17.2365" x2="17.2265" y1="57.1391" y2="57.1291" fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2"/>
26
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M22.1465,53.0191 c0.12,0.81-0.36,1.98-1.31,2.98c-0.29,0.3-0.59,0.56-0.9,0.76"/>
27
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M17.9665,55.6591 c-0.08,0.58-0.36,1.11-0.8,1.49"/>
28
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M41.0551,53.3579L22.4424,34.7451c-0.3905-0.3905-0.3905-1.0237,0-1.4142l11.2424-11.2424c0.3905-0.3905,1.0237-0.3905,1.4142,0 l18.6127,18.6127c0.3905,0.3905,0.3905,1.0237,0,1.4142L42.4693,53.3579C42.0788,53.7484,41.4456,53.7484,41.0551,53.3579z"/>
29
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="2" d="M47.425,35.1923L35.6923,46.925c-0.0422,0.0422-0.0886,0.0725-0.1392,0.0987c-0.2062,0.1007-0.4716,0.0578-0.6501-0.1206 l-6.2227-6.2227c-0.1785-0.1785-0.2214-0.4439-0.1206-0.6501c0.0262-0.0505,0.0565-0.097,0.0987-0.1392L40.391,28.1583 c0.0422-0.0422,0.0887-0.0725,0.1392-0.0987c0.2062-0.1007,0.4716-0.0578,0.6501,0.1206l6.2227,6.2227 c0.1785,0.1785,0.2214,0.4439,0.1206,0.6501C47.4974,35.1036,47.4672,35.1501,47.425,35.1923z"/>
30
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M57.1856,13.5714 c0.3645,0.3645,0.5189,0.8533,0.4674,1.325c0,0,0.0515,8.9017-4.8025,13.9615c-0.0557,0.0643-0.1158,0.1243-0.1758,0.1844 c-1.8352,1.8352-4.8068,1.8309-6.6377,0c-1.831-1.831-1.8352-4.8025,0-6.6377l8.8331-8.8331 C55.5091,12.9325,56.5467,12.9325,57.1856,13.5714z"/>
31
+ <line x1="57.5468" x2="57.5368" y1="16.8287" y2="16.8187" fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2"/>
32
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M53.4268,21.7387 c0.81,0.12,1.98-0.36,2.98-1.31c0.3-0.29,0.56-0.59,0.76-0.9"/>
33
+ <path fill="none" stroke="#000000" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2" d="M56.0668,17.5587 c0.58-0.08,1.11-0.36,1.49-0.8"/>
34
+ </g>
35
+ </svg>
Binary file
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
+ <title>Finius</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link
11
+ href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700;800&display=swap"
12
+ rel="stylesheet"
13
+ />
14
+ <script>
15
+ // Apply the persisted (or system) theme before paint to avoid a flash of the wrong palette.
16
+ // Keep in sync with src/client/theme.tsx (storage key + resolution order).
17
+ (function () {
18
+ try {
19
+ var stored = localStorage.getItem("finius-theme");
20
+ var theme =
21
+ stored === "light" || stored === "dark"
22
+ ? stored
23
+ : window.matchMedia("(prefers-color-scheme: dark)").matches
24
+ ? "dark"
25
+ : "light";
26
+ var root = document.documentElement;
27
+ root.classList.add(theme);
28
+ root.style.colorScheme = theme;
29
+ } catch (e) {}
30
+ })();
31
+ </script>
32
+ <script type="module" crossorigin src="/assets/index-CVYmd4Bm.js"></script>
33
+ <link rel="stylesheet" crossorigin href="/assets/index-Bijt5al-.css">
34
+ </head>
35
+ <body>
36
+ <div id="root"></div>
37
+ </body>
38
+ </html>
@@ -0,0 +1,285 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { appendFileSync, existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { Hono } from "hono";
6
+ import { cors } from "hono/cors";
7
+ import { streamSSE } from "hono/streaming";
8
+ const GRANULARITIES = ["minute", "five_minute", "quarter_hour", "hour", "day", "week"];
9
+ export function createApp({ storage, events, cronToken, rawRetentionDays = 7, authSecret }) {
10
+ const app = new Hono();
11
+ const secure = !!authSecret;
12
+ // JSONL uploads are processed on a background queue; publish the SSE 'ingest' event when each
13
+ // queued job actually completes (not when it was accepted).
14
+ storage.setProcessingListener((signal, result) => events.publish("ingest", { signal, ...result }));
15
+ app.use("*", cors());
16
+ // Single pluggable auth gate. In open mode it's a no-op; in Secure Mode it admits only
17
+ // minted+non-revoked session tokens. The master password is a bootstrap secret for /api/auth/login
18
+ // and is never accepted as a runtime API credential.
19
+ const isValidCredential = (cred) => {
20
+ if (!cred)
21
+ return false;
22
+ const row = storage.findAuthToken(sha256(cred));
23
+ return !!row && row.revoked === 0;
24
+ };
25
+ app.use("*", async (c, next) => {
26
+ if (!secure)
27
+ return next();
28
+ if (!isProtectedPath(c.req.path))
29
+ return next();
30
+ const cred = bearerToken(c.req.header("authorization")) || eventSourceToken(c) || "";
31
+ if (isValidCredential(cred))
32
+ return next();
33
+ return c.json({ error: "unauthorized" }, 401);
34
+ });
35
+ // Public so setup/doctor can probe Secure Mode without a credential, and so the dashboard knows
36
+ // whether to show the login screen.
37
+ app.get("/api/health", (c) => c.json({ ok: true, now: Date.now(), secure }));
38
+ // Exchange the master password for a client session token. Public (it's the bootstrap),
39
+ // constant-time. The minted token is stored hashed in auth_tokens; browsers persist the returned
40
+ // token in localStorage and send it as Authorization: Bearer on API requests.
41
+ app.post("/api/auth/login", async (c) => {
42
+ if (!secure)
43
+ return c.json({ error: "auth is not enabled on this server" }, 400);
44
+ const body = (await c.req.json().catch(() => ({})));
45
+ if (!timingSafeEqualStr(body.password ?? "", authSecret ?? "")) {
46
+ return c.json({ error: "invalid password" }, 401);
47
+ }
48
+ const token = randomBytes(32).toString("hex");
49
+ const label = typeof body.label === "string" && body.label.trim() ? body.label.trim().slice(0, 200) : "client";
50
+ storage.createAuthToken(sha256(token), label, Date.now());
51
+ return c.json({ token });
52
+ });
53
+ // Cron-driven cleanup of old raw_batches. Secured by a bearer token; fails closed when no token
54
+ // is configured. Wire a cron to: curl -X POST -H "Authorization: Bearer $FINIUS_CRON_TOKEN" …
55
+ app.post("/api/maintenance/prune-raw-batches", async (c) => {
56
+ if (!cronToken)
57
+ return c.json({ error: "maintenance endpoints are disabled (set FINIUS_CRON_TOKEN)" }, 503);
58
+ if (!timingSafeEqualStr(bearerToken(c.req.header("authorization")), cronToken)) {
59
+ return c.json({ error: "unauthorized" }, 401);
60
+ }
61
+ const days = Number(c.req.query("olderThanDays") ?? rawRetentionDays);
62
+ const olderThanDays = Number.isFinite(days) && days >= 0 ? days : rawRetentionDays;
63
+ const cutoff = Date.now() - olderThanDays * 86_400_000;
64
+ const result = await storage.pruneRawBatches(cutoff);
65
+ return c.json({ ...result, olderThanDays, cutoff });
66
+ });
67
+ app.post("/otlp/v1/metrics", async (c) => {
68
+ const body = await readOtlpBody(c, "metrics");
69
+ if (body === null)
70
+ return c.json({ error: "expected an OTLP/JSON body" }, 415);
71
+ const result = await storage.ingestOtelMetrics(body);
72
+ if (!result.duplicate)
73
+ events.publish("ingest", { signal: "metrics", ...result });
74
+ return c.json(result);
75
+ });
76
+ app.post("/otlp/v1/logs", async (c) => {
77
+ const body = await readOtlpBody(c, "logs");
78
+ if (body === null)
79
+ return c.json({ error: "expected an OTLP/JSON body" }, 415);
80
+ const result = await storage.ingestOtelLogs(body);
81
+ if (!result.duplicate)
82
+ events.publish("ingest", { signal: "logs", ...result });
83
+ return c.json(result);
84
+ });
85
+ // Cron-driven recompute of synthesized cost (e.g. after a pricing update). Same bearer guard /
86
+ // fail-closed semantics as prune-raw-batches.
87
+ app.post("/api/maintenance/recompute-cost", async (c) => {
88
+ if (!cronToken)
89
+ return c.json({ error: "maintenance endpoints are disabled (set FINIUS_CRON_TOKEN)" }, 503);
90
+ if (!timingSafeEqualStr(bearerToken(c.req.header("authorization")), cronToken)) {
91
+ return c.json({ error: "unauthorized" }, 401);
92
+ }
93
+ return c.json(await storage.recomputeComputedCost());
94
+ });
95
+ // Inspection surface for captured OTLP log records (Codex telemetry is logs-only): one entry per
96
+ // distinct event name with a count + a sample, so we can see the real shape before parsing it.
97
+ app.get("/api/logs/events", async (c) => c.json(await storage.getLogEventSummary()));
98
+ // The model pricing currently loaded (for debugging cost computation).
99
+ app.get("/api/pricing", async (c) => c.json(await storage.getPricing()));
100
+ app.get("/api/metrics/summary", async (c) => c.json(await storage.getSummary(readFilters(c.req.query()))));
101
+ app.get("/api/metrics/timeseries", async (c) => {
102
+ const query = c.req.query();
103
+ return c.json(await storage.getTimeseries({ ...readFilters(query), granularity: readGranularity(query.granularity) }));
104
+ });
105
+ app.get("/api/metrics/timeseries/by-model", async (c) => {
106
+ const query = c.req.query();
107
+ return c.json(await storage.getModelTimeseries({ ...readFilters(query), granularity: readGranularity(query.granularity) }));
108
+ });
109
+ app.get("/api/sessions", async (c) => c.json(await storage.listSessions(readFilters(c.req.query()))));
110
+ app.get("/api/people", async (c) => c.json(await storage.listPeople(readFilters(c.req.query()))));
111
+ app.get("/api/models", async (c) => c.json(await storage.listModels(readFilters(c.req.query()))));
112
+ app.get("/api/meta", async (c) => c.json(await storage.getFilterOptions()));
113
+ app.get("/api/sessions/:id", async (c) => {
114
+ const session = await storage.getSession(Number(c.req.param("id")));
115
+ return session ? c.json(session) : c.json({ error: "Session not found" }, 404);
116
+ });
117
+ app.get("/api/sessions/:id/transcript/info", async (c) => {
118
+ const info = await storage.getSessionTranscriptInfo(Number(c.req.param("id")));
119
+ return info ? c.json(info) : c.json({ error: "No transcript stored for this session" }, 404);
120
+ });
121
+ app.get("/api/sessions/:id/transcript", async (c) => {
122
+ const transcript = await storage.getSessionTranscript(Number(c.req.param("id")));
123
+ if (!transcript)
124
+ return c.json({ error: "No transcript stored for this session" }, 404);
125
+ return c.body(transcript.content, 200, { "content-type": "application/x-ndjson; charset=utf-8" });
126
+ });
127
+ app.post("/api/import/jsonl", async (c) => {
128
+ const contentType = c.req.header("content-type") ?? "";
129
+ let content = "";
130
+ let source = "manual-jsonl";
131
+ let sessionId;
132
+ let format;
133
+ let identity = {};
134
+ if (contentType.includes("application/json")) {
135
+ const body = (await c.req.json());
136
+ content = body.content ?? "";
137
+ source = body.source ?? source;
138
+ sessionId = body.sessionId;
139
+ format = body.format;
140
+ identity = identityHint(body);
141
+ }
142
+ else {
143
+ content = await c.req.text();
144
+ }
145
+ // Persist + queue; the blob is stored immediately and processing happens on the background queue.
146
+ const result = await storage.enqueueImport(source, { sessionId, ...identity }, content, format);
147
+ return c.json(result);
148
+ });
149
+ app.post("/api/import/claude-hook", async (c) => {
150
+ const body = (await c.req.json());
151
+ const sessionHint = { sessionId: body.session_id ?? body.sessionId ?? undefined, ...identityHint(body) };
152
+ // Preferred path: the client (e.g. the `finius` CLI hook) sends the transcript inline, so the
153
+ // server never needs to share a filesystem with Claude Code. Works for remote servers too.
154
+ let content = typeof body.transcript === "string" ? body.transcript : undefined;
155
+ // Fallback: read a local transcript file. Only safe when the server runs on the same machine as
156
+ // Claude Code, so it is restricted to known Claude/project directories.
157
+ if (content === undefined) {
158
+ const transcriptPath = body.transcript_path;
159
+ if (!transcriptPath)
160
+ return c.json({ error: "transcript or transcript_path is required" }, 400);
161
+ const resolvedPath = resolve(transcriptPath.replace(/^~(?=$|\/)/, homedir()));
162
+ if (!isAllowedTranscriptPath(resolvedPath, body.cwd)) {
163
+ return c.json({ error: "transcript_path is outside allowed local Claude/project directories" }, 403);
164
+ }
165
+ if (!existsSync(resolvedPath))
166
+ return c.json({ error: "transcript_path does not exist" }, 404);
167
+ content = readFileSync(resolvedPath, "utf8");
168
+ }
169
+ const result = await storage.enqueueImport("claude-code-jsonl", sessionHint, content);
170
+ return c.json(result);
171
+ });
172
+ app.get("/events", (c) => streamSSE(c, async (stream) => {
173
+ let id = 0;
174
+ const unsubscribe = events.subscribe((event, data) => {
175
+ void stream.writeSSE({ id: String(++id), event, data: JSON.stringify(data) });
176
+ });
177
+ await stream.writeSSE({ id: String(++id), event: "ready", data: JSON.stringify({ ok: true }) });
178
+ const keepAlive = setInterval(() => {
179
+ void stream.writeSSE({ event: "ping", data: JSON.stringify({ now: Date.now() }) });
180
+ }, 25_000);
181
+ await new Promise((resolve) => {
182
+ stream.onAbort(() => {
183
+ clearInterval(keepAlive);
184
+ unsubscribe();
185
+ resolve();
186
+ });
187
+ });
188
+ }));
189
+ return app;
190
+ }
191
+ function readFilters(query) {
192
+ return {
193
+ from: parseTime(query.from),
194
+ to: parseTime(query.to),
195
+ user: query.user,
196
+ model: query.model,
197
+ source: query.source,
198
+ session: parseId(query.session)
199
+ };
200
+ }
201
+ function parseId(value) {
202
+ if (!value)
203
+ return undefined;
204
+ const numeric = Number(value);
205
+ return Number.isInteger(numeric) ? numeric : undefined;
206
+ }
207
+ function identityHint(body) {
208
+ return {
209
+ userEmail: body.user_email ?? body.userEmail ?? undefined,
210
+ userAccountId: body.user_account_id ?? body.userAccountId ?? undefined,
211
+ userId: body.user_id ?? body.userId ?? undefined,
212
+ githubLogin: body.github_login ?? body.githubLogin ?? undefined,
213
+ displayName: body.display_name ?? body.displayName ?? undefined
214
+ };
215
+ }
216
+ function readGranularity(value) {
217
+ return GRANULARITIES.includes(value) ? value : "hour";
218
+ }
219
+ function bearerToken(header) {
220
+ const match = /^Bearer\s+(.+)$/i.exec(header ?? "");
221
+ return match ? match[1].trim() : "";
222
+ }
223
+ function eventSourceToken(c) {
224
+ return c.req.path === "/events" ? (c.req.query("token") ?? "") : "";
225
+ }
226
+ // Reads an OTLP request body as text (so a debug dump can capture the exact bytes the agent sent —
227
+ // even non-JSON), optionally captures it, then parses JSON. Returns null when the body isn't valid
228
+ // OTLP/JSON (e.g. an agent that sends protobuf). Set FINIUS_DEBUG_OTEL to a file path to append every
229
+ // batch as `{kind, at, contentType, raw}` NDJSON — used to discover what a new agent (e.g. Codex)
230
+ // actually emits before writing a parser for it.
231
+ async function readOtlpBody(c, kind) {
232
+ const raw = await c.req.text();
233
+ const debugPath = process.env.FINIUS_DEBUG_OTEL;
234
+ if (debugPath) {
235
+ const line = JSON.stringify({ kind, at: Date.now(), contentType: c.req.header("content-type") ?? null, raw });
236
+ try {
237
+ appendFileSync(debugPath, `${line}\n`);
238
+ console.log(`[finius] OTLP ${kind} batch captured -> ${debugPath} (${raw.length} bytes)`);
239
+ }
240
+ catch (err) {
241
+ console.error(`[finius] OTLP debug capture failed: ${err.message}`);
242
+ }
243
+ }
244
+ try {
245
+ return JSON.parse(raw);
246
+ }
247
+ catch {
248
+ return null;
249
+ }
250
+ }
251
+ function sha256(value) {
252
+ return createHash("sha256").update(value).digest("hex");
253
+ }
254
+ // Which paths the Secure Mode gate guards. Data + ingest + the live stream require a credential;
255
+ // everything else (static client assets, index.html, /api/health, /api/auth/login) is public so the
256
+ // login page can load and clients can bootstrap. /api/auth/login is under /api/ but allow-listed.
257
+ function isProtectedPath(path) {
258
+ if (path === "/api/health" || path === "/api/auth/login")
259
+ return false;
260
+ return path.startsWith("/api/") || path.startsWith("/otlp/") || path === "/events";
261
+ }
262
+ // Constant-time string compare that doesn't leak length via early return.
263
+ function timingSafeEqualStr(a, b) {
264
+ const bufA = Buffer.from(a);
265
+ const bufB = Buffer.from(b);
266
+ if (bufA.length !== bufB.length)
267
+ return false;
268
+ return timingSafeEqual(bufA, bufB);
269
+ }
270
+ function parseTime(value) {
271
+ if (!value)
272
+ return undefined;
273
+ const numeric = Number(value);
274
+ if (Number.isFinite(numeric))
275
+ return numeric;
276
+ const parsed = Date.parse(value);
277
+ return Number.isFinite(parsed) ? parsed : undefined;
278
+ }
279
+ function isAllowedTranscriptPath(path, cwd) {
280
+ const home = homedir();
281
+ const allowedRoots = [resolve(home, ".claude", "projects")];
282
+ if (cwd)
283
+ allowedRoots.push(resolve(cwd));
284
+ return allowedRoots.some((root) => path === root || path.startsWith(`${root}/`));
285
+ }
@@ -0,0 +1,124 @@
1
+ // Parser for Claude Code transcripts (`~/.claude/projects/**/<session>.jsonl`). Pure / side-effect-free
2
+ // so it stays unit-testable; the Codex equivalent is codex.ts and both are dispatched from transcripts.ts.
3
+ export function parseClaudeTranscript(source, sessionHint, lines) {
4
+ const result = { importedLines: 0, malformedLines: 0, metricPoints: 0, rawEvents: 0 };
5
+ const points = [];
6
+ const rawEvents = [];
7
+ // Claude Code writes one API response across several transcript lines (one per content
8
+ // block — text, tool_use, thinking) and stamps the SAME `usage` object on each. Summing
9
+ // every usage-bearing line double-counts tokens ~2-3.6x. Dedupe by the request identity so
10
+ // each API request contributes its tokens exactly once.
11
+ const seenRequests = new Set();
12
+ for (const line of lines) {
13
+ const trimmed = line.trim();
14
+ if (!trimmed)
15
+ continue;
16
+ let event;
17
+ try {
18
+ event = JSON.parse(trimmed);
19
+ }
20
+ catch {
21
+ result.malformedLines += 1;
22
+ continue;
23
+ }
24
+ result.importedLines += 1;
25
+ result.rawEvents += 1;
26
+ rawEvents.push(event);
27
+ const extracted = extractUsage(source, sessionHint, event, seenRequests);
28
+ points.push(...extracted);
29
+ result.metricPoints += extracted.length;
30
+ }
31
+ return { result, points, rawEvents };
32
+ }
33
+ function extractUsage(source, sessionHint, event, seenRequests) {
34
+ const obj = event;
35
+ const usage = findUsageObject(obj);
36
+ if (!usage)
37
+ return [];
38
+ // One token/cost record per API request. `requestId` (or the API `message.id`) is repeated
39
+ // across the split lines of a single response; the first occurrence wins, the rest are skipped.
40
+ // No stable id → fall back to never-deduping rather than risk merging distinct requests.
41
+ const message = obj.message;
42
+ const requestKey = stringValue(obj.requestId) ?? stringValue(message?.id);
43
+ if (requestKey !== undefined) {
44
+ if (seenRequests.has(requestKey))
45
+ return [];
46
+ seenRequests.add(requestKey);
47
+ }
48
+ const timestamp = parseTimestamp(obj.timestamp) ?? parseTimestamp(obj.created_at) ?? Date.now();
49
+ const sessionId = String(obj.session_id ?? obj.sessionId ?? sessionHint.sessionId ?? "unknown-session");
50
+ const model = stringValue(obj.model) ?? stringValue(obj.message?.model) ?? sessionHint.model ?? null;
51
+ const base = {
52
+ source,
53
+ signal: "jsonl",
54
+ sessionId,
55
+ userId: sessionHint.userId ?? null,
56
+ userEmail: sessionHint.userEmail ?? null,
57
+ userAccountId: sessionHint.userAccountId ?? null,
58
+ githubLogin: sessionHint.githubLogin ?? null,
59
+ displayName: sessionHint.displayName ?? null,
60
+ model,
61
+ timestamp,
62
+ attributes: obj
63
+ };
64
+ const points = [];
65
+ const tokenMap = [
66
+ ["input_tokens", "input"],
67
+ ["output_tokens", "output"],
68
+ ["cache_creation_input_tokens", "cache_creation"],
69
+ ["cache_read_input_tokens", "cache_read"]
70
+ ];
71
+ for (const [field, tokenType] of tokenMap) {
72
+ const value = numberValue(usage[field]);
73
+ if (value === undefined)
74
+ continue;
75
+ points.push({
76
+ ...base,
77
+ metricName: "claude_code.token.usage",
78
+ kind: "tokens",
79
+ tokenType,
80
+ value,
81
+ unit: "tokens"
82
+ });
83
+ }
84
+ const cost = numberValue(obj.cost_usd) ?? numberValue(obj.total_cost_usd) ?? numberValue(usage.cost_usd);
85
+ if (cost !== undefined) {
86
+ points.push({
87
+ ...base,
88
+ metricName: "claude_code.cost.usage",
89
+ kind: "cost",
90
+ tokenType: null,
91
+ value: cost,
92
+ unit: "USD"
93
+ });
94
+ }
95
+ return points;
96
+ }
97
+ function findUsageObject(value) {
98
+ if (!value || typeof value !== "object")
99
+ return null;
100
+ const obj = value;
101
+ if (obj.usage && typeof obj.usage === "object")
102
+ return obj.usage;
103
+ if (obj.message && typeof obj.message === "object") {
104
+ const nested = findUsageObject(obj.message);
105
+ if (nested)
106
+ return nested;
107
+ }
108
+ return null;
109
+ }
110
+ function numberValue(value) {
111
+ const numeric = Number(value);
112
+ return Number.isFinite(numeric) ? numeric : undefined;
113
+ }
114
+ function stringValue(value) {
115
+ return typeof value === "string" && value.length > 0 ? value : undefined;
116
+ }
117
+ function parseTimestamp(value) {
118
+ if (typeof value === "number" && Number.isFinite(value))
119
+ return value;
120
+ if (typeof value !== "string")
121
+ return undefined;
122
+ const parsed = Date.parse(value);
123
+ return Number.isFinite(parsed) ? parsed : undefined;
124
+ }
@@ -0,0 +1,94 @@
1
+ export function parseCodexTranscript(source, sessionHint, lines) {
2
+ const result = { importedLines: 0, malformedLines: 0, metricPoints: 0, rawEvents: 0 };
3
+ const points = [];
4
+ const rawEvents = [];
5
+ let sessionId = sessionHint.sessionId ?? undefined;
6
+ let model = sessionHint.model ?? null;
7
+ const prev = { input: 0, output: 0, cached: 0 };
8
+ for (const line of lines) {
9
+ const trimmed = line.trim();
10
+ if (!trimmed)
11
+ continue;
12
+ let event;
13
+ try {
14
+ event = JSON.parse(trimmed);
15
+ }
16
+ catch {
17
+ result.malformedLines += 1;
18
+ continue;
19
+ }
20
+ result.importedLines += 1;
21
+ result.rawEvents += 1;
22
+ rawEvents.push(event);
23
+ const obj = event;
24
+ const type = stringValue(obj.type);
25
+ const payload = obj.payload;
26
+ if (!payload)
27
+ continue;
28
+ if (type === "session_meta") {
29
+ sessionId = stringValue(payload.id) ?? sessionId;
30
+ continue;
31
+ }
32
+ if (type === "turn_context") {
33
+ model = stringValue(payload.model) ?? model;
34
+ continue;
35
+ }
36
+ if (type !== "event_msg" || stringValue(payload.type) !== "token_count")
37
+ continue;
38
+ const info = payload.info;
39
+ const total = info?.total_token_usage;
40
+ if (!total)
41
+ continue;
42
+ const curInput = numberValue(total.input_tokens) ?? 0;
43
+ const curOutput = numberValue(total.output_tokens) ?? 0;
44
+ const curCached = numberValue(total.cached_input_tokens) ?? 0;
45
+ // Cumulative -> delta. Clamp at 0 to guard the (unobserved) case of a non-monotonic reset.
46
+ const dInput = Math.max(0, curInput - prev.input);
47
+ const dOutput = Math.max(0, curOutput - prev.output);
48
+ const dCached = Math.max(0, curCached - prev.cached);
49
+ prev.input = curInput;
50
+ prev.output = curOutput;
51
+ prev.cached = curCached;
52
+ const timestamp = parseTimestamp(obj.timestamp) ?? Date.now();
53
+ const sid = String(sessionId ?? "unknown-session");
54
+ const base = {
55
+ source,
56
+ signal: "jsonl",
57
+ sessionId: sid,
58
+ userId: sessionHint.userId ?? null,
59
+ userEmail: sessionHint.userEmail ?? null,
60
+ userAccountId: sessionHint.userAccountId ?? null,
61
+ githubLogin: sessionHint.githubLogin ?? null,
62
+ displayName: sessionHint.displayName ?? null,
63
+ model,
64
+ timestamp,
65
+ attributes: info ?? obj
66
+ };
67
+ const emit = (tokenType, value) => {
68
+ if (value <= 0)
69
+ return;
70
+ points.push({ ...base, metricName: "codex.token.usage", kind: "tokens", tokenType, value, unit: "tokens" });
71
+ result.metricPoints += 1;
72
+ };
73
+ emit("input", Math.max(0, dInput - dCached)); // fresh (non-cached) input
74
+ emit("cache_read", dCached);
75
+ emit("output", dOutput); // includes reasoning tokens
76
+ void model;
77
+ }
78
+ return { result, points, rawEvents };
79
+ }
80
+ function numberValue(value) {
81
+ const numeric = Number(value);
82
+ return Number.isFinite(numeric) ? numeric : undefined;
83
+ }
84
+ function stringValue(value) {
85
+ return typeof value === "string" && value.length > 0 ? value : undefined;
86
+ }
87
+ function parseTimestamp(value) {
88
+ if (typeof value === "number" && Number.isFinite(value))
89
+ return value;
90
+ if (typeof value !== "string")
91
+ return undefined;
92
+ const parsed = Date.parse(value);
93
+ return Number.isFinite(parsed) ? parsed : undefined;
94
+ }
@@ -0,0 +1,12 @@
1
+ export class EventBus {
2
+ listeners = new Set();
3
+ subscribe(listener) {
4
+ this.listeners.add(listener);
5
+ return () => this.listeners.delete(listener);
6
+ }
7
+ publish(event, data) {
8
+ for (const listener of this.listeners) {
9
+ listener(event, data);
10
+ }
11
+ }
12
+ }