Files
AgentHub/server/lib/cursor-usage.js
T
nntrivi2001 4aae61297f feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
2026-10-06 17:12:28 +07:00

261 lines
8.5 KiB
JavaScript

/**
* @file Cursor sessions, read from the agent transcripts Cursor writes under
* `~/.cursor/projects/<project slug>/agent-transcripts/<id>/<id>.jsonl` (the
* Cursor CLI, `cursor-agent`, and the Cursor editor's agent both write there).
* This is how a Cursor session reaches `sessions`, as `tool = 'cursor'`.
*
* NO tokens and NO model: a transcript line is only `{role, message:{content}}`
* (plus `{type:"turn_ended"}`) — no usage, no model id, no timestamps — so a
* Cursor session gets no `token_usage` rows and no cost. Nothing is guessed.
*
* - the session id is the transcript's file name;
* - the name is the first `<user_query>` line (60 chars);
* - the start is the file's birth time (its mtime where the filesystem keeps
* none), the last activity its mtime: active within 2 minutes, else
* completed, ended at that write;
* - the cwd is the project slug walked back onto the filesystem — the slug
* turns every `/` and every other non-alphanumeric character into `-`, so it
* is resolved only when exactly such a path still exists, else left empty.
* A file is re-read when its mtime changes, and the mtime per file lives in
* `cursor_transcripts`, so a session the user deleted stays deleted until
* Cursor writes to it again. No transcript is ever stored as `transcript_path`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { db, stmts } = require("../db");
const ACTIVE_MS = 120_000; // the window codex-usage.js calls a session active
const FUTURE_SLACK_MS = 1000;
const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const HEAD_BYTES = 256 * 1024;
/** `~/.cursor`, or `AGENTHUB_CURSOR_DIR`; read at call time. */
function getCursorDir() {
return process.env.AGENTHUB_CURSOR_DIR || path.join(os.homedir(), ".cursor");
}
const norm = (s) => s.replace(/[^A-Za-z0-9]/g, "-");
/**
* The existing directory whose path slugs to `slug`, or null. Walks from `/`,
* trying every entry whose own slug is a prefix of what is left.
*/
function cwdFromSlug(slug, root = "/") {
const walk = (dir, rest, depth) => {
if (!rest) {
try {
return fs.statSync(dir).isDirectory() ? dir : null;
} catch {
return null;
}
}
if (depth > 40) return null;
let names;
try {
names = fs.readdirSync(dir);
} catch {
return null;
}
for (const name of names) {
const n = norm(name);
if (rest !== n && !rest.startsWith(`${n}-`)) continue;
const hit = walk(path.join(dir, name), rest.slice(n.length + 1), depth + 1);
if (hit) return hit;
}
return null;
};
const want = norm(slug);
return want ? walk(root, want, 0) : null;
}
/** The first prompt's first line (60 chars) from the transcript's head, or null. */
function firstPrompt(file) {
let text;
try {
const fd = fs.openSync(file, "r");
try {
const buf = Buffer.alloc(Math.min(fs.fstatSync(fd).size, HEAD_BYTES));
fs.readSync(fd, buf, 0, buf.length, 0);
text = buf.toString("utf8");
} finally {
fs.closeSync(fd);
}
} catch {
return null;
}
for (const line of text.split("\n")) {
let o;
try {
o = JSON.parse(line);
} catch {
continue;
}
if (o?.role !== "user" || !Array.isArray(o.message?.content)) continue;
for (const part of o.message.content) {
if (part?.type !== "text" || typeof part.text !== "string") continue;
const q = /<user_query>\s*([\s\S]*?)\s*<\/user_query>/.exec(part.text);
const prompt = (q ? q[1] : part.text.replace(/<timestamp>[\s\S]*?<\/timestamp>/, "")).trim();
if (prompt) return prompt.split("\n")[0].slice(0, 60);
}
}
return null;
}
/** Every agent transcript, with its project slug, birth and mtime. */
function transcriptFiles() {
const projects = path.join(getCursorDir(), "projects");
let slugs;
try {
slugs = fs.readdirSync(projects);
} catch {
return [];
}
const out = [];
for (const slug of slugs) {
const dir = path.join(projects, slug, "agent-transcripts");
let ids;
try {
ids = fs.readdirSync(dir);
} catch {
continue;
}
for (const id of ids) {
if (!ID.test(id)) continue;
const file = path.join(dir, id, `${id}.jsonl`);
try {
const st = fs.statSync(file);
if (!st.isFile()) continue;
const mtime = Math.floor(st.mtimeMs);
out.push({ path: file, id, slug, mtime, birth: Math.floor(st.birthtimeMs) || mtime });
} catch {
/* no transcript (yet) */
}
}
}
return out;
}
const getSeen = db.prepare("SELECT * FROM cursor_transcripts WHERE path = ?");
const upsertSeen = db.prepare(
`INSERT INTO cursor_transcripts (path, session_id, mtime_ms, updated_at)
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
ON CONFLICT(path) DO UPDATE SET session_id = excluded.session_id,
mtime_ms = excluded.mtime_ms, updated_at = excluded.updated_at`
);
const insertSession = db.prepare(
`INSERT INTO sessions (id, name, status, cwd, started_at, ended_at, updated_at, metadata, tool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'cursor')`
);
const updateSession = db.prepare(
`UPDATE sessions SET name = ?, status = ?, ended_at = ?, updated_at = ?, cwd = COALESCE(cwd, ?)
WHERE id = ? AND tool = 'cursor'`
);
const settle = db.prepare(
"UPDATE sessions SET status = 'completed', ended_at = ? WHERE id = ? AND tool = 'cursor'"
);
// A write "in the future" (a clock that runs ahead) is not proof of activity.
const fresh = (mtime, now) => mtime - now < FUTURE_SLACK_MS && now - mtime < ACTIVE_MS;
/** Write one transcript's session row. @returns the broadcast type, or null. */
function writeSession(f, now) {
const existing = stmts.getSession.get(f.id);
if (existing && existing.tool !== "cursor") return null;
const lastWrite = new Date(f.mtime).toISOString();
const active = fresh(f.mtime, now);
const status = active ? "active" : "completed";
const endedAt = active ? null : lastWrite;
const name = firstPrompt(f.path) || existing?.name || `Cursor ${f.id.slice(0, 8)}`;
if (existing) {
updateSession.run(
name,
status,
endedAt,
lastWrite,
existing.cwd ? null : cwdFromSlug(f.slug),
f.id
);
return "session_updated";
}
insertSession.run(
f.id,
name,
status,
cwdFromSlug(f.slug),
new Date(Math.min(f.birth, f.mtime)).toISOString(),
endedAt,
lastWrite,
JSON.stringify({ source: "cursor-transcript" })
);
return "session_created";
}
let running = null;
/**
* One pass over every transcript: a changed one is re-read, an unchanged one
* only has an `active` session settled once it went quiet.
* @returns {Promise<{files: number, sessions: number}>}
*/
function ingestCursorUsage({ broadcast = () => {}, now = Date.now() } = {}) {
if (running) return running;
running = (async () => {
let read = 0;
let written = 0;
for (const f of transcriptFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
if (fresh(f.mtime, now)) continue;
const s = stmts.getSession.get(f.id);
if (s && s.tool === "cursor" && s.status === "active") {
settle.run(new Date(f.mtime).toISOString(), s.id);
broadcast("session_updated", stmts.getSession.get(s.id));
written += 1;
}
continue;
}
read += 1;
const type = writeSession(f, now);
upsertSeen.run(f.path, f.id, f.mtime);
if (type) {
written += 1;
broadcast(type, stmts.getSession.get(f.id));
}
} catch (err) {
console.warn(`[cursor-usage] skipped ${f.path}: ${err.message}`);
}
await new Promise((r) => setImmediate(r));
}
return { files: read, sessions: written };
})().finally(() => {
running = null;
});
return running;
}
/** First pass at once (in the background), then every DASHBOARD_CURSOR_POLL_MS (default 15 s; 0 disables). */
function startCursorUsagePoll(broadcast) {
const POLL_MS = process.env.DASHBOARD_CURSOR_POLL_MS
? Number(process.env.DASHBOARD_CURSOR_POLL_MS)
: 15_000;
if (!Number.isFinite(POLL_MS) || POLL_MS <= 0) return;
const tick = () => {
ingestCursorUsage({ broadcast }).catch(() => {});
};
tick();
const timer = setInterval(tick, POLL_MS);
if (timer.unref) timer.unref();
}
module.exports = {
getCursorDir,
cwdFromSlug,
firstPrompt,
ingestCursorUsage,
startCursorUsagePoll,
};