5cb70d702b
- 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).
277 lines
9.6 KiB
JavaScript
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,
|
|
};
|