@aixle/insights 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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +137 -0
  3. package/dist/auth/credentials.d.ts +23 -0
  4. package/dist/auth/credentials.js +174 -0
  5. package/dist/auth/exchange.d.ts +25 -0
  6. package/dist/auth/exchange.js +87 -0
  7. package/dist/auth/flow.d.ts +24 -0
  8. package/dist/auth/flow.js +66 -0
  9. package/dist/auth/keycloak.d.ts +35 -0
  10. package/dist/auth/keycloak.js +170 -0
  11. package/dist/cli.d.ts +51 -0
  12. package/dist/cli.js +426 -0
  13. package/dist/client.d.ts +28 -0
  14. package/dist/client.js +102 -0
  15. package/dist/collect-cursor-payloads.d.ts +57 -0
  16. package/dist/collect-cursor-payloads.js +134 -0
  17. package/dist/credentials.d.ts +2 -0
  18. package/dist/credentials.js +1 -0
  19. package/dist/cursor-checkpoints.d.ts +12 -0
  20. package/dist/cursor-checkpoints.js +28 -0
  21. package/dist/cursor-config.d.ts +5 -0
  22. package/dist/cursor-config.js +34 -0
  23. package/dist/cursor-payload-contract.d.ts +17 -0
  24. package/dist/cursor-payload-contract.js +258 -0
  25. package/dist/cursor-settings.d.ts +6 -0
  26. package/dist/cursor-settings.js +38 -0
  27. package/dist/cursor-store-audit.d.ts +48 -0
  28. package/dist/cursor-store-audit.js +155 -0
  29. package/dist/daily-stats-versions.d.ts +31 -0
  30. package/dist/daily-stats-versions.js +170 -0
  31. package/dist/health.d.ts +31 -0
  32. package/dist/health.js +195 -0
  33. package/dist/hooks/cursor-hooks-mapper.d.ts +22 -0
  34. package/dist/hooks/cursor-hooks-mapper.js +84 -0
  35. package/dist/hooks/cursor-hooks-reader.d.ts +30 -0
  36. package/dist/hooks/cursor-hooks-reader.js +117 -0
  37. package/dist/hooks/hook-forwarder.mjs +110 -0
  38. package/dist/hooks/hooks-config.d.ts +92 -0
  39. package/dist/hooks/hooks-config.js +235 -0
  40. package/dist/install/claude.d.ts +37 -0
  41. package/dist/install/claude.js +144 -0
  42. package/dist/install/index.d.ts +8 -0
  43. package/dist/install/index.js +11 -0
  44. package/dist/lib/args.d.ts +26 -0
  45. package/dist/lib/args.js +17 -0
  46. package/dist/lib/client.d.ts +33 -0
  47. package/dist/lib/client.js +52 -0
  48. package/dist/lib/config.d.ts +26 -0
  49. package/dist/lib/config.js +39 -0
  50. package/dist/lib/index.d.ts +4 -0
  51. package/dist/lib/index.js +4 -0
  52. package/dist/lib/project-resolver.d.ts +48 -0
  53. package/dist/lib/project-resolver.js +203 -0
  54. package/dist/lock.d.ts +9 -0
  55. package/dist/lock.js +84 -0
  56. package/dist/log.d.ts +14 -0
  57. package/dist/log.js +81 -0
  58. package/dist/pricing.d.ts +40 -0
  59. package/dist/pricing.js +149 -0
  60. package/dist/readers/claude.d.ts +83 -0
  61. package/dist/readers/claude.js +317 -0
  62. package/dist/readers/cursor.d.ts +134 -0
  63. package/dist/readers/cursor.js +900 -0
  64. package/dist/risk-scanner.d.ts +8 -0
  65. package/dist/risk-scanner.js +59 -0
  66. package/dist/server.d.ts +14 -0
  67. package/dist/server.js +234 -0
  68. package/dist/state.d.ts +69 -0
  69. package/dist/state.js +155 -0
  70. package/dist/sync.d.ts +74 -0
  71. package/dist/sync.js +679 -0
  72. package/package.json +66 -0
@@ -0,0 +1,48 @@
1
+ export interface ProjectResolution {
2
+ projectId: string | null;
3
+ source: "flag" | "config" | "auto-detect" | "auto-detect-not-found" | "none";
4
+ }
5
+ export interface LookupResult {
6
+ project_id: string;
7
+ name: string;
8
+ }
9
+ export declare function resolveProjectId(flagValue: string | undefined, configValue: string | undefined, host: string, token: string, verbose: boolean): Promise<ProjectResolution>;
10
+ export declare function resolveProjectIdForRepoPath(repoPath: string, host: string, token: string, verbose: boolean): Promise<ProjectResolution>;
11
+ export declare function getGitRemote(verbose: boolean): string | null;
12
+ export declare function getGitRemoteForPath(repoPath: string, verbose: boolean): string | null;
13
+ /**
14
+ * `git remote get-url origin` returns the literal host from the URL, which may
15
+ * be an SSH host alias from the user's ~/.ssh/config (e.g. `git@github-work:...`
16
+ * where `github-work` maps to `github.com`). DB90 stores the real host, so the
17
+ * server-side normalized URLs never match. Resolve the alias to its real
18
+ * hostname via `ssh -G` before lookup so the remote is canonical at the source.
19
+ * Non-SSH remotes and unresolvable hosts are returned unchanged.
20
+ */
21
+ export declare function canonicalizeGitRemote(remote: string, verbose: boolean): string;
22
+ /**
23
+ * Cursor `recentCommit.repoName` is typically `owner/repo` (GitHub-style slug), not a full
24
+ * git remote. Expand to remotes the API lookup understands (`Project.normalize_git_remote`).
25
+ */
26
+ export declare function repoNameToGitRemoteCandidates(repoName: string): string[];
27
+ export declare function lookupProjectByRepoName(repoName: string, host: string, token: string, verbose: boolean): Promise<LookupResult | "not-found" | null>;
28
+ /** Payload shape shared by db90-cursor and telemetry-mcp commit mappers. */
29
+ export interface CommitAttributionPayload {
30
+ event_type?: string;
31
+ project_id?: string;
32
+ metadata?: {
33
+ source?: string;
34
+ repo_name?: string;
35
+ };
36
+ }
37
+ /**
38
+ * For recent-commit events, prefer DB90 project lookup from Cursor's `metadata.repo_name`
39
+ * unless the user set `--project-id` or config `project_id` (explicit attribution).
40
+ * Daily stats / legacy rows keep batch (CWD) attribution only.
41
+ */
42
+ export declare function enrichCommitProjectAttribution(payloads: CommitAttributionPayload[], options: {
43
+ projectIdSource?: ProjectResolution["source"];
44
+ host: string;
45
+ token: string;
46
+ verbose?: boolean;
47
+ }): Promise<void>;
48
+ export declare function lookupProjectByRemote(gitRemote: string, host: string, token: string, verbose: boolean): Promise<LookupResult | "not-found" | null>;
@@ -0,0 +1,203 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /** Coerce empty string to undefined so "" is treated as "not set" */
3
+ function coerce(val) {
4
+ return val === "" ? undefined : val;
5
+ }
6
+ export async function resolveProjectId(flagValue, configValue, host, token, verbose) {
7
+ const flag = coerce(flagValue);
8
+ const config = coerce(configValue);
9
+ if (flag !== undefined)
10
+ return { projectId: flag, source: "flag" };
11
+ if (config !== undefined)
12
+ return { projectId: config, source: "config" };
13
+ const gitRemote = getGitRemote(verbose);
14
+ if (gitRemote === null)
15
+ return { projectId: null, source: "none" };
16
+ const result = await lookupProjectByRemote(gitRemote, host, token, verbose);
17
+ if (result === "not-found")
18
+ return { projectId: null, source: "auto-detect-not-found" };
19
+ if (result !== null)
20
+ return { projectId: result.project_id, source: "auto-detect" };
21
+ return { projectId: null, source: "none" };
22
+ }
23
+ export async function resolveProjectIdForRepoPath(repoPath, host, token, verbose) {
24
+ const gitRemote = getGitRemoteForPath(repoPath, verbose);
25
+ if (gitRemote === null)
26
+ return { projectId: null, source: "none" };
27
+ const result = await lookupProjectByRemote(gitRemote, host, token, verbose);
28
+ if (result === "not-found")
29
+ return { projectId: null, source: "auto-detect-not-found" };
30
+ if (result !== null)
31
+ return { projectId: result.project_id, source: "auto-detect" };
32
+ return { projectId: null, source: "none" };
33
+ }
34
+ export function getGitRemote(verbose) {
35
+ try {
36
+ const out = execFileSync("git", ["remote", "get-url", "origin"], {
37
+ encoding: "utf-8",
38
+ stdio: ["ignore", "pipe", "pipe"],
39
+ timeout: 5000,
40
+ }).trim();
41
+ return out ? canonicalizeGitRemote(out, verbose) : null;
42
+ }
43
+ catch {
44
+ if (verbose)
45
+ console.log("[verbose] Could not determine git remote");
46
+ return null;
47
+ }
48
+ }
49
+ export function getGitRemoteForPath(repoPath, verbose) {
50
+ try {
51
+ const out = execFileSync("git", ["-C", repoPath, "remote", "get-url", "origin"], {
52
+ encoding: "utf-8",
53
+ stdio: ["ignore", "pipe", "pipe"],
54
+ timeout: 5000,
55
+ }).trim();
56
+ return out ? canonicalizeGitRemote(out, verbose) : null;
57
+ }
58
+ catch {
59
+ if (verbose)
60
+ console.log(`[verbose] Could not determine git remote for path: ${repoPath}`);
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * `git remote get-url origin` returns the literal host from the URL, which may
66
+ * be an SSH host alias from the user's ~/.ssh/config (e.g. `git@github-work:...`
67
+ * where `github-work` maps to `github.com`). DB90 stores the real host, so the
68
+ * server-side normalized URLs never match. Resolve the alias to its real
69
+ * hostname via `ssh -G` before lookup so the remote is canonical at the source.
70
+ * Non-SSH remotes and unresolvable hosts are returned unchanged.
71
+ */
72
+ export function canonicalizeGitRemote(remote, verbose) {
73
+ const trimmed = remote.trim();
74
+ if (!trimmed)
75
+ return remote;
76
+ const scp = trimmed.match(/^([\w.-]+)@([^:/]+):(.+)$/);
77
+ const sshUrl = trimmed.match(/^ssh:\/\/(?:([\w.-]+)@)?([^:/]+)(?::\d+)?\/(.+)$/i);
78
+ const host = scp?.[2] ?? sshUrl?.[2];
79
+ if (!host)
80
+ return trimmed;
81
+ const resolved = resolveSshHostName(host, verbose);
82
+ if (!resolved || resolved.toLowerCase() === host.toLowerCase())
83
+ return trimmed;
84
+ if (verbose)
85
+ console.log(`[verbose] Resolved SSH host alias ${host} -> ${resolved}`);
86
+ if (scp)
87
+ return `${scp[1]}@${resolved}:${scp[3]}`;
88
+ const user = sshUrl?.[1] ? `${sshUrl[1]}@` : "";
89
+ return `ssh://${user}${resolved}/${sshUrl[3]}`;
90
+ }
91
+ function resolveSshHostName(host, verbose) {
92
+ try {
93
+ const out = execFileSync("ssh", ["-G", host], {
94
+ encoding: "utf-8",
95
+ stdio: ["ignore", "pipe", "pipe"],
96
+ timeout: 5000,
97
+ });
98
+ for (const line of out.split("\n")) {
99
+ const m = line.match(/^hostname\s+(.+)$/i);
100
+ if (m)
101
+ return m[1].trim();
102
+ }
103
+ return null;
104
+ }
105
+ catch {
106
+ if (verbose)
107
+ console.log(`[verbose] Could not resolve SSH host alias: ${host}`);
108
+ return null;
109
+ }
110
+ }
111
+ function isLookupResponse(body) {
112
+ if (typeof body !== "object" || body === null)
113
+ return false;
114
+ const d = body.data;
115
+ if (typeof d !== "object" || d === null)
116
+ return false;
117
+ const data = d;
118
+ return typeof data.project_id === "string" && typeof data.name === "string";
119
+ }
120
+ /**
121
+ * Cursor `recentCommit.repoName` is typically `owner/repo` (GitHub-style slug), not a full
122
+ * git remote. Expand to remotes the API lookup understands (`Project.normalize_git_remote`).
123
+ */
124
+ export function repoNameToGitRemoteCandidates(repoName) {
125
+ const trimmed = repoName.trim();
126
+ if (!trimmed)
127
+ return [];
128
+ if (trimmed.includes("://") || trimmed.includes("@")) {
129
+ return [trimmed];
130
+ }
131
+ if (/^[\w.-]+\/[\w.-]+(\/[\w.-]+)*$/.test(trimmed)) {
132
+ return [`https://github.com/${trimmed}`, `git@github.com:${trimmed}.git`];
133
+ }
134
+ return [];
135
+ }
136
+ export async function lookupProjectByRepoName(repoName, host, token, verbose) {
137
+ const candidates = repoNameToGitRemoteCandidates(repoName);
138
+ if (candidates.length === 0) {
139
+ if (verbose)
140
+ console.log(`[verbose] Cannot derive git remote from repo_name: ${repoName}`);
141
+ return "not-found";
142
+ }
143
+ for (const candidate of candidates) {
144
+ const result = await lookupProjectByRemote(candidate, host, token, verbose);
145
+ if (result === "not-found")
146
+ continue;
147
+ return result;
148
+ }
149
+ return "not-found";
150
+ }
151
+ /**
152
+ * For recent-commit events, prefer DB90 project lookup from Cursor's `metadata.repo_name`
153
+ * unless the user set `--project-id` or config `project_id` (explicit attribution).
154
+ * Daily stats / legacy rows keep batch (CWD) attribution only.
155
+ */
156
+ export async function enrichCommitProjectAttribution(payloads, options) {
157
+ const explicit = options.projectIdSource === "flag" || options.projectIdSource === "config";
158
+ if (explicit)
159
+ return;
160
+ for (const payload of payloads) {
161
+ if (payload.event_type !== "commit" && payload.metadata?.source !== "recent_commit") {
162
+ continue;
163
+ }
164
+ const repoName = payload.metadata?.repo_name;
165
+ if (!repoName)
166
+ continue;
167
+ const result = await lookupProjectByRepoName(repoName, options.host, options.token, options.verbose ?? false);
168
+ if (result && typeof result === "object" && "project_id" in result) {
169
+ payload.project_id = result.project_id;
170
+ if (options.verbose) {
171
+ console.log(`[verbose] Commit project attribution from metadata.repo_name=${repoName}: ${result.project_id}`);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ export async function lookupProjectByRemote(gitRemote, host, token, verbose) {
177
+ const url = `${host.replace(/\/$/, "")}/api/v1/projects/lookup?git_remote=${encodeURIComponent(gitRemote)}`;
178
+ try {
179
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
180
+ if (res.status === 404) {
181
+ if (verbose)
182
+ console.log(`[verbose] No project found for git remote: ${gitRemote}`);
183
+ return "not-found";
184
+ }
185
+ if (!res.ok) {
186
+ if (verbose)
187
+ console.log(`[verbose] Project lookup failed: HTTP ${res.status}`);
188
+ return null;
189
+ }
190
+ const body = await res.json();
191
+ if (!isLookupResponse(body)) {
192
+ if (verbose)
193
+ console.log("[verbose] Unexpected response shape from project lookup");
194
+ return null;
195
+ }
196
+ return body.data;
197
+ }
198
+ catch {
199
+ if (verbose)
200
+ console.log("[verbose] Project lookup network error — proceeding without project attribution");
201
+ return null;
202
+ }
203
+ }
package/dist/lock.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface SyncLock {
2
+ acquired: boolean;
3
+ release: () => void;
4
+ }
5
+ /**
6
+ * Advisory lock file under the MCP app directory. Prevents overlapping sync runs
7
+ * across timer ticks, manual tool calls, and separate MCP processes.
8
+ */
9
+ export declare function acquireSyncLock(appDir: string, staleMs?: number): SyncLock;
package/dist/lock.js ADDED
@@ -0,0 +1,84 @@
1
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ const LOCK_FILE = "state.lock";
5
+ /** Stale lock TTL — longer than a normal transcript sync run. */
6
+ const DEFAULT_STALE_MS = 30 * 60 * 1000;
7
+ function lockOwnerIsAlive(owner) {
8
+ const pid = Number.parseInt(owner.split(":")[0] ?? "", 10);
9
+ if (!Number.isFinite(pid) || pid <= 0)
10
+ return false;
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ /**
20
+ * Advisory lock file under the MCP app directory. Prevents overlapping sync runs
21
+ * across timer ticks, manual tool calls, and separate MCP processes.
22
+ */
23
+ export function acquireSyncLock(appDir, staleMs = DEFAULT_STALE_MS) {
24
+ mkdirSync(appDir, { recursive: true });
25
+ const lockPath = join(appDir, LOCK_FILE);
26
+ const owner = `${process.pid}:${randomUUID()}`;
27
+ const tryAcquire = () => {
28
+ try {
29
+ const fd = openSync(lockPath, "wx");
30
+ closeSync(fd);
31
+ writeFileSync(lockPath, owner, "utf-8");
32
+ return true;
33
+ }
34
+ catch (err) {
35
+ const code = err?.code;
36
+ if (code !== "EEXIST")
37
+ throw err;
38
+ let fd = null;
39
+ try {
40
+ fd = openSync(lockPath, "r");
41
+ const st = fstatSync(fd);
42
+ if (Date.now() - st.mtimeMs > staleMs) {
43
+ try {
44
+ const existingOwner = readFileSync(lockPath, "utf-8");
45
+ if (lockOwnerIsAlive(existingOwner))
46
+ return false;
47
+ const current = statSync(lockPath);
48
+ if (current.dev === st.dev && current.ino === st.ino) {
49
+ unlinkSync(lockPath);
50
+ }
51
+ }
52
+ catch {
53
+ // another winner removed it — fall through to retry
54
+ }
55
+ return tryAcquire();
56
+ }
57
+ }
58
+ catch {
59
+ // stat/unlink race — treat as not acquired
60
+ }
61
+ finally {
62
+ if (fd !== null)
63
+ closeSync(fd);
64
+ }
65
+ return false;
66
+ }
67
+ };
68
+ const acquired = tryAcquire();
69
+ return {
70
+ acquired,
71
+ release: () => {
72
+ if (!acquired)
73
+ return;
74
+ try {
75
+ if (readFileSync(lockPath, "utf-8") !== owner)
76
+ return;
77
+ unlinkSync(lockPath);
78
+ }
79
+ catch {
80
+ // best-effort
81
+ }
82
+ },
83
+ };
84
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /** Active log file is capped; overflow rotates to `mcp.log.1`. */
2
+ export declare const MCP_LOG_MAX_BYTES: number;
3
+ export declare function getMcpLogPath(appDir?: string): string;
4
+ /**
5
+ * Append one UTF-8 line to `mcp.log` under the app dir (`AIXLE_INSIGHTS_HOME` or `~/.aixle-insights`).
6
+ * Rotates when the file would exceed {@link MCP_LOG_MAX_BYTES}.
7
+ */
8
+ export declare function appendMcpLogLine(line: string, appDir?: string): void;
9
+ /** Structured MCP operational log (file + optional stderr mirror for operators). */
10
+ export declare const mcpLog: {
11
+ info(event: string, fields?: Record<string, unknown>, mirrorToConsole?: boolean): void;
12
+ warn(event: string, fields?: Record<string, unknown>, mirrorToConsole?: boolean): void;
13
+ error(event: string, fields?: Record<string, unknown>, mirrorToConsole?: boolean): void;
14
+ };
package/dist/log.js ADDED
@@ -0,0 +1,81 @@
1
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { getAppDir } from "./state.js";
4
+ /** Active log file is capped; overflow rotates to `mcp.log.1`. */
5
+ export const MCP_LOG_MAX_BYTES = 5 * 1024 * 1024;
6
+ const LOG_BASENAME = "mcp.log";
7
+ const ROTATED_BASENAME = "mcp.log.1";
8
+ export function getMcpLogPath(appDir) {
9
+ return join(appDir ?? getAppDir(), LOG_BASENAME);
10
+ }
11
+ function formatLine(level, event, fields) {
12
+ const base = {
13
+ ts: new Date().toISOString(),
14
+ level,
15
+ event,
16
+ };
17
+ if (fields && Object.keys(fields).length > 0) {
18
+ base["data"] = fields;
19
+ }
20
+ return JSON.stringify(base);
21
+ }
22
+ function rotateIfNeeded(logPath, incomingByteLength) {
23
+ if (!existsSync(logPath))
24
+ return;
25
+ const size = statSync(logPath).size;
26
+ if (size + incomingByteLength <= MCP_LOG_MAX_BYTES)
27
+ return;
28
+ const rotated = join(dirname(logPath), ROTATED_BASENAME);
29
+ if (existsSync(rotated)) {
30
+ unlinkSync(rotated);
31
+ }
32
+ renameSync(logPath, rotated);
33
+ }
34
+ /**
35
+ * Append one UTF-8 line to `mcp.log` under the app dir (`AIXLE_INSIGHTS_HOME` or `~/.aixle-insights`).
36
+ * Rotates when the file would exceed {@link MCP_LOG_MAX_BYTES}.
37
+ */
38
+ export function appendMcpLogLine(line, appDir) {
39
+ const dir = appDir ?? getAppDir();
40
+ mkdirSync(dir, { recursive: true });
41
+ const logPath = join(dir, LOG_BASENAME);
42
+ const raw = Buffer.from(`${line}\n`, "utf8");
43
+ const buf = raw.length > MCP_LOG_MAX_BYTES
44
+ ? Buffer.concat([raw.subarray(0, MCP_LOG_MAX_BYTES - 1), Buffer.from("\n")])
45
+ : raw;
46
+ rotateIfNeeded(logPath, buf.length);
47
+ appendFileSync(logPath, buf);
48
+ }
49
+ function emit(level, event, fields, mirrorToConsole) {
50
+ const line = formatLine(level, event, fields);
51
+ try {
52
+ appendMcpLogLine(line);
53
+ }
54
+ catch (err) {
55
+ /* best-effort — never break sync for logging */
56
+ const msg = err instanceof Error ? err.message : String(err);
57
+ console.error(`[aixle-insights] failed to append mcp.log: ${msg}`);
58
+ }
59
+ if (mirrorToConsole) {
60
+ const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
61
+ const prefix = `[aixle-insights][${level}] ${event}`;
62
+ if (level === "info")
63
+ console.log(prefix + suffix);
64
+ else if (level === "warn")
65
+ console.warn(prefix + suffix);
66
+ else
67
+ console.error(prefix + suffix);
68
+ }
69
+ }
70
+ /** Structured MCP operational log (file + optional stderr mirror for operators). */
71
+ export const mcpLog = {
72
+ info(event, fields, mirrorToConsole = false) {
73
+ emit("info", event, fields, mirrorToConsole);
74
+ },
75
+ warn(event, fields, mirrorToConsole = true) {
76
+ emit("warn", event, fields, mirrorToConsole);
77
+ },
78
+ error(event, fields, mirrorToConsole = true) {
79
+ emit("error", event, fields, mirrorToConsole);
80
+ },
81
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Model pricing table and cost calculation for @aixle/insights.
3
+ *
4
+ * Default rates source: https://platform.claude.com/docs/en/about-claude/pricing
5
+ * Rates last verified: 2026-05-04
6
+ */
7
+ export interface ModelPricing {
8
+ input_per_mtok: number;
9
+ output_per_mtok: number;
10
+ cache_write_per_mtok: number;
11
+ cache_read_per_mtok: number;
12
+ }
13
+ export type PricingTable = Record<string, ModelPricing>;
14
+ /**
15
+ * Default pricing table (USD per million tokens).
16
+ * Covers the Claude model IDs most commonly seen in Claude Code transcripts.
17
+ *
18
+ * NOTE: Sessions may use multiple models but SessionAggregate stores only
19
+ * the last-seen model. Cost is calculated as if all tokens in the session
20
+ * used that model. This is a known approximation; per-model-per-session
21
+ * breakdown is a future improvement.
22
+ */
23
+ export declare const DEFAULT_PRICING: PricingTable;
24
+ /**
25
+ * Deep-merges user-supplied overrides on top of base, per model.
26
+ *
27
+ * - For models already in base: any subset of the four rate fields may be
28
+ * supplied; missing fields fall back to the base value for that model.
29
+ * - For NEW model IDs not in base: all four *_per_mtok fields are required.
30
+ * If any rate is missing or non-finite after merge, calculateCost will
31
+ * return null rather than produce NaN. getCostWarning will explain why.
32
+ * - Returns a new table object so mutations never affect DEFAULT_PRICING.
33
+ */
34
+ export declare function mergePricing(base: PricingTable, overrides: PricingTable): PricingTable;
35
+ export declare function calculateCost(model: string | null, baseInputTokens: number, outputTokens: number, cacheWriteTokens: number, cacheReadTokens: number, pricing: PricingTable): number | null;
36
+ /**
37
+ * Returns a human-readable warning string explaining why cost could not be
38
+ * computed, or null if cost should be calculable (no warning needed).
39
+ */
40
+ export declare function getCostWarning(model: string | null, pricing: PricingTable): string | null;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Model pricing table and cost calculation for @aixle/insights.
3
+ *
4
+ * Default rates source: https://platform.claude.com/docs/en/about-claude/pricing
5
+ * Rates last verified: 2026-05-04
6
+ */
7
+ /**
8
+ * Default pricing table (USD per million tokens).
9
+ * Covers the Claude model IDs most commonly seen in Claude Code transcripts.
10
+ *
11
+ * NOTE: Sessions may use multiple models but SessionAggregate stores only
12
+ * the last-seen model. Cost is calculated as if all tokens in the session
13
+ * used that model. This is a known approximation; per-model-per-session
14
+ * breakdown is a future improvement.
15
+ */
16
+ export const DEFAULT_PRICING = {
17
+ // Opus 4.7/4.6/4.5 — $5 input / $25 output
18
+ "claude-opus-4-7": {
19
+ input_per_mtok: 5.0,
20
+ output_per_mtok: 25.0,
21
+ cache_write_per_mtok: 6.25,
22
+ cache_read_per_mtok: 0.5,
23
+ },
24
+ "claude-opus-4-6": {
25
+ input_per_mtok: 5.0,
26
+ output_per_mtok: 25.0,
27
+ cache_write_per_mtok: 6.25,
28
+ cache_read_per_mtok: 0.5,
29
+ },
30
+ "claude-opus-4-5": {
31
+ input_per_mtok: 5.0,
32
+ output_per_mtok: 25.0,
33
+ cache_write_per_mtok: 6.25,
34
+ cache_read_per_mtok: 0.5,
35
+ },
36
+ // Opus 4.1/4 — $15 input / $75 output
37
+ "claude-opus-4-1": {
38
+ input_per_mtok: 15.0,
39
+ output_per_mtok: 75.0,
40
+ cache_write_per_mtok: 18.75,
41
+ cache_read_per_mtok: 1.5,
42
+ },
43
+ "claude-opus-4": {
44
+ input_per_mtok: 15.0,
45
+ output_per_mtok: 75.0,
46
+ cache_write_per_mtok: 18.75,
47
+ cache_read_per_mtok: 1.5,
48
+ },
49
+ // Sonnet 4.x family — $3 input / $15 output
50
+ "claude-sonnet-4": {
51
+ input_per_mtok: 3.0,
52
+ output_per_mtok: 15.0,
53
+ cache_write_per_mtok: 3.75,
54
+ cache_read_per_mtok: 0.3,
55
+ },
56
+ "claude-sonnet-4-6": {
57
+ input_per_mtok: 3.0,
58
+ output_per_mtok: 15.0,
59
+ cache_write_per_mtok: 3.75,
60
+ cache_read_per_mtok: 0.3,
61
+ },
62
+ "claude-sonnet-4-5-20251001": {
63
+ input_per_mtok: 3.0,
64
+ output_per_mtok: 15.0,
65
+ cache_write_per_mtok: 3.75,
66
+ cache_read_per_mtok: 0.3,
67
+ },
68
+ // Haiku 4.5 — $1 input / $5 output
69
+ "claude-haiku-4-5-20251001": {
70
+ input_per_mtok: 1.0,
71
+ output_per_mtok: 5.0,
72
+ cache_write_per_mtok: 1.25,
73
+ cache_read_per_mtok: 0.1,
74
+ },
75
+ // Legacy Claude 3.x models
76
+ "claude-3-5-sonnet-20241022": {
77
+ input_per_mtok: 3.0,
78
+ output_per_mtok: 15.0,
79
+ cache_write_per_mtok: 3.75,
80
+ cache_read_per_mtok: 0.3,
81
+ },
82
+ "claude-3-5-haiku-20241022": {
83
+ input_per_mtok: 0.8,
84
+ output_per_mtok: 4.0,
85
+ cache_write_per_mtok: 1.0,
86
+ cache_read_per_mtok: 0.08,
87
+ },
88
+ "claude-3-opus-20240229": {
89
+ input_per_mtok: 15.0,
90
+ output_per_mtok: 75.0,
91
+ cache_write_per_mtok: 18.75,
92
+ cache_read_per_mtok: 1.5,
93
+ },
94
+ };
95
+ /**
96
+ * Deep-merges user-supplied overrides on top of base, per model.
97
+ *
98
+ * - For models already in base: any subset of the four rate fields may be
99
+ * supplied; missing fields fall back to the base value for that model.
100
+ * - For NEW model IDs not in base: all four *_per_mtok fields are required.
101
+ * If any rate is missing or non-finite after merge, calculateCost will
102
+ * return null rather than produce NaN. getCostWarning will explain why.
103
+ * - Returns a new table object so mutations never affect DEFAULT_PRICING.
104
+ */
105
+ export function mergePricing(base, overrides) {
106
+ const result = { ...base };
107
+ for (const [model, rates] of Object.entries(overrides)) {
108
+ const merged = { ...(base[model] ?? {}), ...rates };
109
+ result[model] = merged;
110
+ }
111
+ return result;
112
+ }
113
+ /** Returns true only when all four rate fields are finite numbers. */
114
+ function hasValidRates(rates) {
115
+ return (Number.isFinite(rates.input_per_mtok) &&
116
+ Number.isFinite(rates.output_per_mtok) &&
117
+ Number.isFinite(rates.cache_write_per_mtok) &&
118
+ Number.isFinite(rates.cache_read_per_mtok));
119
+ }
120
+ export function calculateCost(model, baseInputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, pricing) {
121
+ if (!model)
122
+ return null;
123
+ const rates = pricing[model];
124
+ if (!rates || !hasValidRates(rates))
125
+ return null;
126
+ const raw = (baseInputTokens * rates.input_per_mtok +
127
+ outputTokens * rates.output_per_mtok +
128
+ cacheWriteTokens * rates.cache_write_per_mtok +
129
+ cacheReadTokens * rates.cache_read_per_mtok) /
130
+ 1_000_000;
131
+ // Round to 6 decimal places to match DB DECIMAL(10,6) precision
132
+ return Math.round(raw * 1_000_000) / 1_000_000;
133
+ }
134
+ /**
135
+ * Returns a human-readable warning string explaining why cost could not be
136
+ * computed, or null if cost should be calculable (no warning needed).
137
+ */
138
+ export function getCostWarning(model, pricing) {
139
+ if (!model)
140
+ return null;
141
+ const rates = pricing[model];
142
+ if (!rates) {
143
+ return `Model "${model}" not in pricing table — cost_usd will be null. Extend DEFAULT_PRICING or add future pricing overrides when supported.`;
144
+ }
145
+ if (!hasValidRates(rates)) {
146
+ return `Incomplete pricing for "${model}" — all four *_per_mtok fields are required for new model IDs. cost_usd will be null.`;
147
+ }
148
+ return null;
149
+ }