@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
150 lines (149 loc) • 6.58 kB
JavaScript
import { open } from "node:fs/promises";
import { constants } from "node:fs";
import { paneIdentity } from "./herdr.js";
import { object, objects } from "./protocol.js";
import { transcriptPath } from "./transcripts.js";
const MAX_TAIL_BYTES = 262_144;
const MAX_SESSIONS = 32;
const CONCURRENCY = 4;
const OVERVIEW_BUDGET_MS = 1500;
function count(value) {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 1_000_000_000_000 ? value : undefined;
}
/** Use the last response's complete token count, never cumulative session
* billing or a guessed model limit. A reset/compaction invalidates older usage. */
function codexContext(tail, startsAtBeginning) {
const rows = tail.toString("utf8").split("\n");
rows.pop(); // An unfinished trailing row is not yet an observation.
if (!startsAtBeginning)
rows.shift();
for (let i = rows.length - 1; i >= 0; i--) {
let raw;
try {
raw = object(JSON.parse(rows[i]));
}
catch {
continue;
}
if (raw.type === "compacted")
return undefined;
const payload = object(raw.payload);
if (raw.type !== "event_msg" || payload.type !== "token_count")
continue;
const info = object(payload.info), usage = object(info.last_token_usage);
const used = count(usage.total_tokens), limit = count(info.model_context_window);
if (used === undefined || limit === undefined || limit === 0)
return undefined;
return Math.min(100, used / limit * 100);
}
return undefined;
}
/** A bounded metadata cache avoids decoding unchanged transcripts. Reads
* inspect at most the final 256 KiB, without building the full chat index. */
export class ContextUsageReader {
cache = new Map();
async read(file, session) {
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const metadata = await handle.stat();
if (!metadata.isFile())
return undefined;
const key = `codex:${session}:${file}`;
const stat = `${metadata.dev}:${metadata.ino}:${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}`;
const cached = this.cache.get(key);
this.cache.delete(key);
if (cached?.stat === stat) {
this.cache.set(key, cached);
return cached.percent;
}
const start = Math.max(0, metadata.size - MAX_TAIL_BYTES);
const tail = Buffer.alloc(metadata.size - start);
const { bytesRead } = await handle.read(tail, 0, tail.length, start);
if (bytesRead !== tail.length)
return undefined;
const after = await handle.stat();
if (after.size !== metadata.size || after.mtimeMs !== metadata.mtimeMs || after.ctimeMs !== metadata.ctimeMs)
return undefined;
const percent = codexContext(tail, start === 0);
this.cache.set(key, { stat, percent });
while (this.cache.size > MAX_SESSIONS)
this.cache.delete(this.cache.keys().next().value);
return percent;
}
finally {
await handle.close();
}
}
}
/** Resolve only exact foreground identities. A tab with multiple agent panes
* has no single context percentage, and unsupported providers stay unknown. */
export class WorkspaceContextUsage {
reader = new ContextUsageReader();
active;
async read(server, snapshot) {
// A timed-out batch may still be draining bounded filesystem/process work.
// Do not accumulate another batch or apply its observations to a new snapshot.
if (this.active)
return new Map();
const agentsByTab = new Map();
for (const pane of objects(snapshot.panes)) {
if (!pane.agent || typeof pane.workspace_id !== "string" || typeof pane.tab_id !== "string")
continue;
const key = JSON.stringify([pane.workspace_id, pane.tab_id]);
const agents = agentsByTab.get(key) ?? [];
agents.push(pane);
agentsByTab.set(key, agents);
}
const candidates = [];
for (const tab of objects(snapshot.tabs)) {
if (typeof tab.workspace_id !== "string" || typeof tab.tab_id !== "string")
continue;
const agents = agentsByTab.get(JSON.stringify([tab.workspace_id, tab.tab_id]));
if (agents?.length === 1 && agents[0].agent === "codex")
candidates.push(agents[0]);
if (candidates.length === MAX_SESSIONS)
break;
}
const result = new Map();
const sessions = new Map();
let cursor = 0, expired = false;
let timer;
const deadline = new Promise(resolve => {
timer = setTimeout(() => { expired = true; resolve(); }, OVERVIEW_BUDGET_MS);
});
const batch = Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, async () => {
while (!expired && cursor < candidates.length) {
const pane = candidates[cursor++];
try {
const session = await paneIdentity(server, pane);
if (expired)
return;
if (!session)
continue;
let reading = sessions.get(session);
if (!reading) {
reading = transcriptPath("codex", session).then(file => expired ? undefined : this.reader.read(file, session));
sessions.set(session, reading);
}
const percent = await reading;
if (!expired && percent !== undefined)
result.set(pane, percent);
}
catch { /* Unavailable identity or usage must not block the overview. */ }
}
})).then(() => { });
this.active = batch;
const release = () => { if (this.active === batch)
this.active = undefined; };
void batch.then(release, release);
try {
await Promise.race([batch, deadline]);
}
finally {
expired = true;
clearTimeout(timer);
}
// The response owns a snapshot, even when slow lookups finish after it returns.
return new Map(result);
}
}