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.
- package/README.md +27 -0
- package/dist/agentConfig.js +202 -0
- package/dist/agentConfigWithDaemon.js +42 -0
- package/dist/apiClient.js +54 -0
- package/dist/browser.js +20 -0
- package/dist/cloudApi.js +15 -0
- package/dist/commands/accept.js +20 -0
- package/dist/commands/agents.js +35 -0
- package/dist/commands/auth.js +55 -0
- package/dist/commands/daemon.js +99 -0
- package/dist/commands/init.js +59 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/mcp.js +54 -0
- package/dist/commands/members.js +20 -0
- package/dist/commands/projects.js +55 -0
- package/dist/commands/revoke.js +15 -0
- package/dist/commands/share.js +18 -0
- package/dist/commands/sync.js +59 -0
- package/dist/commands/uninstall.js +61 -0
- package/dist/constants.js +61 -0
- package/dist/daemon.js +409 -0
- package/dist/daemonService.js +326 -0
- package/dist/deps.js +1 -0
- package/dist/dev.js +32 -0
- package/dist/device.js +40 -0
- package/dist/httpCloudApi.js +61 -0
- package/dist/index.js +160 -0
- package/dist/localCapture.js +18 -0
- package/dist/localHistory/claudeCode.js +82 -0
- package/dist/localHistory/codex.js +106 -0
- package/dist/localHistory/cursor.js +492 -0
- package/dist/localHistory/index.js +96 -0
- package/dist/localHistory/opencode.js +148 -0
- package/dist/localHistory/registry.js +66 -0
- package/dist/localHistory/shared.js +174 -0
- package/dist/paths.js +85 -0
- package/dist/projectRoot.js +77 -0
- package/dist/session.js +36 -0
- package/dist/syncCore.js +108 -0
- package/dist/syncState.js +51 -0
- package/dist/ui.js +289 -0
- package/dist/utils.js +41 -0
- package/dist/version.js +43 -0
- 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
|
+
}
|