@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
311 lines (310 loc) • 19.2 kB
JavaScript
import { findPhrenPath } from "../phren-paths.js";
import { creditsText, HARNESS_NAMES, resetsIn, USAGE_SOURCES } from "../computers/read.js";
import { hookRequest } from "./client.js";
import { notLinkedFrom } from "./hand-off.js";
import { optionalHookPeers, peerRequest } from "./peers.js";
import { errorCode, object } from "./protocol.js";
import { NEAR_LIMIT_LEFT, windowExhausted, windowLeft } from "./usage.js";
export { NEAR_LIMIT_LEFT };
/**
* Every computer's agent usage merged by account, for a conductor choosing
* where to send work: `account_usage` and `phren dispatch usage`. Read like
* `live_sessions`: this Hook, then each linked Hook over its pinned pipe, with
* unreachable and unlinked computers listed apart.
*
* The merge follows the phone's cards: one account is one allowance whichever
* computers report it, so each window comes from the newest report that has
* it (`mergedWindows`). Claude and Codex
* rows merge by their account key; a Claude login with no identity
* (`claude:home:<id>`) stays per computer, since two unknown logins are not
* one account. A window whose reset time has passed says `reset` and has no
* percent, and a report older than STALE_AFTER_MS is flagged stale.
*
* `nearLimit` is information: the owner often wants quota used before it
* resets. Only `exhausted` (a window at 100% or refusing requests) rules an
* account out.
*/
/** A report older than this is flagged: the Hook reads each account at most once a minute. */
export const STALE_AFTER_MS = 15 * 60_000;
/** ElevenLabs is left out: every read of it spends a request against its key's quota. */
const SOURCES = USAGE_SOURCES.join(",");
const USAGE_ROUTE = `/v1/usage?${new URLSearchParams({ sources: SOURCES, goPlan: "1", accounts: "all" })}`;
const date = (value) => value ? Date.parse(value) : NaN;
const hasData = (usage) => usage.windows.length > 0 || Boolean(usage.spend) || Boolean(usage.subscription) || Boolean(usage.credits);
const newer = (a, b) => (date(b.usage.updatedAt) || 0) > (date(a.usage.updatedAt) || 0) ? b : a;
export function ageText(ms) {
const minutes = Math.max(0, Math.round(ms / 60_000));
const days = Math.floor(minutes / 1440), hours = Math.floor(minutes % 1440 / 60);
return days ? `${days}d ${hours}h` : hours ? `${hours}h ${minutes % 60}m` : `${minutes}m`;
}
/** The phone's card key, except that a Claude login with no identity stays on its own computer. */
export function accountIdentity(usage, computer) {
const key = usage.account?.key;
if (!key || key === usage.source)
return usage.source;
return key.startsWith(`${usage.source}:home:`) ? `${usage.source}|${key}@${computer}` : `${usage.source}|${key}`;
}
/** Percent used and left, with a passed reset reported as reset rather than as its old percent. */
export function settleWindow(window, now) {
const out = { id: window.id, name: window.name };
const resetAt = date(window.resetsAt);
const reset = window.reset === true || Number.isFinite(resetAt) && resetAt <= now;
const left = windowLeft(window, now);
if (reset)
out.reset = true;
else if (typeof window.usedPercent === "number")
out.usedPercent = window.usedPercent;
if (left !== undefined)
out.leftPercent = left;
if (window.resetsAt && !reset) {
out.resetsAt = window.resetsAt;
const until = resetsIn(window.resetsAt, now);
if (until)
out.resetsIn = until;
}
if (window.limited && !reset)
out.limited = true;
if (windowExhausted(window, now))
out.exhausted = true;
if (window.usedUSD !== undefined)
out.usedUSD = window.usedUSD;
if (window.limitUSD !== undefined)
out.limitUSD = window.limitUSD;
return out;
}
/** Least room across an account's windows, undefined when none has a percent. */
export function roomLeft(windows) {
const known = windows.map(w => w.leftPercent).filter((value) => value !== undefined);
return known.length ? Math.min(...known) : undefined;
}
function accountName(usage) {
const named = usage.accountName;
const label = usage.account?.label;
return usage.account?.email ?? (typeof named === "string" && named ? named : undefined)
?? (label && label.toLowerCase() !== (HARNESS_NAMES[usage.source] ?? usage.source).toLowerCase() ? label : undefined);
}
/** One row per account across every computer's report. */
export function mergeAccountUsage(reports, now = Date.now()) {
const groups = new Map();
for (const report of reports)
for (const usage of report.accounts) {
const id = accountIdentity(usage, report.computer);
groups.set(id, [...groups.get(id) ?? [], { computer: report.computer, usage }]);
}
const accounts = [];
const empty = new Map();
for (const [id, group] of groups) {
const reporting = group.filter(item => hasData(item.usage));
if (!reporting.length) {
const entry = empty.get(group[0].usage.source) ?? { computers: [], messages: [] };
for (const item of group) {
if (!entry.computers.includes(item.computer))
entry.computers.push(item.computer);
if (item.usage.message && !entry.messages.includes(item.usage.message))
entry.messages.push(item.usage.message);
}
empty.set(group[0].usage.source, entry);
continue;
}
const source = group[0].usage.source;
const subscriptions = group.filter(item => item.usage.subscription);
const subscription = subscriptions.sort((a, b) => (date(b.usage.subscription?.checkedAt) || date(b.usage.updatedAt) || 0) - (date(a.usage.subscription?.checkedAt) || date(a.usage.updatedAt) || 0))[0]?.usage.subscription;
const withWindows = reporting.filter(item => item.usage.windows.length);
const standing = (withWindows.length ? withWindows : reporting).reduce(newer);
const windows = mergedWindows(withWindows).map(window => settleWindow(window, now));
const spend = mergedSpend(source, reporting);
const withCredits = reporting.filter(item => item.usage.credits);
const credits = withCredits.length ? withCredits.reduce(newer).usage.credits : undefined;
const updated = date(standing.usage.updatedAt);
const leftPercent = roomLeft(windows);
const exhausted = windows.filter(w => w.exhausted);
// A credit's date is when it expires, not when it comes back.
const availableAt = exhausted.filter(w => !w.id.startsWith("credit:")).map(w => w.resetsAt).filter((value) => Boolean(value)).sort().at(-1);
const availableIn = availableAt ? resetsIn(availableAt, now) : undefined;
const name = accountName(standing.usage);
const computers = reporting.map(item => ({ name: item.computer, ...(source === "claude" && item.usage.account?.id ? { account: item.usage.account.id } : {}) }))
.filter((item, index, list) => list.findIndex(other => other.name === item.name && other.account === item.account) === index);
accounts.push({
id, harness: source, name: HARNESS_NAMES[source] ?? source, ...(name ? { account: name } : {}), windows,
...(leftPercent !== undefined ? { leftPercent } : {}),
nearLimit: windows.some(w => w.limited) || leftPercent !== undefined && leftPercent < NEAR_LIMIT_LEFT,
exhausted: exhausted.length > 0, ...(availableIn ? { availableIn } : {}),
...(spend ? { spend } : {}),
...(subscription ? { subscription } : {}),
...(credits ? { credits } : {}),
...(standing.usage.updatedAt ? { updatedAt: standing.usage.updatedAt } : {}),
...(Number.isFinite(updated) ? { age: ageText(now - updated) } : {}),
stale: !Number.isFinite(updated) || now - updated > STALE_AFTER_MS || windows.some(w => w.reset),
from: standing.computer, computers,
...(standing.usage.message ? { message: standing.usage.message } : {}),
});
}
const order = (source) => { const at = USAGE_SOURCES.indexOf(source); return at < 0 ? USAGE_SOURCES.length : at; };
accounts.sort((a, b) => order(a.harness) - order(b.harness) || (a.account ?? "").localeCompare(b.account ?? "") || a.id.localeCompare(b.id));
const noData = [...empty].filter(([source]) => !accounts.some(row => row.harness === source))
.map(([source, entry]) => ({ harness: source, name: HARNESS_NAMES[source] ?? source, computers: entry.computers, ...(entry.messages[0] ? { message: entry.messages[0] } : {}) }))
.sort((a, b) => order(a.harness) - order(b.harness));
return { accounts, noData };
}
/**
* One account's windows across computers. Each window is taken from the
* newest report that has it, so a computer that has only seen the 5-hour
* window never hides the weekly one another computer reported. Between
* equally new reports the one with a reset time wins. The newest report's
* order leads; windows only older reports carry follow.
*/
function mergedWindows(reports) {
const sorted = [...reports].sort((a, b) => (date(b.usage.updatedAt) || 0) - (date(a.usage.updatedAt) || 0));
const chosen = new Map();
for (const { usage } of sorted) {
const at = date(usage.updatedAt) || 0;
for (const window of usage.windows) {
const seen = chosen.get(window.id);
if (!seen || seen.at === at && !seen.window.resetsAt && window.resetsAt)
chosen.set(window.id, { window, at });
}
}
return [...chosen.values()].map(item => item.window);
}
/** OpenCode's ledgers are per computer and add up; OpenRouter is one key seen from several, counted once per key. */
function mergedSpend(source, reporting) {
const spends = reporting.filter(item => item.usage.spend);
if (!spends.length)
return undefined;
const round = (value) => Math.round(value * 100) / 100;
if (source === "opencode" || source === "opencode-go") {
return { amountUSD: round(spends.reduce((sum, item) => sum + item.usage.spend.amountUSD, 0)), period: spends[0].usage.spend.period };
}
if (source === "openrouter") {
const byKey = new Map();
for (const item of spends) {
const key = item.usage.accountId ?? `computer:${item.computer}`;
const seen = byKey.get(key);
byKey.set(key, seen ? newer(seen, item) : item);
}
return { amountUSD: round([...byKey.values()].reduce((sum, item) => sum + item.usage.spend.amountUSD, 0)), period: spends[0].usage.spend.period };
}
return spends.reduce(newer).usage.spend;
}
/** This Hook's usage and every linked Hook's, merged by account. */
export async function readAccountUsage(options = {}) {
const hook = options.hook ?? (route => hookRequest(route, undefined, undefined, 30_000));
const ask = options.peer ?? ((peer, route) => peerRequest(peer, route, undefined, 20_000));
const [health, local, linked] = await Promise.all([hook("/v1/health"), hook(USAGE_ROUTE), (options.peers ?? optionalHookPeers)()]);
const here = typeof object(health.computer).name === "string" ? String(object(health.computer).name) : "this computer";
const reports = [{ computer: here, accounts: accountsOf(local) }];
const unreachable = [];
const peerNames = new Map();
await Promise.all(linked.peers.map(async (peer) => {
try {
const [usage, peerHealth] = await Promise.all([ask(peer, USAGE_ROUTE), ask(peer, "/v1/health").catch(() => ({}))]);
reports.push({ computer: peer.name, accounts: accountsOf(usage) });
const computer = object(object(peerHealth).computer);
peerNames.set(peer.name, [computer.name, ...(Array.isArray(computer.aliases) ? computer.aliases : [])].filter((name) => typeof name === "string"));
}
catch (error) {
const code = errorCode(error);
unreachable.push({ computer: peer.name, error: error instanceof Error ? error.message.slice(0, 300) : "Unreachable.", ...(code ? { code } : {}) });
}
}));
// Peers answer in any order; this computer first, then by name, so the standing report never depends on timing.
reports.sort((a, b) => a.computer === here ? -1 : b.computer === here ? 1 : a.computer.localeCompare(b.computer));
unreachable.sort((a, b) => a.computer.localeCompare(b.computer));
const store = options.store !== undefined ? options.store : findPhrenPath();
const notLinked = notLinkedFrom(store, here, linked.peers.map(peer => ({ name: peer.name, address: peer.address, names: peerNames.get(peer.name) })));
return { ...mergeAccountUsage(reports, options.now ?? Date.now()), computers: reports.map(report => report.computer), unreachable, notLinked,
enrolled: linked.peers.length, ...(linked.peerError ? { peerError: linked.peerError } : {}) };
}
function accountsOf(answer) {
return Array.isArray(answer.accounts) ? answer.accounts.filter((item) => {
const row = object(item);
return typeof row.source === "string" && Array.isArray(row.windows);
}) : [];
}
const ago = (age) => age === "0m" ? "just now" : `${age} ago`;
const title = (row) => row.account ? `${row.name} · ${row.account}` : row.name;
/** The one-line answer a conductor reads first: how many, and which accounts are tight or stale. */
export function usageSummary(view) {
const parts = [`${view.accounts.length} account${view.accounts.length === 1 ? "" : "s"} across ${view.computers.length} computer${view.computers.length === 1 ? "" : "s"}.`];
const out = view.accounts.filter(row => row.exhausted);
if (out.length)
parts.push(`Out of quota, do not dispatch to: ${out.map(row => `${title(row)}${row.availableIn ? ` (back in ${row.availableIn})` : ""}`).join("; ")}.`);
const near = view.accounts.filter(row => row.nearLimit && !row.exhausted);
if (near.length)
parts.push(`Low but usable: ${near.map(row => `${title(row)} (${row.leftPercent ?? 0}% left${tightest(row)?.resetsIn ? `, resets in ${tightest(row).resetsIn}` : ""})`).join("; ")}.`);
const limitedRows = view.accounts.filter(row => row.leftPercent !== undefined || row.exhausted);
if (limitedRows.length && limitedRows.every(row => row.exhausted))
parts.push("Every account with limits is out of quota: tell the owner before dispatching.");
const claude = view.accounts.filter(row => row.harness === "claude" && claudeRoom(row));
if (claude.length)
parts.push(`Claude room: ${claude.map(row => `${title(row)} ${claudeRoom(row)}`).join("; ")}.`);
const stale = view.accounts.filter(row => row.stale);
if (stale.length)
parts.push(`Stale: ${stale.map(row => `${title(row)} (${row.windows.some(w => w.reset) ? "a window reset since its report" : `reported ${row.age ? ago(row.age) : "at an unknown time"}`})`).join("; ")}.`);
if (view.unreachable.length)
parts.push(`Unreachable: ${view.unreachable.map(item => item.computer).join(", ")}.`);
if (view.notLinked.length)
parts.push(`Not linked, so not checked: ${view.notLinked.map(item => item.name).join(", ")}.`);
return parts.join(" ");
}
/** "5-hour 82% left (resets in 2h 10m), weekly 64% left (resets in 3d 4h)": what launch and failover rank Claude accounts by. */
export function claudeRoom(row) {
const part = (id, name) => {
const w = row.windows.find(item => item.id === id);
if (!w)
return "";
if (w.reset)
return `${name} reset`;
return w.leftPercent === undefined ? "" : `${name} ${w.leftPercent}% left${w.resetsIn ? ` (resets in ${w.resetsIn})` : ""}`;
};
return [part("five_hour", "5-hour"), part("seven_day", "weekly")].filter(Boolean).join(", ");
}
function tightest(row) {
return row.windows.filter(w => w.leftPercent !== undefined).sort((a, b) => a.leftPercent - b.leftPercent)[0];
}
/** `phren dispatch usage` without --json. */
export function formatAccountUsage(view) {
const lines = [`Usage by account from ${view.computers.join(", ")}`];
for (const row of view.accounts) {
const where = row.computers.map(c => c.account && c.account !== "default" ? `${c.name} (${c.account})` : c.name).join(", ");
const flags = [row.exhausted ? `out of quota${row.availableIn ? `, back in ${row.availableIn}` : ""}` : row.nearLimit ? `low, ${row.leftPercent ?? 0}% left` : row.leftPercent !== undefined ? `${row.leftPercent}% left` : "",
row.stale ? `stale${row.age ? `, reported ${ago(row.age)}` : ""}` : row.age ? ago(row.age) : ""].filter(Boolean).join("; ");
lines.push(`${title(row)} on ${where}${flags ? ` (${flags})` : ""}`);
if (row.subscription) {
const sub = row.subscription;
lines.push(` ${[sub.plan, sub.startedAt ? `since ${sub.startedAt.slice(0, 10)}` : "", sub.renewsAt ? `renews ${sub.renewsEstimated ? "about " : ""}${sub.renewsAt.slice(0, 10)}` : ""].filter(Boolean).join(" · ")}`);
}
for (const w of row.windows) {
if (w.id.startsWith("credit:") && w.usedUSD !== undefined && w.limitUSD !== undefined) {
lines.push(` ${w.name.padEnd(30)} $${w.usedUSD.toFixed(2)} of $${w.limitUSD.toFixed(2)}, about $${Math.max(0, w.limitUSD - w.usedUSD).toFixed(2)} left${w.resetsAt ? ` expires ${w.resetsAt.slice(0, 10)}` : ""}`);
continue;
}
const used = w.reset ? "reset, no new report" : w.usedPercent !== undefined ? `${w.usedPercent}% used${w.leftPercent !== undefined ? `, ${w.leftPercent}% left` : ""}` : w.usedUSD !== undefined ? `$${w.usedUSD.toFixed(2)}${w.limitUSD !== undefined ? ` of $${w.limitUSD.toFixed(2)}` : ""}` : "";
lines.push(` ${w.name.padEnd(30)} ${used.padEnd(22)}${w.limited ? "limited now " : ""}${w.resetsIn ? `resets in ${w.resetsIn}${w.resetsAt ? ` (${w.resetsAt.slice(0, 16).replace("T", " ")} UTC)` : ""}` : ""}`.trimEnd());
}
if (row.credits?.hasCredits)
lines.push(` credits${" ".repeat(24)}${creditsText(row.credits)}`);
if (row.spend)
lines.push(` spend ${row.spend.period.replace(/_/g, " ").padEnd(24)} $${row.spend.amountUSD.toFixed(2)}`);
if (row.message)
lines.push(` ${row.message}`);
}
if (view.noData.length) {
lines.push("No numbers reported");
for (const item of view.noData)
lines.push(` ${item.name} (${item.computers.join(", ")})${item.message ? `: ${item.message}` : ""}`);
}
if (view.unreachable.length) {
lines.push("Unreachable");
for (const item of view.unreachable)
lines.push(` ${item.computer}: ${item.error}`);
}
if (view.notLinked.length) {
lines.push("Not linked, so not checked");
for (const item of view.notLinked)
lines.push(` ${item.name}${item.aliases?.length ? ` (also ${item.aliases.join(", ")})` : ""}`);
}
if (view.peerError)
lines.push(`Linked computers skipped: ${view.peerError}`);
return lines.join("\n");
}