Files
nntrivi2001 5cb70d702b perf: cheaper ingest — no re-parse of live hooked transcripts, delta hook writes, lighter pollers
- Session sync leaves a transcript alone while hooks feed its session live
  (active, newest event a hook's, touched within DASHBOARD_SYNC_LIVE_HOOK_MS,
  default 5 min) and catches it up once quiet; a hook-created session (which
  importSession skips anyway) is only snapshotted, never parsed.
- Hook writer keeps a per-transcript mark (applied after commit): an
  unchanged TranscriptCache result skips token/compaction/API-error/turn
  writes, a grown one walks only the entries after the last one handled.
- Codex usage: one stamp query per pass instead of one row per journal;
  between full passes (first + every 5 min) only the newest day folders and
  journals written in the last hour are statted; fork parents resolved from
  the table or a full walk.
- Cursor/Antigravity polls back off to 60 s after idle passes
  (server/lib/backoff-poll.js); lane sync runs only while another dashboard
  shares the data dir (checked every 30 s).
2026-10-16 10:20:09 +07:00

277 lines
9.6 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, "-");
// A walk enters at most this many directories: a member can build a tree (or
// a symlink loop) whose matching paths grow exponentially with the slug.
const MAX_VISITS = 500;
/**
* The existing directory whose path slugs to `slug`, or null. Walks from `/`,
* trying every real directory (never a symlink) whose own slug is a prefix of
* what is left; gives up (null) after MAX_VISITS directories.
*/
function cwdFromSlug(slug, root = "/") {
let visits = 0;
const walk = (dir, rest, depth) => {
if (!rest) return dir;
if (depth > 40 || ++visits > MAX_VISITS) return null;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return null;
}
for (const e of entries) {
if (!e.isDirectory()) continue;
const n = norm(e.name);
if (rest !== n && !rest.startsWith(`${n}-`)) continue;
const hit = walk(path.join(dir, e.name), rest.slice(n.length + 1), depth + 1);
if (hit || visits > MAX_VISITS) 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 = require("./run-as").openSafe(file);
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 — this
* account's, then every member's (S3: their own home). */
function transcriptFiles() {
const members = require("./run-as")
.memberHomes()
.map((h) => path.join(h, ".cursor"));
return [getCursorDir(), ...members].flatMap((dir) => transcriptFilesIn(dir));
}
function transcriptFilesIn(cursorDir) {
const projects = path.join(cursorDir, "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;
// S3: a member's transcript names only their own folder and session.
const cwd = existing?.cwd ? null : cwdFromSlug(f.slug);
if (!require("./run-as").sameOwner(f.path, existing?.cwd || cwd)) 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, cwd, f.id);
return "session_updated";
}
insertSession.run(
f.id,
name,
status,
cwd,
new Date(Math.min(f.birth, f.mtime)).toISOString(),
endedAt,
lastWrite,
JSON.stringify({ source: "cursor-transcript" })
);
return "session_created";
}
let running = null;
// Fresh sessions the last pass left for a later one to settle (the poll keeps
// its base pace while there are any).
let lastPending = 0;
/**
* 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;
let pending = 0; // still-fresh sessions a later pass may have to settle
for (const f of transcriptFiles()) {
try {
const seen = getSeen.get(f.path);
if (seen && seen.mtime_ms === f.mtime) {
if (fresh(f.mtime, now)) {
pending += 1;
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));
}
lastPending = pending;
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) — slowed to once a minute after a pass that read
* nothing, wrote nothing and left no fresh session to settle, and back to the
* base pace as soon as one finds work.
*/
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;
require("./backoff-poll").startBackoffPoll(
() => ingestCursorUsage({ broadcast }).then((r) => r.files + r.sessions + lastPending > 0),
{ baseMs: POLL_MS }
);
}
module.exports = {
getCursorDir,
cwdFromSlug,
firstPrompt,
ingestCursorUsage,
startCursorUsagePoll,
};