UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

237 lines (236 loc) • 11.2 kB
// API credits for Claude homes signed in with a Console login: the owner // enters each credit once (amount, optional expiry and label) and the Hook // estimates what is left from Claude Code's own per-session cost under that // home. Opt-in: with no credit entered nothing is recorded or shown. // The estimate never reads billing; it can drift from the Console's balance. import { createHash } from "node:crypto"; import { readFileSync, readdirSync, statSync } from "node:fs"; import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { dump, load } from "js-yaml"; import { atomic, atomicInPrivateDir, bridgeRoot, object } from "./protocol.js"; import { accountsFile, isAccountSlug } from "./claude-accounts.js"; const MAX_CREDITS = 16; const MAX_SESSIONS = 4000; const DAY = /^\d{4}-\d{2}-\d{2}$/; const day = (value, what) => { if (!DAY.test(value) || !Number.isFinite(Date.parse(`${value}T00:00:00Z`))) throw new Error(`${what} is a date like 2026-12-31.`); return new Date(`${value}T00:00:00Z`).toISOString(); }; function readYaml(text) { try { const parsed = text === undefined ? undefined : load(text); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; } } function creditsOf(parsed, home) { const list = object(parsed.credits)[home]; if (!Array.isArray(list)) return []; return list.slice(0, MAX_CREDITS).flatMap((raw) => { const item = object(raw); const amountUSD = item.amountUSD, since = item.since; if (typeof item.id !== "string" || typeof amountUSD !== "number" || !Number.isFinite(amountUSD) || amountUSD <= 0 || typeof since !== "string" || !Number.isFinite(Date.parse(since))) return []; const expires = typeof item.expiresAt === "string" && Number.isFinite(Date.parse(item.expiresAt)) ? item.expiresAt : undefined; const label = typeof item.label === "string" && item.label.trim() ? item.label.trim().slice(0, 40) : undefined; return [{ id: item.id.slice(0, 16), amountUSD, since, ...(expires ? { expiresAt: expires } : {}), ...(label ? { label } : {}) }]; }); } /** The credits entered for one Claude home, in `<bridge>/accounts.yaml` under `credits`. */ export function claudeCredits(home) { let text; try { text = readFileSync(accountsFile(), "utf8"); } catch { return []; } return creditsOf(readYaml(text), home); } async function updateCredits(home, change) { if (!isAccountSlug(home)) throw new Error(`"${home}" is not an account name: use lowercase letters, digits and dashes.`); let text; try { text = await readFile(accountsFile(), "utf8"); } catch { /* First entry. */ } const parsed = readYaml(text); const next = change(creditsOf(parsed, home)); const credits = { ...object(parsed.credits) }; if (next.length) credits[home] = next; else delete credits[home]; parsed.credits = credits; if (!Object.keys(credits).length) delete parsed.credits; await mkdir(bridgeRoot(), { recursive: true, mode: 0o700 }); await atomic(accountsFile(), dump(parsed), 0o600); return next; } /** `phren bridge accounts credit <id> --amount <usd> [--expires <date>] [--since <date>] [--label <name>]`. */ export async function addClaudeCredit(home, entry, now = new Date()) { const amountUSD = Number(entry.amount.replace(/^\$/, "")); if (!Number.isFinite(amountUSD) || amountUSD <= 0 || amountUSD > 1_000_000) throw new Error("--amount is the credit in US dollars, like 200."); const label = entry.label?.trim(); if (label !== undefined && (!label || label.length > 40 || /[\u0000-\u001f]/.test(label))) throw new Error("A label is 1 to 40 printable characters."); const since = entry.since ? day(entry.since, "--since") : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())).toISOString(); // A credit lasts through its expiry day. const expiresAt = entry.expires ? new Date(Date.parse(day(entry.expires, "--expires")) + 86_400_000 - 1000).toISOString() : undefined; if (expiresAt && expiresAt <= since) throw new Error("--expires is after --since."); const id = createHash("sha256").update(`${home}|${amountUSD}|${since}|${expiresAt ?? ""}|${label ?? ""}`).digest("hex").slice(0, 8); const credit = { id, amountUSD, since, ...(expiresAt ? { expiresAt } : {}), ...(label ? { label } : {}) }; await updateCredits(home, list => { if (list.some(item => item.id === id)) return list; if (list.length >= MAX_CREDITS) throw new Error(`An account holds at most ${MAX_CREDITS} credits; remove one first.`); return [...list, credit]; }); return credit; } export async function removeClaudeCredit(home, id) { let removed = false; await updateCredits(home, list => list.filter(item => item.id === id ? (removed = true, false) : true)); return removed; } // ── spend (Claude Code's own per-session cost) ────────────────────────────── /** Per-session costs from the status line, kept only for homes with a credit. */ export const costLedgerFile = (home) => path.join(bridgeRoot(), "usage", home.isDefault ? "claude-cost.json" : `claude-cost-${home.id}.json`); function readLedger(file) { try { const stat = statSync(file); if (!stat.isFile() || stat.size > 1_048_576) return {}; const sessions = object(object(JSON.parse(readFileSync(file, "utf8"))).sessions); return Object.fromEntries(Object.entries(sessions).flatMap(([id, raw]) => { const item = object(raw); return typeof item.costUSD === "number" && Number.isFinite(item.costUSD) && item.costUSD >= 0 && typeof item.startedAt === "string" ? [[id, { costUSD: item.costUSD, startedAt: item.startedAt }]] : []; })); } catch { return {}; } } /** Record one status-line render: the session's running total only grows, so the largest seen stands. */ export async function recordSessionCost(home, payload, now = new Date()) { if (!claudeCredits(home.id).length) return; const value = object(payload); const session = typeof value.session_id === "string" && /^[A-Za-z0-9-]{1,64}$/.test(value.session_id) ? value.session_id : undefined; const cost = object(value.cost).total_cost_usd; if (!session || typeof cost !== "number" || !Number.isFinite(cost) || cost < 0 || cost > 100_000) return; const file = costLedgerFile(home); const sessions = readLedger(file); const seen = sessions[session]; if (seen && seen.costUSD >= cost) return; sessions[session] = { costUSD: cost, startedAt: seen?.startedAt ?? now.toISOString() }; const kept = Object.entries(sessions).sort(([, a], [, b]) => b.startedAt.localeCompare(a.startedAt)).slice(0, MAX_SESSIONS); await atomicInPrivateDir(file, JSON.stringify({ sessions: Object.fromEntries(kept) })); } /** * Every session cost known for a home: the status-line ledger, plus the * `lastCost` Claude Code keeps per project in the home's `.claude.json` * (which also covers sessions with no status line), dated by the session's * transcript. The larger figure for one session stands. */ export function homeSessionCosts(home) { const sessions = readLedger(costLedgerFile(home)); let projects = {}; try { const stat = statSync(home.configFile); if (stat.isFile() && stat.size <= 16_777_216) projects = object(object(JSON.parse(readFileSync(home.configFile, "utf8"))).projects); } catch { /* No config: the ledger stands alone. */ } const wanted = new Map(); for (const raw of Object.values(projects)) { const project = object(raw); const id = project.lastSessionId, cost = project.lastCost; if (typeof id === "string" && /^[A-Za-z0-9-]{1,64}$/.test(id) && typeof cost === "number" && Number.isFinite(cost) && cost > 0) wanted.set(id, cost); } if (wanted.size) { const started = transcriptTimes(path.join(home.dir, "projects"), new Set(wanted.keys())); for (const [id, cost] of wanted) { const startedAt = sessions[id]?.startedAt ?? started.get(id); if (!startedAt) continue; if (!sessions[id] || sessions[id].costUSD < cost) sessions[id] = { costUSD: cost, startedAt }; } } return sessions; } /** When each wanted session's transcript was created, from `<home>/projects/<folder>/<session>.jsonl`. */ function transcriptTimes(root, wanted) { const found = new Map(); let folders = []; try { folders = readdirSync(root); } catch { return found; } for (const folder of folders.slice(0, 2000)) { for (const id of wanted) { if (found.has(id)) continue; try { const stat = statSync(path.join(root, folder, `${id}.jsonl`)); const time = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs; found.set(id, new Date(time).toISOString()); } catch { /* Not in this folder. */ } } if (found.size === wanted.size) break; } return found; } /** * One window per live credit: spend is drawn from the credit that expires * first among those active when the session began, overflowing to the next. * Expired credits are left out. The window's `resetsAt` is the expiry. */ export function creditWindows(credits, sessions, now = new Date()) { const ordered = [...credits].sort((a, b) => (a.expiresAt ?? "9999").localeCompare(b.expiresAt ?? "9999") || a.since.localeCompare(b.since)); const used = new Map(ordered.map(credit => [credit.id, 0])); const spend = Object.values(sessions).sort((a, b) => a.startedAt.localeCompare(b.startedAt)); for (const session of spend) { let cost = session.costUSD; for (const credit of ordered) { if (cost <= 0) break; if (session.startedAt < credit.since || credit.expiresAt && session.startedAt > credit.expiresAt) continue; const take = Math.min(cost, credit.amountUSD - used.get(credit.id)); if (take <= 0) continue; used.set(credit.id, used.get(credit.id) + take); cost -= take; } } const round = (value) => Math.round(value * 100) / 100; return ordered.filter(credit => !credit.expiresAt || Date.parse(credit.expiresAt) > now.getTime()).map(credit => { const usedUSD = round(used.get(credit.id)); return { id: `credit:${credit.id}`, name: `API credit${credit.label ? ` · ${credit.label}` : ""}, estimate`, usedUSD, limitUSD: credit.amountUSD, usedPercent: Math.min(100, Math.round(usedUSD / credit.amountUSD * 100)), ...(credit.expiresAt ? { resetsAt: credit.expiresAt } : {}), asOf: now.toISOString(), }; }); }