4aae61297f
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.
261 lines
8.5 KiB
JavaScript
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,
|
|
};
|