resumecontext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +27 -0
  2. package/dist/agentConfig.js +202 -0
  3. package/dist/agentConfigWithDaemon.js +42 -0
  4. package/dist/apiClient.js +54 -0
  5. package/dist/browser.js +20 -0
  6. package/dist/cloudApi.js +15 -0
  7. package/dist/commands/accept.js +20 -0
  8. package/dist/commands/agents.js +35 -0
  9. package/dist/commands/auth.js +55 -0
  10. package/dist/commands/daemon.js +99 -0
  11. package/dist/commands/init.js +59 -0
  12. package/dist/commands/logout.js +20 -0
  13. package/dist/commands/mcp.js +54 -0
  14. package/dist/commands/members.js +20 -0
  15. package/dist/commands/projects.js +55 -0
  16. package/dist/commands/revoke.js +15 -0
  17. package/dist/commands/share.js +18 -0
  18. package/dist/commands/sync.js +59 -0
  19. package/dist/commands/uninstall.js +61 -0
  20. package/dist/constants.js +61 -0
  21. package/dist/daemon.js +409 -0
  22. package/dist/daemonService.js +326 -0
  23. package/dist/deps.js +1 -0
  24. package/dist/dev.js +32 -0
  25. package/dist/device.js +40 -0
  26. package/dist/httpCloudApi.js +61 -0
  27. package/dist/index.js +160 -0
  28. package/dist/localCapture.js +18 -0
  29. package/dist/localHistory/claudeCode.js +82 -0
  30. package/dist/localHistory/codex.js +106 -0
  31. package/dist/localHistory/cursor.js +492 -0
  32. package/dist/localHistory/index.js +96 -0
  33. package/dist/localHistory/opencode.js +148 -0
  34. package/dist/localHistory/registry.js +66 -0
  35. package/dist/localHistory/shared.js +174 -0
  36. package/dist/paths.js +85 -0
  37. package/dist/projectRoot.js +77 -0
  38. package/dist/session.js +36 -0
  39. package/dist/syncCore.js +108 -0
  40. package/dist/syncState.js +51 -0
  41. package/dist/ui.js +289 -0
  42. package/dist/utils.js +41 -0
  43. package/dist/version.js +43 -0
  44. package/package.json +64 -0
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Claude Code stores each session as a plain JSONL log at
3
+ * ~/.claude/projects/<projectDirSlug>/<sessionId>.jsonl -- one line per
4
+ * event, filtered here to user/assistant message events.
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { isPlainObject, listDirs, listFiles, newestMtimeMs, renderContent } from "./shared.js";
10
+ export const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), ".claude");
11
+ export const DEFAULT_CLAUDE_PROJECTS_DIR = path.join(DEFAULT_CLAUDE_DIR, "projects");
12
+ export function claudeProjectsDir(configuredDir) {
13
+ return path.join(path.resolve(configuredDir), "projects");
14
+ }
15
+ /** Yields normalized turns from one Claude Code session .jsonl file. */
16
+ function parseClaudeCodeFile(filePath) {
17
+ const turns = [];
18
+ const source_mtime_ms = newestMtimeMs([filePath]);
19
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
20
+ for (const rawLine of lines) {
21
+ const line = rawLine.trim();
22
+ if (!line)
23
+ continue;
24
+ let d;
25
+ try {
26
+ d = JSON.parse(line);
27
+ }
28
+ catch {
29
+ continue;
30
+ }
31
+ if (d.type !== "user" && d.type !== "assistant")
32
+ continue;
33
+ const message = d.message || {};
34
+ const role = message.role;
35
+ if (role !== "user" && role !== "assistant")
36
+ continue;
37
+ const content = message.content;
38
+ const text = renderContent(content);
39
+ const toolCalls = [];
40
+ if (Array.isArray(content)) {
41
+ for (const block of content) {
42
+ if (isPlainObject(block) && block.type === "tool_use") {
43
+ toolCalls.push(block.name || "unknown_tool");
44
+ }
45
+ }
46
+ }
47
+ if (!text && toolCalls.length === 0)
48
+ continue;
49
+ const cwd = d.cwd;
50
+ if (!cwd)
51
+ continue;
52
+ turns.push({
53
+ project_cwd: cwd, // resolved to the real project root later
54
+ session_id: d.sessionId ?? null,
55
+ agent: "claude-code",
56
+ timestamp: d.timestamp ?? null,
57
+ role,
58
+ text,
59
+ tool_calls: toolCalls,
60
+ source_mtime_ms,
61
+ });
62
+ }
63
+ return turns;
64
+ }
65
+ export const CLAUDE_CODE_DIR_SHAPE = "~/.claude (projects/<name>/<session-id>.jsonl is resolved at sync time)";
66
+ /** True when `dir` is the top-level Claude install dir (~/.claude). */
67
+ export function looksLikeClaudeCodeDir(dir) {
68
+ const projectsDir = claudeProjectsDir(dir);
69
+ if (!fs.existsSync(projectsDir))
70
+ return false;
71
+ return listDirs(projectsDir).some((projectDir) => listFiles(projectDir, ".jsonl").length > 0);
72
+ }
73
+ export function collectClaudeCodeTurns(configuredDir) {
74
+ const turns = [];
75
+ for (const projectDir of listDirs(claudeProjectsDir(configuredDir))) {
76
+ for (const jsonlFile of listFiles(projectDir, ".jsonl")) {
77
+ for (const turn of parseClaudeCodeFile(jsonlFile))
78
+ turns.push(turn);
79
+ }
80
+ }
81
+ return turns;
82
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Codex CLI stores each session as a plain JSONL "rollout" file under
3
+ * ~/.codex/sessions/YYYY/MM/DD/*.jsonl -- a session_meta event carries the
4
+ * cwd/session id, followed by response_item events for everything else.
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { hasFileWithSuffix, newestMtimeMs, renderContent, walkJsonl } from "./shared.js";
10
+ export const DEFAULT_CODEX_DIR = path.join(os.homedir(), ".codex");
11
+ export const DEFAULT_CODEX_SESSIONS_DIR = path.join(DEFAULT_CODEX_DIR, "sessions");
12
+ export function codexSessionsDir(configuredDir) {
13
+ return path.join(path.resolve(configuredDir), "sessions");
14
+ }
15
+ /**
16
+ * Yields normalized turns from one Codex CLI session .jsonl rollout file.
17
+ * Handles every response_item payload type actually observed in these files
18
+ * (message, function_call, function_call_output, custom_tool_call,
19
+ * custom_tool_call_output, reasoning) -- earlier versions silently dropped
20
+ * function_call_output (tool results) and custom_tool_call_output entirely,
21
+ * and only kept tool *names* for function_call, not their arguments.
22
+ */
23
+ function parseCodexFile(filePath) {
24
+ const turns = [];
25
+ const source_mtime_ms = newestMtimeMs([filePath]);
26
+ let cwd = null;
27
+ let sessionId = null;
28
+ const lines = fs.readFileSync(filePath, "utf-8").split("\n");
29
+ for (const rawLine of lines) {
30
+ const line = rawLine.trim();
31
+ if (!line)
32
+ continue;
33
+ let d;
34
+ try {
35
+ d = JSON.parse(line);
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ if (d.type === "session_meta") {
41
+ const payload = d.payload || {};
42
+ cwd = payload.cwd ?? null;
43
+ sessionId = payload.id ?? null;
44
+ continue;
45
+ }
46
+ if (d.type !== "response_item")
47
+ continue;
48
+ if (!cwd)
49
+ continue;
50
+ const payload = d.payload || {};
51
+ const ptype = payload.type;
52
+ const timestamp = d.timestamp ?? null;
53
+ const base = { project_cwd: cwd, session_id: sessionId, agent: "codex", timestamp };
54
+ if (ptype === "message") {
55
+ const role = payload.role;
56
+ if (role !== "user" && role !== "assistant")
57
+ continue;
58
+ const text = renderContent(payload.content);
59
+ if (!text)
60
+ continue;
61
+ turns.push({ ...base, role, text, tool_calls: [], source_mtime_ms });
62
+ }
63
+ else if (ptype === "function_call") {
64
+ const name = payload.name || "unknown_tool";
65
+ const args = payload.arguments ?? "";
66
+ turns.push({ ...base, role: "assistant", text: `[tool_call: ${name}] ${args}`, tool_calls: [name], source_mtime_ms });
67
+ }
68
+ else if (ptype === "custom_tool_call") {
69
+ const name = payload.name || "unknown_tool";
70
+ const args = payload.input ?? "";
71
+ turns.push({ ...base, role: "assistant", text: `[tool_call: ${name}] ${args}`, tool_calls: [name], source_mtime_ms });
72
+ }
73
+ else if (ptype === "function_call_output" || ptype === "custom_tool_call_output") {
74
+ let output = payload.output ?? "";
75
+ if (typeof output !== "string")
76
+ output = JSON.stringify(output);
77
+ // Matches Claude Code's convention of tool results living in a
78
+ // user-role turn (that's the API shape both are modeled on).
79
+ turns.push({ ...base, role: "user", text: `[tool_result] ${output}`, tool_calls: [], source_mtime_ms });
80
+ }
81
+ else if (ptype === "reasoning") {
82
+ const summaryText = renderContent(payload.summary);
83
+ const contentText = renderContent(payload.content);
84
+ const text = [summaryText, contentText].filter((t) => t).join("\n");
85
+ if (text) {
86
+ turns.push({ ...base, role: "assistant", text: `[reasoning] ${text}`, tool_calls: [], source_mtime_ms });
87
+ }
88
+ }
89
+ }
90
+ return turns;
91
+ }
92
+ export const CODEX_DIR_SHAPE = "~/.codex (sessions/YYYY/MM/DD/*.jsonl is resolved at sync time)";
93
+ /** True when `dir` is the top-level Codex install dir (~/.codex). */
94
+ export function looksLikeCodexDir(dir) {
95
+ const sessionsDir = codexSessionsDir(dir);
96
+ return fs.existsSync(sessionsDir) && hasFileWithSuffix(sessionsDir, ".jsonl");
97
+ }
98
+ /** `configuredDir` is the user-configured top-level dir (~/.codex). */
99
+ export function collectCodexTurns(configuredDir) {
100
+ const turns = [];
101
+ for (const jsonlFile of walkJsonl(codexSessionsDir(configuredDir))) {
102
+ for (const turn of parseCodexFile(jsonlFile))
103
+ turns.push(turn);
104
+ }
105
+ return turns;
106
+ }
@@ -0,0 +1,492 @@
1
+ /**
2
+ * Cursor agent history lives in two places Cursor keeps in sync for most
3
+ * sessions, but not always:
4
+ *
5
+ * ~/.cursor/chats/<projectHash>/<sessionId>/{meta.json, store.db}
6
+ * -- SQLite blob store; richest source when present.
7
+ * ~/.cursor/projects/<workspaceSlug>/agent-transcripts/<sessionId>/<id>.jsonl
8
+ * -- JSONL export; often the ONLY copy for a live session (store.db may
9
+ * never appear). Lossier (no per-turn timestamps, truncated tool output)
10
+ * but required for current chats.
11
+ *
12
+ * resumecontext scopes collection to Cursor workspaces that belong to the
13
+ * project: the slug derived from the resumecontext project root, plus any
14
+ * slug whose path is nested inside that root. Cursor gives every directory
15
+ * it is opened in its own ~/.cursor/projects/<slug>/ folder, so a session
16
+ * run from a subdirectory like cli/ lives under a different slug than the
17
+ * parent project and must be discovered via slug-prefix matching (the
18
+ * transform is lossy, so we match `w === p || w.startsWith(p + "-")` rather
19
+ * than decoding). Sessions with meta.json still pass through the generic
20
+ * resolveProjectRoots filter on their real cwd; transcript-only sessions
21
+ * rely on this slug containment. Unrelated workspaces on the machine are
22
+ * excluded. Within matching workspaces, only sessions whose start time is
23
+ * at or after the project's .resumecontext.json createdAt are included.
24
+ */
25
+ import fs from "node:fs";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+ import { createRequire } from "node:module";
29
+ import { CURSOR_SQLITE_TIMEOUT_MS } from "../constants.js";
30
+ import { isPlainObject, listDirs, msToIso, newestMtimeMs, renderContent } from "./shared.js";
31
+ // Not a static `import { DatabaseSync } from "node:sqlite"`: this module is
32
+ // imported unconditionally (registry.ts's AGENT_ADAPTERS covers every
33
+ // agent regardless of what a given project has configured), and merely
34
+ // importing node:sqlite -- even without ever constructing a DatabaseSync --
35
+ // prints Node's ExperimentalWarning immediately. That warning would then
36
+ // fire on every single CLI invocation, including every daemon tick (a
37
+ // fresh process every DAEMON_INTERVAL_MS) whether or not that tick's
38
+ // projects use Cursor at all, drowning out daemon.log with noise no one
39
+ // asked for. createRequire's require() defers actually loading the module
40
+ // (and so the warning) until parseCursorSession genuinely runs.
41
+ const require = createRequire(import.meta.url);
42
+ function loadDatabaseSync() {
43
+ return require("node:sqlite").DatabaseSync;
44
+ }
45
+ export const DEFAULT_CURSOR_DIR = path.join(os.homedir(), ".cursor");
46
+ export const DEFAULT_CURSOR_CHATS_DIR = path.join(DEFAULT_CURSOR_DIR, "chats");
47
+ export const DEFAULT_CURSOR_PROJECTS_DIR = path.join(DEFAULT_CURSOR_DIR, "projects");
48
+ const TIMESTAMP_TAG_RE = /<timestamp>([^<]+)<\/timestamp>/;
49
+ /** Maps a resumecontext project root to Cursor's ~/.cursor/projects/<slug>. */
50
+ export function cursorWorkspaceSlug(projectRoot) {
51
+ return path.resolve(projectRoot).replace(/^\//, "").replace(/\//g, "-");
52
+ }
53
+ export function cursorAgentTranscriptsDir(projectRoot, projectsDir = DEFAULT_CURSOR_PROJECTS_DIR) {
54
+ return path.join(projectsDir, cursorWorkspaceSlug(projectRoot), "agent-transcripts");
55
+ }
56
+ /** Cursor workspace slugs under ~/.cursor/projects/ that belong to this
57
+ * project: the root's own slug plus any slug for a nested subdirectory.
58
+ * Prefix matching is lossy (slashes and literal dashes both become `-`) but
59
+ * is only used to discover which folders to scan; meta.json-backed sessions
60
+ * are still filtered by their real cwd downstream. */
61
+ function matchingWorkspaceSlugs(projectRoot, projectsDir) {
62
+ const projectSlug = cursorWorkspaceSlug(projectRoot);
63
+ if (!fs.existsSync(projectsDir))
64
+ return [];
65
+ return listDirs(projectsDir)
66
+ .map((d) => path.basename(d))
67
+ .filter((slug) => slug === projectSlug || slug.startsWith(`${projectSlug}-`));
68
+ }
69
+ function cursorAgentTranscriptsDirs(projectRoot, projectsDir) {
70
+ return matchingWorkspaceSlugs(projectRoot, projectsDir).map((slug) => path.join(projectsDir, slug, "agent-transcripts"));
71
+ }
72
+ /** Project-scoped paths derived at sync/fingerprint time from the configured
73
+ * ~/.cursor base and the resumecontext project root. */
74
+ export function cursorDerivedDirs(projectRoot, configuredCursorDir) {
75
+ const base = path.resolve(configuredCursorDir);
76
+ const projectsDir = path.join(base, "projects");
77
+ return {
78
+ chatsDir: path.join(base, "chats"),
79
+ projectsDir,
80
+ transcriptsDirs: cursorAgentTranscriptsDirs(projectRoot, projectsDir),
81
+ };
82
+ }
83
+ /** Yields normalized turns from one Cursor chat session directory
84
+ * (~/.cursor/chats/<projectHash>/<sessionId>/). */
85
+ function parseCursorSession(sessionDir) {
86
+ const metaPath = path.join(sessionDir, "meta.json");
87
+ const dbPath = path.join(sessionDir, "store.db");
88
+ if (!fs.existsSync(metaPath) || !fs.existsSync(dbPath))
89
+ return [];
90
+ // Only meta.json and store.db -- NOT store.db-shm or store.db-wal. Opening
91
+ // a WAL-mode SQLite database read-only bumps the -shm mtime, which would
92
+ // keep every Cursor session permanently "hot" and never sync its trailing
93
+ // turn (see daemon.ts isFingerprintNoise for the same trap).
94
+ const source_mtime_ms = newestMtimeMs([metaPath, dbPath]);
95
+ let meta;
96
+ try {
97
+ meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
98
+ }
99
+ catch {
100
+ return [];
101
+ }
102
+ const cwd = meta.cwd;
103
+ if (!cwd || meta.hasConversation === false)
104
+ return [];
105
+ const sessionId = path.basename(sessionDir);
106
+ const startMs = meta.createdAtMs ?? null;
107
+ let endMs = meta.updatedAtMs ?? startMs;
108
+ if (endMs !== null && startMs !== null && endMs < startMs)
109
+ endMs = startMs;
110
+ let rows;
111
+ try {
112
+ // Read-only connection: this file can be live-open in a running Cursor
113
+ // instance, and we must never write to or lock real app state.
114
+ const DatabaseSync = loadDatabaseSync();
115
+ const db = new DatabaseSync(dbPath, { readOnly: true, timeout: CURSOR_SQLITE_TIMEOUT_MS });
116
+ try {
117
+ rows = db.prepare("SELECT id, data FROM blobs ORDER BY rowid").all();
118
+ }
119
+ finally {
120
+ db.close();
121
+ }
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ // First collect the raw (role, text, tool_calls) turns in chat order,
127
+ // THEN assign interpolated timestamps once we know the final count --
128
+ // can't interpolate on the fly since we don't know N until we're done.
129
+ const rawTurns = [];
130
+ for (const { data } of rows) {
131
+ let d;
132
+ try {
133
+ // Binary protobuf envelope blobs decode to non-JSON text (or, on the
134
+ // rare invalid-UTF-8 byte, a lossy replacement-char string) and then
135
+ // fail JSON.parse -- functionally equivalent to Python's explicit
136
+ // UnicodeDecodeError/JSONDecodeError catch, just via one path.
137
+ d = JSON.parse(Buffer.from(data).toString("utf-8"));
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ if (!isPlainObject(d))
143
+ continue;
144
+ const role = d.role;
145
+ if (role !== "user" && role !== "assistant" && role !== "tool") {
146
+ continue; // covers "system" (skipped -- static boilerplate, same
147
+ // precedent as Claude Code/Codex never emitting a system turn) and
148
+ // the store's other non-message JSON blobs (e.g. our own tool's
149
+ // cached results, which have no "role" key at all).
150
+ }
151
+ const content = d.content;
152
+ const text = renderContent(content);
153
+ const toolCalls = [];
154
+ if (Array.isArray(content)) {
155
+ for (const block of content) {
156
+ if (isPlainObject(block) && block.type === "tool-call") {
157
+ toolCalls.push(block.toolName || "unknown_tool");
158
+ }
159
+ }
160
+ }
161
+ if (!text && toolCalls.length === 0)
162
+ continue;
163
+ // Tool results are their own role='tool' message in this store, unlike
164
+ // Claude/Codex where they're folded into a user-role turn -- normalize
165
+ // to the same "tool results live in a user turn" convention so
166
+ // downstream code (which only ever expects user/assistant) is
167
+ // consistent across all four agents.
168
+ const normalizedRole = role === "tool" ? "user" : role;
169
+ rawTurns.push({ role: normalizedRole, text, toolCalls });
170
+ }
171
+ const n = rawTurns.length;
172
+ const turns = [];
173
+ rawTurns.forEach((rt, i) => {
174
+ let tsMs;
175
+ if (startMs === null) {
176
+ tsMs = null;
177
+ }
178
+ else if (endMs === null || endMs === startMs || n <= 1) {
179
+ tsMs = startMs;
180
+ }
181
+ else {
182
+ tsMs = startMs + Math.floor(((endMs - startMs) * i) / (n - 1));
183
+ }
184
+ turns.push({
185
+ project_cwd: cwd, // resolved to the real project root later
186
+ session_id: sessionId,
187
+ agent: "cursor",
188
+ timestamp: tsMs !== null ? msToIso(tsMs) : null,
189
+ role: rt.role,
190
+ text: rt.text,
191
+ tool_calls: rt.toolCalls,
192
+ source_mtime_ms,
193
+ });
194
+ });
195
+ return turns;
196
+ }
197
+ function readSessionMeta(sessionDir) {
198
+ try {
199
+ return JSON.parse(fs.readFileSync(path.join(sessionDir, "meta.json"), "utf-8"));
200
+ }
201
+ catch {
202
+ return null;
203
+ }
204
+ }
205
+ /** Session start time for filtering: meta.createdAtMs when the SQLite copy
206
+ * exists, otherwise the first <timestamp> tag in the JSONL. */
207
+ export function sessionStartMs(chatsSessionDir, transcriptPath) {
208
+ if (chatsSessionDir) {
209
+ const meta = readSessionMeta(chatsSessionDir);
210
+ if (meta?.createdAtMs != null)
211
+ return meta.createdAtMs;
212
+ }
213
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
214
+ try {
215
+ const lines = fs.readFileSync(transcriptPath, "utf-8").split("\n");
216
+ for (const line of lines) {
217
+ if (!line.trim())
218
+ continue;
219
+ const parsed = JSON.parse(line);
220
+ if (parsed.role !== "user")
221
+ continue;
222
+ const text = renderContent(parsed.message?.content);
223
+ const match = TIMESTAMP_TAG_RE.exec(text);
224
+ if (match) {
225
+ const ms = Date.parse(match[1].trim());
226
+ if (!Number.isNaN(ms))
227
+ return ms;
228
+ }
229
+ }
230
+ }
231
+ catch {
232
+ // fall through
233
+ }
234
+ try {
235
+ return fs.statSync(transcriptPath).birthtimeMs;
236
+ }
237
+ catch {
238
+ return null;
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+ function findChatsSessionDir(chatsBaseDir, sessionId) {
244
+ for (const projectHashDir of listDirs(chatsBaseDir)) {
245
+ const sessionDir = path.join(projectHashDir, sessionId);
246
+ if (fs.existsSync(path.join(sessionDir, "store.db")))
247
+ return sessionDir;
248
+ }
249
+ return null;
250
+ }
251
+ function parseTimestampFromUserText(text) {
252
+ const match = TIMESTAMP_TAG_RE.exec(text);
253
+ if (!match)
254
+ return null;
255
+ const ms = Date.parse(match[1].trim());
256
+ return Number.isNaN(ms) ? null : ms;
257
+ }
258
+ /** Latest activity signal for a session -- used with projectInitAt to drop
259
+ * workspace history that ended before `init`, while keeping a live thread
260
+ * that started earlier but is still receiving messages. */
261
+ export function sessionLastActivityMs(chatsSessionDir, transcriptPath) {
262
+ let latest = null;
263
+ const bump = (ms) => {
264
+ if (ms == null || Number.isNaN(ms))
265
+ return;
266
+ if (latest === null || ms > latest)
267
+ latest = ms;
268
+ };
269
+ if (chatsSessionDir) {
270
+ const meta = readSessionMeta(chatsSessionDir);
271
+ bump(meta?.updatedAtMs ?? meta?.createdAtMs);
272
+ try {
273
+ bump(fs.statSync(path.join(chatsSessionDir, "store.db")).mtimeMs);
274
+ }
275
+ catch {
276
+ // no store.db yet
277
+ }
278
+ }
279
+ if (transcriptPath && fs.existsSync(transcriptPath)) {
280
+ try {
281
+ const lines = fs.readFileSync(transcriptPath, "utf-8").split("\n");
282
+ for (const line of lines) {
283
+ if (!line.trim())
284
+ continue;
285
+ try {
286
+ const parsed = JSON.parse(line);
287
+ if (parsed.role !== "user")
288
+ continue;
289
+ bump(parseTimestampFromUserText(renderContent(parsed.message?.content)));
290
+ }
291
+ catch {
292
+ // skip bad line
293
+ }
294
+ }
295
+ }
296
+ catch {
297
+ // unreadable transcript
298
+ }
299
+ try {
300
+ bump(fs.statSync(transcriptPath).mtimeMs);
301
+ }
302
+ catch {
303
+ // missing
304
+ }
305
+ }
306
+ return latest;
307
+ }
308
+ function sessionIncludedAfterInit(chatsSessionDir, transcriptPath, projectInitAtMs) {
309
+ if (chatsSessionDir) {
310
+ const meta = readSessionMeta(chatsSessionDir);
311
+ if (meta?.updatedAtMs != null && meta.updatedAtMs >= projectInitAtMs)
312
+ return true;
313
+ if (meta?.createdAtMs != null && meta.createdAtMs >= projectInitAtMs)
314
+ return true;
315
+ }
316
+ if (!transcriptPath || !fs.existsSync(transcriptPath))
317
+ return false;
318
+ let latestUserMs = null;
319
+ try {
320
+ for (const line of fs.readFileSync(transcriptPath, "utf-8").split("\n")) {
321
+ if (!line.trim())
322
+ continue;
323
+ try {
324
+ const parsed = JSON.parse(line);
325
+ if (parsed.role !== "user")
326
+ continue;
327
+ const ms = parseTimestampFromUserText(renderContent(parsed.message?.content));
328
+ if (ms !== null && (latestUserMs === null || ms > latestUserMs))
329
+ latestUserMs = ms;
330
+ }
331
+ catch {
332
+ // skip bad line
333
+ }
334
+ }
335
+ }
336
+ catch {
337
+ return false;
338
+ }
339
+ if (latestUserMs !== null)
340
+ return latestUserMs >= projectInitAtMs;
341
+ // Transcript-only session with no embedded timestamps -- fall back to file mtime.
342
+ try {
343
+ return fs.statSync(transcriptPath).mtimeMs >= projectInitAtMs;
344
+ }
345
+ catch {
346
+ return false;
347
+ }
348
+ }
349
+ function assignInterpolatedTimestamps(rawTurns, startMs, endMs) {
350
+ if (rawTurns.every((t) => t.timestampMs !== null))
351
+ return;
352
+ const known = rawTurns.map((t, i) => ({ i, ms: t.timestampMs })).filter((t) => t.ms !== null);
353
+ if (known.length === 0) {
354
+ if (startMs === null)
355
+ return;
356
+ rawTurns.forEach((t, i) => {
357
+ t.timestampMs = endMs === null || endMs === startMs || rawTurns.length <= 1
358
+ ? startMs
359
+ : startMs + Math.floor(((endMs - startMs) * i) / (rawTurns.length - 1));
360
+ });
361
+ return;
362
+ }
363
+ for (let i = 0; i < rawTurns.length; i++) {
364
+ if (rawTurns[i].timestampMs !== null)
365
+ continue;
366
+ const prev = [...known].reverse().find((k) => k.i < i);
367
+ const next = known.find((k) => k.i > i);
368
+ if (prev && next) {
369
+ rawTurns[i].timestampMs = prev.ms + Math.floor(((next.ms - prev.ms) * (i - prev.i)) / (next.i - prev.i));
370
+ }
371
+ else if (prev) {
372
+ rawTurns[i].timestampMs = prev.ms;
373
+ }
374
+ else if (next) {
375
+ rawTurns[i].timestampMs = next.ms;
376
+ }
377
+ else if (startMs !== null) {
378
+ rawTurns[i].timestampMs = startMs;
379
+ }
380
+ }
381
+ }
382
+ /** Parses ~/.cursor/projects/.../agent-transcripts/<id>/<id>.jsonl -- used
383
+ * when store.db hasn't been written yet for a live session. */
384
+ export function parseCursorAgentTranscript(transcriptPath, sessionId, projectCwd) {
385
+ let lines;
386
+ try {
387
+ lines = fs.readFileSync(transcriptPath, "utf-8").split("\n");
388
+ }
389
+ catch {
390
+ return [];
391
+ }
392
+ const source_mtime_ms = newestMtimeMs([transcriptPath]);
393
+ const rawTurns = [];
394
+ for (const line of lines) {
395
+ if (!line.trim())
396
+ continue;
397
+ let parsed;
398
+ try {
399
+ parsed = JSON.parse(line);
400
+ }
401
+ catch {
402
+ continue;
403
+ }
404
+ if (parsed.type === "turn_ended")
405
+ continue;
406
+ const role = parsed.role;
407
+ if (role !== "user" && role !== "assistant" && role !== "tool")
408
+ continue;
409
+ const content = parsed.message?.content;
410
+ const text = renderContent(content);
411
+ const toolCalls = [];
412
+ if (Array.isArray(content)) {
413
+ for (const block of content) {
414
+ if (!isPlainObject(block))
415
+ continue;
416
+ if (block.type === "tool-call" || block.type === "tool_use") {
417
+ toolCalls.push(block.toolName || block.name || "unknown_tool");
418
+ }
419
+ }
420
+ }
421
+ if (!text && toolCalls.length === 0)
422
+ continue;
423
+ const normalizedRole = role === "tool" ? "user" : role;
424
+ const timestampMs = role === "user" ? parseTimestampFromUserText(text) : null;
425
+ rawTurns.push({ role: normalizedRole, text, toolCalls, timestampMs });
426
+ }
427
+ const startMs = sessionStartMs(null, transcriptPath);
428
+ const endMs = rawTurns.reduce((latest, t) => {
429
+ if (t.timestampMs === null)
430
+ return latest;
431
+ return latest === null || t.timestampMs > latest ? t.timestampMs : latest;
432
+ }, startMs);
433
+ assignInterpolatedTimestamps(rawTurns, startMs, endMs);
434
+ return rawTurns.map((rt) => ({
435
+ project_cwd: projectCwd,
436
+ session_id: sessionId,
437
+ agent: "cursor",
438
+ timestamp: rt.timestampMs !== null ? msToIso(rt.timestampMs) : null,
439
+ role: rt.role,
440
+ text: rt.text,
441
+ tool_calls: rt.toolCalls,
442
+ source_mtime_ms,
443
+ }));
444
+ }
445
+ /** Workspace-scoped collection: sessions under this project's Cursor
446
+ * workspace(s) agent-transcripts/ (root and nested subdirectories), started
447
+ * at or after projectInitAtMs. `configuredCursorDir` is the user-configured
448
+ * top-level dir (~/.cursor). */
449
+ export function collectCursorTurnsForProject(projectRoot, projectInitAtMs, configuredCursorDir = DEFAULT_CURSOR_DIR) {
450
+ const { chatsDir: chatsBaseDir, transcriptsDirs } = cursorDerivedDirs(projectRoot, configuredCursorDir);
451
+ const projectCwd = path.resolve(projectRoot);
452
+ const turns = [];
453
+ for (const transcriptsDir of transcriptsDirs) {
454
+ if (!fs.existsSync(transcriptsDir))
455
+ continue;
456
+ for (const sessionDir of listDirs(transcriptsDir)) {
457
+ const sessionId = path.basename(sessionDir);
458
+ const transcriptPath = path.join(sessionDir, `${sessionId}.jsonl`);
459
+ const chatsSessionDir = findChatsSessionDir(chatsBaseDir, sessionId);
460
+ const transcriptExists = fs.existsSync(transcriptPath);
461
+ if (!sessionIncludedAfterInit(chatsSessionDir, transcriptExists ? transcriptPath : null, projectInitAtMs))
462
+ continue;
463
+ if (chatsSessionDir) {
464
+ turns.push(...parseCursorSession(chatsSessionDir));
465
+ }
466
+ else if (transcriptExists) {
467
+ turns.push(...parseCursorAgentTranscript(transcriptPath, sessionId, projectCwd));
468
+ }
469
+ }
470
+ }
471
+ return turns;
472
+ }
473
+ export const CURSOR_DIR_SHAPE = "~/.cursor (chats/ and projects/<slug>/agent-transcripts/ are resolved at sync time)";
474
+ /** True when `dir` is the top-level Cursor install dir (~/.cursor). */
475
+ export function looksLikeCursorDir(dir) {
476
+ const base = path.resolve(dir);
477
+ return fs.existsSync(path.join(base, "chats")) || fs.existsSync(path.join(base, "projects"));
478
+ }
479
+ /** Used only when validating a configured path outside project-scoped sync. */
480
+ export function collectCursorTurns(configuredDir) {
481
+ const chatsDir = path.join(path.resolve(configuredDir), "chats");
482
+ if (!fs.existsSync(chatsDir))
483
+ return [];
484
+ const turns = [];
485
+ for (const projectHashDir of listDirs(chatsDir)) {
486
+ for (const sessionDir of listDirs(projectHashDir)) {
487
+ for (const turn of parseCursorSession(sessionDir))
488
+ turns.push(turn);
489
+ }
490
+ }
491
+ return turns;
492
+ }