lvwerra's picture
lvwerra HF Staff
Decouple names from folders (explicit paths + folder picker, visual-only groups), pin Codex conversations, fix drag-into-group, async usage, WS origin check, secrets filter
fd0a8b6 verified
Raw
History Blame Contribute Delete
8.15 kB
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
const execFile = promisify(execFileCb);
// Cache stores the in-flight/settled PROMISE, so concurrent requests share one
// underlying ccusage run / file scan instead of piling up.
const cache = new Map(); // key -> { ts, val: Promise }
const TTL = 60_000;
function cached(key, fn) {
const c = cache.get(key);
if (c && Date.now() - c.ts < TTL) return c.val;
const val = Promise.resolve().then(fn).catch(() => null);
cache.set(key, { ts: Date.now(), val });
return val;
}
// Normalize a reset timestamp to unix SECONDS. Providers disagree: some report
// epoch seconds, some epoch millis, Claude's statusline payload uses an ISO
// string. The frontend assumes seconds.
function toEpochSec(v) {
if (typeof v === 'number' && Number.isFinite(v)) return v > 1e12 ? Math.floor(v / 1000) : v;
if (typeof v === 'string') {
const ms = Date.parse(v);
if (!Number.isNaN(ms)) return Math.floor(ms / 1000);
}
return undefined;
}
function dateOf(entry) {
for (const k of ['date', 'period', 'day']) {
if (typeof entry[k] === 'string' && /^\d{4}-\d{2}-\d{2}/.test(entry[k])) return entry[k].slice(0, 10);
}
for (const v of Object.values(entry)) {
if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
}
return null;
}
// Claude stores conversation transcripts under CLAUDE_CONFIG_DIR/projects, but
// depending on version they can also live in ~/.claude or ~/.config/claude. Give
// ccusage all candidates (it accepts a comma-separated CLAUDE_CONFIG_DIR and
// ignores ones that don't exist) so token/cost aggregation isn't silently empty.
function claudeEnv() {
const home = process.env.HOME || '';
const dirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')]
.filter(Boolean)
.filter((d, i, a) => a.indexOf(d) === i);
return { ...process.env, CLAUDE_CONFIG_DIR: dirs.join(',') };
}
// Tokens + (estimated) cost for a provider, via `ccusage <provider> daily --json`.
function providerUsage(prov) {
return cached(`u:${prov}`, async () => {
const env = prov === 'claude' ? claudeEnv() : process.env;
let out;
try {
({ stdout: out } = await execFile('ccusage', [prov, 'daily', '--json'], { encoding: 'utf8', timeout: 20_000, maxBuffer: 16 * 1024 * 1024, env }));
} catch { return null; }
let data;
try { data = JSON.parse(out); } catch { return null; }
const arr = Array.isArray(data.daily) ? data.daily : [];
const today = new Date().toISOString().slice(0, 10);
const weekCut = new Date(Date.now() - 6 * 864e5).toISOString().slice(0, 10);
let tT = 0, cT = 0, tW = 0, cW = 0;
for (const e of arr) {
const d = dateOf(e);
if (!d) continue;
const tok = e.totalTokens || 0;
const cost = e.totalCost || 0;
if (d === today) { tT += tok; cT += cost; }
if (d >= weekCut) { tW += tok; cW += cost; }
}
return {
tokensToday: tT, costToday: cT, tokensWeek: tW, costWeek: cW,
totalCost: (data.totals && data.totals.totalCost) || 0,
};
});
}
function findRateLimits(obj) {
let found = null;
const walk = (o) => {
if (!o || typeof o !== 'object') return;
if (o.rate_limits && typeof o.rate_limits === 'object') found = o.rate_limits;
for (const v of Object.values(o)) if (v && typeof v === 'object') walk(v);
};
walk(obj);
return found;
}
// Codex persists a rate-limit snapshot into its session rollout files. Scan
// rollout files newest-first and return the latest snapshot we can find — NOT
// just the newest file's, because a freshly-started session's rollout has no
// rate_limits yet (it only appears after a request), which would otherwise make
// the quota look "gone" right after a restart until you run something.
async function codexRollouts() {
const home = process.env.CODEX_HOME || path.join(process.env.HOME || '', '.codex');
const root = path.join(home, 'sessions');
const files = [];
const walk = async (dir, depth) => {
if (depth > 5) return;
let ents = [];
try { ents = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) await walk(p, depth + 1);
else if (e.name.startsWith('rollout-') && e.name.endsWith('.jsonl')) {
try { files.push({ p, m: (await fsp.stat(p)).mtimeMs }); } catch {}
}
}
};
await walk(root, 0);
return files.sort((a, b) => b.m - a.m);
}
function codexQuota() {
return cached('codexq', async () => {
const win = (w) => (w ? { usedPercent: w.used_percent, windowMinutes: w.window_minutes, resetsAt: toEpochSec(w.resets_at) } : null);
for (const f of await codexRollouts()) {
let rl = null;
let lines;
try { lines = (await fsp.readFile(f.p, 'utf8')).split('\n'); } catch { continue; }
for (const ln of lines) {
if (!ln) continue;
let j; try { j = JSON.parse(ln); } catch { continue; }
const r = findRateLimits(j);
if (r) rl = r;
}
if (rl) return { fiveHour: win(rl.primary), weekly: win(rl.secondary) };
}
return null;
});
}
// Claude quota is written by our statusline hook (see claude-statusline.mjs):
// it captures the rate_limits Claude reports, so it's as fresh as the last time
// a Claude session made a model call on the Space.
async function claudeQuota() {
try {
const cfg = process.env.CLAUDE_CONFIG_DIR;
if (!cfg) return null;
const p = path.join(cfg, 'usage.json');
const j = JSON.parse(await fsp.readFile(p, 'utf8'));
const rl = j.rate_limits;
if (!rl) return null;
const w = (x) => (x ? { usedPercent: x.used_percentage ?? x.used_percent, resetsAt: toEpochSec(x.resets_at) } : null);
return { fiveHour: w(rl.five_hour), weekly: w(rl.seven_day), opus: w(rl.seven_day_opus), updatedAt: j.ts || null };
} catch { return null; }
}
// Diagnostics: where might Claude transcripts live, and does ccusage see them?
function countJsonl(dir, depth = 0) {
if (depth > 5) return 0;
let n = 0;
let ents = [];
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
for (const e of ents) {
const p = path.join(dir, e.name);
if (e.isDirectory()) n += countJsonl(p, depth + 1);
else if (e.name.endsWith('.jsonl')) n++;
}
return n;
}
async function debugInfo() {
const home = process.env.HOME || '';
let ccusage = null;
try { ccusage = (await execFile('ccusage', ['--version'], { encoding: 'utf8', timeout: 10_000 })).stdout.trim(); } catch (e) { ccusage = `MISSING (${e.code || e.message})`; }
const claudeDirs = [process.env.CLAUDE_CONFIG_DIR, path.join(home, '.claude'), path.join(home, '.config', 'claude')]
.filter(Boolean)
.map((d) => {
const proj = path.join(d, 'projects');
return { dir: d, projectsExists: fs.existsSync(proj), jsonl: fs.existsSync(proj) ? countJsonl(proj) : 0 };
});
const raw = async (prov) => { const u = await providerUsage(prov); return u ? { tokensWeek: u.tokensWeek, costWeek: u.costWeek } : null; };
return {
env: { HOME: home, CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR || null, CODEX_HOME: process.env.CODEX_HOME || null },
ccusage,
claudeDirs,
codexRolloutFiles: (await codexRollouts()).length,
raw: { claude: await raw('claude'), codex: await raw('codex'), gemini: await raw('gemini') },
};
}
export async function buildUsage(debug = false) {
const [uClaude, uCodex, uGemini, qClaude, qCodex] = await Promise.all([
providerUsage('claude'), providerUsage('codex'), providerUsage('gemini'),
claudeQuota(), codexQuota(),
]);
const out = {
providers: {
claude: { ...(uClaude || {}), quota: qClaude },
codex: { ...(uCodex || {}), quota: qCodex },
gemini: { ...(uGemini || {}), quota: null },
},
generatedAt: new Date().toISOString(),
};
if (debug) out._debug = await debugInfo();
return out;
}