@groundtrack/hooks 0.1.9 → 0.1.10
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 +28 -10
- package/dist/agents.d.ts +55 -0
- package/dist/agents.js +77 -3
- package/dist/groundtrack-hook.js +0 -0
- package/dist/groundtrack.js +0 -0
- package/dist/session-import/canonical.d.ts +33 -0
- package/dist/session-import/canonical.js +10 -0
- package/dist/session-import/claude.d.ts +20 -0
- package/dist/session-import/claude.js +237 -0
- package/dist/session-import/cli.d.ts +3 -2
- package/dist/session-import/cli.js +161 -131
- package/dist/session-import/client.d.ts +4 -3
- package/dist/session-import/client.js +4 -3
- package/dist/session-import/codex.d.ts +6 -33
- package/dist/session-import/codex.js +62 -9
- package/dist/session-import/cursor.d.ts +13 -0
- package/dist/session-import/cursor.js +268 -0
- package/dist/session-import/discovery.d.ts +16 -7
- package/dist/session-import/discovery.js +120 -67
- package/dist/session-import/format.d.ts +51 -0
- package/dist/session-import/format.js +11 -0
- package/dist/session-import/formats.d.ts +4 -0
- package/dist/session-import/formats.js +13 -0
- package/dist/session-import/normalize.d.ts +21 -0
- package/dist/session-import/normalize.js +96 -0
- package/dist/session-import/opencode-cli.d.ts +14 -0
- package/dist/session-import/opencode-cli.js +178 -0
- package/dist/session-import/opencode-normalizer.d.ts +8 -0
- package/dist/session-import/opencode-normalizer.js +230 -0
- package/dist/session-import/opencode.d.ts +8 -0
- package/dist/session-import/opencode.js +230 -0
- package/dist/session-import/prune.d.ts +13 -4
- package/dist/session-import/prune.js +45 -79
- package/dist/session-import/receipts.d.ts +2 -1
- package/dist/session-import/receipts.js +3 -3
- package/dist/session-import/types.d.ts +21 -7
- package/dist/setup/agents.d.ts +32 -0
- package/package.json +29 -1
- package/src/agents.ts +104 -3
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isSessionRecord, sessionString, } from "./canonical.js";
|
|
5
|
+
import { sessionImportFormatIdentity, } from "./format.js";
|
|
6
|
+
import { containsInternalInstructionText, sanitizeSessionValue, sanitizeWorkingDirectory, } from "./redaction.js";
|
|
7
|
+
function timestamp(value) {
|
|
8
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
9
|
+
return null;
|
|
10
|
+
const date = new Date(value);
|
|
11
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
12
|
+
}
|
|
13
|
+
function defaultDatabasePath(context) {
|
|
14
|
+
if (context.env.CURSOR_STATE_DB)
|
|
15
|
+
return context.env.CURSOR_STATE_DB;
|
|
16
|
+
if (context.platform === "darwin")
|
|
17
|
+
return join(context.userHome, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb");
|
|
18
|
+
if (context.platform === "win32" && context.env.APPDATA)
|
|
19
|
+
return join(context.env.APPDATA, "Cursor", "User", "globalStorage", "state.vscdb");
|
|
20
|
+
return join(context.env.XDG_CONFIG_HOME || join(context.userHome, ".config"), "Cursor", "User", "globalStorage", "state.vscdb");
|
|
21
|
+
}
|
|
22
|
+
function readHeaders(context) {
|
|
23
|
+
const path = defaultDatabasePath(context);
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
throw new Error(`Cursor session database not found: ${path}`);
|
|
26
|
+
let DatabaseSync;
|
|
27
|
+
try {
|
|
28
|
+
const sqlite = createRequire(import.meta.url)("node:sqlite");
|
|
29
|
+
DatabaseSync = sqlite.DatabaseSync;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw new Error("Cursor session import requires unflagged node:sqlite support (Node.js 22.13+, 23.4+, or 24+).");
|
|
33
|
+
}
|
|
34
|
+
const database = new DatabaseSync(path, { readOnly: true });
|
|
35
|
+
try {
|
|
36
|
+
return database
|
|
37
|
+
.prepare(`
|
|
38
|
+
SELECT
|
|
39
|
+
composerId,
|
|
40
|
+
createdAt,
|
|
41
|
+
lastUpdatedAt,
|
|
42
|
+
isSubagent,
|
|
43
|
+
CASE WHEN json_valid(value)
|
|
44
|
+
THEN COALESCE(
|
|
45
|
+
json_extract(value, '$.workspaceIdentifier.uri.fsPath'),
|
|
46
|
+
json_extract(value, '$.trackedGitRepos[0].repoPath')
|
|
47
|
+
)
|
|
48
|
+
ELSE NULL
|
|
49
|
+
END AS workingDirectory
|
|
50
|
+
FROM composerHeaders
|
|
51
|
+
`)
|
|
52
|
+
.all();
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
database.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function cursorProjectName(path) {
|
|
59
|
+
return path.replace(/^[/\\]+/, "").replaceAll(/[:/\\]+/g, "-");
|
|
60
|
+
}
|
|
61
|
+
function transcriptPaths(projectsRoot) {
|
|
62
|
+
const result = new Map();
|
|
63
|
+
if (!existsSync(projectsRoot))
|
|
64
|
+
return result;
|
|
65
|
+
for (const project of readdirSync(projectsRoot, { withFileTypes: true })) {
|
|
66
|
+
if (!project.isDirectory())
|
|
67
|
+
continue;
|
|
68
|
+
const transcripts = join(projectsRoot, project.name, "agent-transcripts");
|
|
69
|
+
if (!existsSync(transcripts))
|
|
70
|
+
continue;
|
|
71
|
+
for (const session of readdirSync(transcripts, { withFileTypes: true })) {
|
|
72
|
+
if (!session.isDirectory())
|
|
73
|
+
continue;
|
|
74
|
+
const path = join(transcripts, session.name, `${session.name}.jsonl`);
|
|
75
|
+
if (!existsSync(path))
|
|
76
|
+
continue;
|
|
77
|
+
const paths = result.get(session.name) ?? [];
|
|
78
|
+
paths.push(path);
|
|
79
|
+
result.set(session.name, paths);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
function selectTranscript(paths, workingDirectory) {
|
|
85
|
+
if (!workingDirectory)
|
|
86
|
+
return paths.length === 1 ? paths[0] : null;
|
|
87
|
+
const expectedProject = cursorProjectName(workingDirectory);
|
|
88
|
+
const matching = paths.filter((path) => dirname(dirname(dirname(path)))
|
|
89
|
+
.split(/[/\\]/)
|
|
90
|
+
.at(-1) === expectedProject);
|
|
91
|
+
return matching.length === 1 ? matching[0] : null;
|
|
92
|
+
}
|
|
93
|
+
function isEmptyTranscript(path) {
|
|
94
|
+
if (statSync(path).size > 64 * 1024)
|
|
95
|
+
return false;
|
|
96
|
+
let records;
|
|
97
|
+
try {
|
|
98
|
+
records = readFileSync(path, "utf8")
|
|
99
|
+
.split(/\r?\n/)
|
|
100
|
+
.filter(Boolean)
|
|
101
|
+
.map((line) => JSON.parse(line));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
return !records.some((record) => isSessionRecord(record) &&
|
|
107
|
+
(record.role === "user" || record.role === "assistant") &&
|
|
108
|
+
isSessionRecord(record.message) &&
|
|
109
|
+
Array.isArray(record.message.content));
|
|
110
|
+
}
|
|
111
|
+
export function cursorCandidates(context, headers = readHeaders(context)) {
|
|
112
|
+
const projectsRoot = context.env.CURSOR_PROJECTS_DIR ?? join(context.sessionHome, "projects");
|
|
113
|
+
const transcripts = transcriptPaths(projectsRoot);
|
|
114
|
+
const included = [];
|
|
115
|
+
const skipped = [];
|
|
116
|
+
const headerSessionIds = new Set();
|
|
117
|
+
for (const header of headers) {
|
|
118
|
+
const externalSessionId = sessionString(header.composerId);
|
|
119
|
+
if (!externalSessionId)
|
|
120
|
+
continue;
|
|
121
|
+
headerSessionIds.add(externalSessionId);
|
|
122
|
+
const paths = transcripts.get(externalSessionId);
|
|
123
|
+
if (!paths?.length)
|
|
124
|
+
continue;
|
|
125
|
+
const workingDirectory = sessionString(header.workingDirectory);
|
|
126
|
+
const path = selectTranscript(paths, workingDirectory);
|
|
127
|
+
if (!path) {
|
|
128
|
+
for (const duplicate of paths)
|
|
129
|
+
skipped.push({
|
|
130
|
+
path: duplicate,
|
|
131
|
+
reason: workingDirectory ? "workspace_mismatch" : "conflicting_duplicates",
|
|
132
|
+
});
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
for (const other of paths) {
|
|
136
|
+
if (other !== path)
|
|
137
|
+
skipped.push({ path: other, reason: "workspace_mismatch" });
|
|
138
|
+
}
|
|
139
|
+
const startedAt = timestamp(header.createdAt);
|
|
140
|
+
const endedAt = timestamp(header.lastUpdatedAt);
|
|
141
|
+
const discoveredAt = endedAt ?? new Date(statSync(path).mtimeMs);
|
|
142
|
+
if (!startedAt ||
|
|
143
|
+
(header.lastUpdatedAt != null && !endedAt) ||
|
|
144
|
+
(endedAt && endedAt < startedAt)) {
|
|
145
|
+
skipped.push({ path, reason: "invalid_metadata" });
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (isEmptyTranscript(path)) {
|
|
149
|
+
skipped.push({ path, reason: "empty_trace" });
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
included.push({
|
|
153
|
+
path,
|
|
154
|
+
metadata: {
|
|
155
|
+
externalSessionId,
|
|
156
|
+
timestamp: discoveredAt,
|
|
157
|
+
cwd: workingDirectory,
|
|
158
|
+
repositoryUrl: null,
|
|
159
|
+
repositoryBranch: null,
|
|
160
|
+
repositoryCommitSha: null,
|
|
161
|
+
originator: "cursor",
|
|
162
|
+
isSubagent: header.isSubagent === 1,
|
|
163
|
+
startedAt,
|
|
164
|
+
endedAt,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const [sessionId, paths] of transcripts) {
|
|
169
|
+
if (headerSessionIds.has(sessionId))
|
|
170
|
+
continue;
|
|
171
|
+
for (const path of paths)
|
|
172
|
+
skipped.push({ path, reason: "missing_metadata" });
|
|
173
|
+
}
|
|
174
|
+
return { included, skipped };
|
|
175
|
+
}
|
|
176
|
+
function normalizeMessage(record) {
|
|
177
|
+
const role = sessionString(record.role);
|
|
178
|
+
if (role !== "user" && role !== "assistant")
|
|
179
|
+
return null;
|
|
180
|
+
const message = isSessionRecord(record.message) ? record.message : null;
|
|
181
|
+
if (!message || !Array.isArray(message.content))
|
|
182
|
+
return null;
|
|
183
|
+
const entries = [];
|
|
184
|
+
let sensitive = false;
|
|
185
|
+
let unsupported = false;
|
|
186
|
+
for (const block of message.content) {
|
|
187
|
+
if (!isSessionRecord(block)) {
|
|
188
|
+
unsupported = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const type = sessionString(block.type);
|
|
192
|
+
if (type === "thinking" || type === "redacted_thinking") {
|
|
193
|
+
sensitive = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (type === "text") {
|
|
197
|
+
const text = sessionString(block.text);
|
|
198
|
+
if (!text)
|
|
199
|
+
continue;
|
|
200
|
+
if (containsInternalInstructionText(text))
|
|
201
|
+
sensitive = true;
|
|
202
|
+
else
|
|
203
|
+
entries.push({
|
|
204
|
+
kind: role === "user" ? "user_message" : "assistant_message",
|
|
205
|
+
occurredAt: null,
|
|
206
|
+
payload: { text: sanitizeSessionValue(text) },
|
|
207
|
+
});
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (role === "assistant" && type === "tool_use") {
|
|
211
|
+
const toolName = sessionString(block.name);
|
|
212
|
+
if (!toolName)
|
|
213
|
+
unsupported = true;
|
|
214
|
+
else
|
|
215
|
+
entries.push({
|
|
216
|
+
kind: "tool_call",
|
|
217
|
+
occurredAt: null,
|
|
218
|
+
payload: {
|
|
219
|
+
callId: sessionString(block.id),
|
|
220
|
+
toolName,
|
|
221
|
+
input: sanitizeSessionValue(block.input),
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
unsupported = true;
|
|
227
|
+
}
|
|
228
|
+
return { entries, sensitive, unsupported };
|
|
229
|
+
}
|
|
230
|
+
export function createCursorSessionNormalizer(metadata) {
|
|
231
|
+
return {
|
|
232
|
+
normalizeRecord: (record, entryBudget = Number.POSITIVE_INFINITY) => {
|
|
233
|
+
if (record.type === "turn_ended")
|
|
234
|
+
return { entries: [], sensitive: false, unsupported: false };
|
|
235
|
+
const normalized = normalizeMessage(record) ?? {
|
|
236
|
+
entries: [],
|
|
237
|
+
sensitive: false,
|
|
238
|
+
unsupported: true,
|
|
239
|
+
};
|
|
240
|
+
const overflowed = normalized.entries.length > entryBudget;
|
|
241
|
+
return overflowed
|
|
242
|
+
? { ...normalized, entries: normalized.entries.slice(0, entryBudget), overflowed }
|
|
243
|
+
: normalized;
|
|
244
|
+
},
|
|
245
|
+
canonicalMetadata: () => metadata
|
|
246
|
+
? {
|
|
247
|
+
type: "session",
|
|
248
|
+
version: 1,
|
|
249
|
+
externalSessionId: metadata.externalSessionId,
|
|
250
|
+
repositoryUrl: metadata.repositoryUrl,
|
|
251
|
+
repositoryBranch: metadata.repositoryBranch,
|
|
252
|
+
repositoryCommitSha: metadata.repositoryCommitSha,
|
|
253
|
+
workingDirectory: sanitizeWorkingDirectory(metadata.cwd),
|
|
254
|
+
originator: "cursor",
|
|
255
|
+
model: null,
|
|
256
|
+
}
|
|
257
|
+
: null,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
export const cursorSessionFormat = {
|
|
261
|
+
...sessionImportFormatIdentity("cursor"),
|
|
262
|
+
discovery: "custom",
|
|
263
|
+
homeEnvironmentVariable: "CURSOR_HOME",
|
|
264
|
+
defaultHomeDirectory: ".cursor",
|
|
265
|
+
discoverCandidates: cursorCandidates,
|
|
266
|
+
createNormalizer: createCursorSessionNormalizer,
|
|
267
|
+
sourceHashSeed: (metadata) => metadata.externalSessionId,
|
|
268
|
+
};
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { SessionImportFormat } from "./format.js";
|
|
2
|
+
import type { AgentSessionCandidate, FailedAgentSession, SkippedAgentSession } from "./types.js";
|
|
3
3
|
export declare function normalizeRepositoryUrl(value: string): string | null;
|
|
4
4
|
export type RepositoryIdentity = Readonly<{
|
|
5
5
|
root: string;
|
|
6
6
|
remotes: ReadonlySet<string>;
|
|
7
7
|
}>;
|
|
8
8
|
export declare function repositoryIdentity(path: string): RepositoryIdentity;
|
|
9
|
-
export type
|
|
10
|
-
|
|
9
|
+
export type DiscoverSessionsOptions = Readonly<{
|
|
10
|
+
home: string;
|
|
11
|
+
format: SessionImportFormat;
|
|
11
12
|
repository?: RepositoryIdentity;
|
|
12
13
|
allRepositories: boolean;
|
|
13
14
|
includeUnscoped: boolean;
|
|
@@ -15,8 +16,16 @@ export type DiscoverCodexSessionsOptions = Readonly<{
|
|
|
15
16
|
since: Date | null;
|
|
16
17
|
nowMs?: number;
|
|
17
18
|
liveWindowMs?: number;
|
|
19
|
+
resolveRepositoryRoot?: (path: string) => string;
|
|
20
|
+
resolveRepositoryIdentity?: (path: string) => RepositoryIdentity;
|
|
21
|
+
userHome?: string;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
platform?: NodeJS.Platform;
|
|
24
|
+
workingDirectory?: string;
|
|
18
25
|
}>;
|
|
19
|
-
export declare function
|
|
20
|
-
included:
|
|
21
|
-
skipped:
|
|
26
|
+
export declare function discoverSessions(options: DiscoverSessionsOptions): {
|
|
27
|
+
included: AgentSessionCandidate[];
|
|
28
|
+
skipped: SkippedAgentSession[];
|
|
29
|
+
failed: FailedAgentSession[];
|
|
30
|
+
cleanup?: () => void;
|
|
22
31
|
};
|
|
@@ -1,69 +1,46 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
2
|
import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, realpathSync, } from "node:fs";
|
|
3
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
function stringValue(value) {
|
|
8
|
-
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
9
|
-
}
|
|
10
|
-
function firstLine(path, maxBytes = 1024 * 1024) {
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { isSessionRecord } from "./canonical.js";
|
|
6
|
+
function initialRecords(path, maxRecords, maxBytes = 1024 * 1024) {
|
|
11
7
|
const file = openSync(path, "r");
|
|
12
8
|
try {
|
|
13
9
|
const chunks = [];
|
|
14
10
|
let offset = 0;
|
|
15
|
-
|
|
11
|
+
let newlineCount = 0;
|
|
12
|
+
while (offset < maxBytes && newlineCount < maxRecords) {
|
|
16
13
|
const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes - offset));
|
|
17
14
|
const read = readSync(file, chunk, 0, chunk.length, offset);
|
|
18
15
|
if (read === 0)
|
|
19
16
|
break;
|
|
20
17
|
const bytes = chunk.subarray(0, read);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
18
|
+
chunks.push(bytes);
|
|
19
|
+
offset += read;
|
|
20
|
+
for (const byte of bytes) {
|
|
21
|
+
if (byte === 10 && ++newlineCount >= maxRecords)
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
26
24
|
}
|
|
27
|
-
return Buffer.concat(chunks)
|
|
25
|
+
return Buffer.concat(chunks)
|
|
26
|
+
.toString("utf8")
|
|
27
|
+
.split(/\r?\n/, maxRecords)
|
|
28
|
+
.flatMap((line) => {
|
|
29
|
+
if (!line.trim())
|
|
30
|
+
return [];
|
|
31
|
+
try {
|
|
32
|
+
const record = JSON.parse(line);
|
|
33
|
+
return isSessionRecord(record) ? [record] : [];
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
});
|
|
28
39
|
}
|
|
29
40
|
finally {
|
|
30
41
|
closeSync(file);
|
|
31
42
|
}
|
|
32
43
|
}
|
|
33
|
-
function isSubagentMetadata(payload) {
|
|
34
|
-
if (stringValue(payload.parent_thread_id) || stringValue(payload.agent_path))
|
|
35
|
-
return true;
|
|
36
|
-
if (isRecord(payload.source) && "subagent" in payload.source)
|
|
37
|
-
return true;
|
|
38
|
-
return ["subagent", "agent_forked_thread", "guardian_review"].includes(stringValue(payload.thread_source) ?? "");
|
|
39
|
-
}
|
|
40
|
-
export function parseCodexMetadata(line) {
|
|
41
|
-
try {
|
|
42
|
-
const record = JSON.parse(line);
|
|
43
|
-
if (!isRecord(record) || record.type !== "session_meta" || !isRecord(record.payload))
|
|
44
|
-
return null;
|
|
45
|
-
const payload = record.payload;
|
|
46
|
-
const git = isRecord(payload.git) ? payload.git : {};
|
|
47
|
-
const externalSessionId = stringValue(payload.session_id) ?? stringValue(payload.id);
|
|
48
|
-
if (!externalSessionId)
|
|
49
|
-
return null;
|
|
50
|
-
const timestampValue = stringValue(payload.timestamp) ?? stringValue(record.timestamp);
|
|
51
|
-
const timestamp = timestampValue ? new Date(timestampValue) : null;
|
|
52
|
-
return {
|
|
53
|
-
externalSessionId,
|
|
54
|
-
timestamp: timestamp && !Number.isNaN(timestamp.getTime()) ? timestamp : null,
|
|
55
|
-
cwd: stringValue(payload.cwd),
|
|
56
|
-
repositoryUrl: stringValue(git.repository_url),
|
|
57
|
-
repositoryBranch: stringValue(git.branch),
|
|
58
|
-
repositoryCommitSha: stringValue(git.commit_hash),
|
|
59
|
-
originator: stringValue(payload.originator),
|
|
60
|
-
isSubagent: isSubagentMetadata(payload),
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
44
|
function walkJsonl(root) {
|
|
68
45
|
if (!existsSync(root))
|
|
69
46
|
return [];
|
|
@@ -102,12 +79,15 @@ export function normalizeRepositoryUrl(value) {
|
|
|
102
79
|
return null;
|
|
103
80
|
return parts.slice(-2).join("/").toLowerCase();
|
|
104
81
|
}
|
|
105
|
-
|
|
106
|
-
|
|
82
|
+
function repositoryRoot(path) {
|
|
83
|
+
return realpathSync(execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
|
|
107
84
|
encoding: "utf8",
|
|
108
85
|
stdio: ["ignore", "pipe", "ignore"],
|
|
109
86
|
timeout: 5_000,
|
|
110
87
|
}).trim());
|
|
88
|
+
}
|
|
89
|
+
export function repositoryIdentity(path) {
|
|
90
|
+
const root = repositoryRoot(path);
|
|
111
91
|
const names = execFileSync("git", ["-C", root, "remote"], {
|
|
112
92
|
encoding: "utf8",
|
|
113
93
|
stdio: ["ignore", "pipe", "ignore"],
|
|
@@ -136,25 +116,78 @@ function isInside(candidate, root) {
|
|
|
136
116
|
const child = relative(root, resolve(candidate));
|
|
137
117
|
return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
|
|
138
118
|
}
|
|
139
|
-
export function
|
|
119
|
+
export function discoverSessions(options) {
|
|
140
120
|
const nowMs = options.nowMs ?? Date.now();
|
|
141
121
|
const liveWindowMs = options.liveWindowMs ?? 5 * 60_000;
|
|
142
|
-
const roots = [
|
|
143
|
-
resolve(options.codexHome, "sessions"),
|
|
144
|
-
resolve(options.codexHome, "archived_sessions"),
|
|
145
|
-
];
|
|
146
122
|
const included = [];
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
123
|
+
const discoveryContext = {
|
|
124
|
+
sessionHome: options.home,
|
|
125
|
+
userHome: options.userHome ?? homedir(),
|
|
126
|
+
workingDirectory: options.workingDirectory ?? options.repository?.root ?? process.cwd(),
|
|
127
|
+
env: options.env ?? process.env,
|
|
128
|
+
platform: options.platform ?? process.platform,
|
|
129
|
+
};
|
|
130
|
+
const discovered = options.format.discovery === "custom"
|
|
131
|
+
? options.format.discoverCandidates(discoveryContext)
|
|
132
|
+
: (() => {
|
|
133
|
+
const candidates = [];
|
|
134
|
+
const rejected = [];
|
|
135
|
+
for (const path of options.format.sessionRoots(options.home).flatMap(walkJsonl)) {
|
|
136
|
+
const metadata = options.format.parseDiscoveryMetadata(initialRecords(path, options.format.discoveryRecordLimit));
|
|
137
|
+
if (metadata)
|
|
138
|
+
candidates.push({ path, metadata });
|
|
139
|
+
else
|
|
140
|
+
rejected.push({ path, reason: "invalid_metadata" });
|
|
141
|
+
}
|
|
142
|
+
return { included: candidates, skipped: rejected };
|
|
143
|
+
})();
|
|
144
|
+
const skipped = [...discovered.skipped];
|
|
145
|
+
const repositoryRootsByCwd = new Map();
|
|
146
|
+
const repositoryIdentitiesByRoot = new Map();
|
|
147
|
+
const repositoryRootForCwd = (cwd) => {
|
|
148
|
+
if (!cwd)
|
|
149
|
+
return null;
|
|
150
|
+
const cached = repositoryRootsByCwd.get(cwd);
|
|
151
|
+
if (cached !== undefined)
|
|
152
|
+
return cached;
|
|
153
|
+
let root = null;
|
|
154
|
+
try {
|
|
155
|
+
root = (options.resolveRepositoryRoot ?? repositoryRoot)(cwd);
|
|
153
156
|
}
|
|
154
|
-
|
|
155
|
-
|
|
157
|
+
catch { }
|
|
158
|
+
repositoryRootsByCwd.set(cwd, root);
|
|
159
|
+
return root;
|
|
160
|
+
};
|
|
161
|
+
const repositoryIdentityForCwd = (cwd) => {
|
|
162
|
+
const root = repositoryRootForCwd(cwd);
|
|
163
|
+
if (!root)
|
|
164
|
+
return null;
|
|
165
|
+
const cached = repositoryIdentitiesByRoot.get(root);
|
|
166
|
+
if (cached !== undefined)
|
|
167
|
+
return cached;
|
|
168
|
+
let identity = null;
|
|
169
|
+
try {
|
|
170
|
+
identity = (options.resolveRepositoryIdentity ?? repositoryIdentity)(root);
|
|
171
|
+
}
|
|
172
|
+
catch { }
|
|
173
|
+
repositoryIdentitiesByRoot.set(root, identity);
|
|
174
|
+
return identity;
|
|
175
|
+
};
|
|
176
|
+
const soleRemote = (identity) => {
|
|
177
|
+
if (!identity || identity.remotes.size !== 1)
|
|
178
|
+
return null;
|
|
179
|
+
return identity.remotes.values().next().value ?? null;
|
|
180
|
+
};
|
|
181
|
+
for (const candidate of discovered.included) {
|
|
182
|
+
const { path, metadata } = candidate;
|
|
183
|
+
const modifiedAtMs = candidate.modifiedAt?.getTime() ??
|
|
184
|
+
(() => {
|
|
185
|
+
const file = lstatSync(path);
|
|
186
|
+
return file.isFile() ? file.mtimeMs : Number.NaN;
|
|
187
|
+
})();
|
|
188
|
+
if (!Number.isFinite(modifiedAtMs))
|
|
156
189
|
continue;
|
|
157
|
-
if (nowMs -
|
|
190
|
+
if (nowMs - modifiedAtMs < liveWindowMs) {
|
|
158
191
|
skipped.push({ path, reason: "live" });
|
|
159
192
|
continue;
|
|
160
193
|
}
|
|
@@ -178,11 +211,31 @@ export function discoverCodexSessions(options) {
|
|
|
178
211
|
skipped.push({ path, reason: "other_repository" });
|
|
179
212
|
continue;
|
|
180
213
|
}
|
|
181
|
-
if (options.allRepositories &&
|
|
214
|
+
if (options.allRepositories &&
|
|
215
|
+
!options.includeUnscoped &&
|
|
216
|
+
!normalizedRemote &&
|
|
217
|
+
!repositoryRootForCwd(metadata.cwd)) {
|
|
182
218
|
skipped.push({ path, reason: "unscoped" });
|
|
183
219
|
continue;
|
|
184
220
|
}
|
|
185
|
-
|
|
221
|
+
const inferredRemote = normalizedRemote
|
|
222
|
+
? null
|
|
223
|
+
: options.repository && matchesCurrentRepository
|
|
224
|
+
? soleRemote(options.repository)
|
|
225
|
+
: options.allRepositories
|
|
226
|
+
? soleRemote(repositoryIdentityForCwd(metadata.cwd))
|
|
227
|
+
: null;
|
|
228
|
+
included.push(inferredRemote
|
|
229
|
+
? { ...candidate, metadata: { ...metadata, repositoryUrl: inferredRemote } }
|
|
230
|
+
: candidate);
|
|
186
231
|
}
|
|
187
|
-
|
|
232
|
+
if (!options.format.materializeCandidates)
|
|
233
|
+
return { included, skipped, failed: [] };
|
|
234
|
+
const materialized = options.format.materializeCandidates(included, discoveryContext);
|
|
235
|
+
return {
|
|
236
|
+
included: [...materialized.included],
|
|
237
|
+
skipped,
|
|
238
|
+
failed: [...materialized.failed],
|
|
239
|
+
cleanup: materialized.cleanup,
|
|
240
|
+
};
|
|
188
241
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { CanonicalSessionEntry, CanonicalSessionMetadata, SessionRecord } from "./canonical.js";
|
|
2
|
+
import type { AgentSessionCandidate, AgentSessionMetadata, AgentSessionSource, FailedAgentSession, ImportAgent, SkippedAgentSession } from "./types.js";
|
|
3
|
+
export type SessionRecordNormalization = Readonly<{
|
|
4
|
+
entries: readonly CanonicalSessionEntry[];
|
|
5
|
+
sensitive: boolean;
|
|
6
|
+
unsupported: boolean;
|
|
7
|
+
overflowed?: boolean;
|
|
8
|
+
}>;
|
|
9
|
+
export type SessionNormalizer = Readonly<{
|
|
10
|
+
normalizeRecord(record: SessionRecord, entryBudget?: number): SessionRecordNormalization;
|
|
11
|
+
canonicalMetadata(): Omit<CanonicalSessionMetadata, "startedAt" | "endedAt" | "statistics"> | null;
|
|
12
|
+
}>;
|
|
13
|
+
export type SessionDiscoveryContext = Readonly<{
|
|
14
|
+
sessionHome: string;
|
|
15
|
+
userHome: string;
|
|
16
|
+
workingDirectory: string;
|
|
17
|
+
env: NodeJS.ProcessEnv;
|
|
18
|
+
platform: NodeJS.Platform;
|
|
19
|
+
}>;
|
|
20
|
+
export type SessionDiscoveryCandidates = Readonly<{
|
|
21
|
+
included: readonly AgentSessionCandidate[];
|
|
22
|
+
skipped: readonly SkippedAgentSession[];
|
|
23
|
+
}>;
|
|
24
|
+
export type MaterializedSessionCandidates = Readonly<{
|
|
25
|
+
included: readonly AgentSessionCandidate[];
|
|
26
|
+
failed: readonly FailedAgentSession[];
|
|
27
|
+
cleanup(): void;
|
|
28
|
+
}>;
|
|
29
|
+
type SessionImportFormatBase = Readonly<{
|
|
30
|
+
agent: ImportAgent;
|
|
31
|
+
source: AgentSessionSource;
|
|
32
|
+
displayName: string;
|
|
33
|
+
homeEnvironmentVariable: string;
|
|
34
|
+
defaultHomeDirectory: string;
|
|
35
|
+
materializeCandidates?(candidates: readonly AgentSessionCandidate[], context: SessionDiscoveryContext): MaterializedSessionCandidates;
|
|
36
|
+
createNormalizer(metadata?: AgentSessionMetadata): SessionNormalizer;
|
|
37
|
+
sourceHashSeed?(metadata: AgentSessionMetadata): string;
|
|
38
|
+
}>;
|
|
39
|
+
type FileSessionDiscovery = Readonly<{
|
|
40
|
+
discovery: "files";
|
|
41
|
+
discoveryRecordLimit: number;
|
|
42
|
+
sessionRoots(home: string): readonly string[];
|
|
43
|
+
parseDiscoveryMetadata(records: readonly SessionRecord[]): AgentSessionMetadata | null;
|
|
44
|
+
}>;
|
|
45
|
+
type CustomSessionDiscovery = Readonly<{
|
|
46
|
+
discovery: "custom";
|
|
47
|
+
discoverCandidates(context: SessionDiscoveryContext): SessionDiscoveryCandidates;
|
|
48
|
+
}>;
|
|
49
|
+
export type SessionImportFormat = SessionImportFormatBase & (FileSessionDiscovery | CustomSessionDiscovery);
|
|
50
|
+
export declare function sessionImportFormatIdentity(agent: ImportAgent): Pick<SessionImportFormat, "agent" | "source" | "displayName">;
|
|
51
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SESSION_IMPORT_AGENTS } from "../agents.js";
|
|
2
|
+
export function sessionImportFormatIdentity(agent) {
|
|
3
|
+
const definition = SESSION_IMPORT_AGENTS.find((candidate) => candidate.id === agent);
|
|
4
|
+
if (!definition)
|
|
5
|
+
throw new Error(`Unsupported session import agent: ${agent}`);
|
|
6
|
+
return {
|
|
7
|
+
agent: definition.id,
|
|
8
|
+
source: definition.source,
|
|
9
|
+
displayName: definition.name,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { SessionImportFormat } from "./format.js";
|
|
2
|
+
import type { ImportAgent } from "./types.js";
|
|
3
|
+
export declare const sessionImportFormats: Readonly<Record<ImportAgent, SessionImportFormat>>;
|
|
4
|
+
export declare function sessionImportFormat(agent: ImportAgent): SessionImportFormat;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { claudeSessionFormat } from "./claude.js";
|
|
2
|
+
import { codexSessionFormat } from "./codex.js";
|
|
3
|
+
import { cursorSessionFormat } from "./cursor.js";
|
|
4
|
+
import { opencodeSessionFormat } from "./opencode-cli.js";
|
|
5
|
+
export const sessionImportFormats = {
|
|
6
|
+
claude: claudeSessionFormat,
|
|
7
|
+
codex: codexSessionFormat,
|
|
8
|
+
cursor: cursorSessionFormat,
|
|
9
|
+
opencode: opencodeSessionFormat,
|
|
10
|
+
};
|
|
11
|
+
export function sessionImportFormat(agent) {
|
|
12
|
+
return sessionImportFormats[agent];
|
|
13
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type CanonicalSessionEntry, type CanonicalSessionMetadata } from "./canonical.js";
|
|
2
|
+
import type { SessionNormalizer } from "./format.js";
|
|
3
|
+
import type { AgentSessionMetadata } from "./types.js";
|
|
4
|
+
export type SessionNormalizationErrorCode = "too_many_entries" | "missing_session_metadata" | "empty_trace";
|
|
5
|
+
export declare class SessionNormalizationError extends Error {
|
|
6
|
+
readonly code: SessionNormalizationErrorCode;
|
|
7
|
+
constructor(code: SessionNormalizationErrorCode, message: string);
|
|
8
|
+
}
|
|
9
|
+
export type NormalizedSession = Readonly<{
|
|
10
|
+
metadata: CanonicalSessionMetadata;
|
|
11
|
+
entries: readonly CanonicalSessionEntry[];
|
|
12
|
+
}>;
|
|
13
|
+
export declare function createSessionNormalizationAccumulator(normalizer: SessionNormalizer, options?: Readonly<{
|
|
14
|
+
discoveryMetadata?: AgentSessionMetadata;
|
|
15
|
+
collectEntries?: boolean;
|
|
16
|
+
onEntry?: (entry: CanonicalSessionEntry) => void;
|
|
17
|
+
}>): {
|
|
18
|
+
add(record: unknown): void;
|
|
19
|
+
finish(): NormalizedSession;
|
|
20
|
+
};
|
|
21
|
+
export declare function normalizeSessionRecords(records: readonly unknown[], normalizer: SessionNormalizer, discoveryMetadata?: AgentSessionMetadata): NormalizedSession;
|