@groundtrack/hooks 0.1.8 → 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 +42 -4
- package/dist/agents.d.ts +55 -0
- package/dist/agents.js +77 -3
- package/dist/groundtrack.js +5 -2
- 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 +22 -0
- package/dist/session-import/cli.js +365 -0
- package/dist/session-import/client.d.ts +14 -0
- package/dist/session-import/client.js +74 -0
- package/dist/session-import/codex.d.ts +10 -0
- package/dist/session-import/codex.js +187 -0
- package/dist/session-import/cursor.d.ts +13 -0
- package/dist/session-import/cursor.js +268 -0
- package/dist/session-import/discovery.d.ts +31 -0
- package/dist/session-import/discovery.js +241 -0
- 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 +14 -0
- package/dist/session-import/prune.js +86 -0
- package/dist/session-import/receipts.d.ts +21 -0
- package/dist/session-import/receipts.js +44 -0
- package/dist/session-import/redaction.d.ts +6 -0
- package/dist/session-import/redaction.js +61 -0
- package/dist/session-import/types.d.ts +42 -0
- package/dist/session-import/types.js +1 -0
- package/dist/setup/agents.d.ts +32 -0
- package/dist/setup/cli.js +1 -0
- package/package.json +43 -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
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SessionImportFormat } from "./format.js";
|
|
2
|
+
import type { AgentSessionCandidate, FailedAgentSession, SkippedAgentSession } from "./types.js";
|
|
3
|
+
export declare function normalizeRepositoryUrl(value: string): string | null;
|
|
4
|
+
export type RepositoryIdentity = Readonly<{
|
|
5
|
+
root: string;
|
|
6
|
+
remotes: ReadonlySet<string>;
|
|
7
|
+
}>;
|
|
8
|
+
export declare function repositoryIdentity(path: string): RepositoryIdentity;
|
|
9
|
+
export type DiscoverSessionsOptions = Readonly<{
|
|
10
|
+
home: string;
|
|
11
|
+
format: SessionImportFormat;
|
|
12
|
+
repository?: RepositoryIdentity;
|
|
13
|
+
allRepositories: boolean;
|
|
14
|
+
includeUnscoped: boolean;
|
|
15
|
+
includeSubagents: boolean;
|
|
16
|
+
since: Date | null;
|
|
17
|
+
nowMs?: number;
|
|
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;
|
|
25
|
+
}>;
|
|
26
|
+
export declare function discoverSessions(options: DiscoverSessionsOptions): {
|
|
27
|
+
included: AgentSessionCandidate[];
|
|
28
|
+
skipped: SkippedAgentSession[];
|
|
29
|
+
failed: FailedAgentSession[];
|
|
30
|
+
cleanup?: () => void;
|
|
31
|
+
};
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, realpathSync, } from "node:fs";
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { isSessionRecord } from "./canonical.js";
|
|
6
|
+
function initialRecords(path, maxRecords, maxBytes = 1024 * 1024) {
|
|
7
|
+
const file = openSync(path, "r");
|
|
8
|
+
try {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let offset = 0;
|
|
11
|
+
let newlineCount = 0;
|
|
12
|
+
while (offset < maxBytes && newlineCount < maxRecords) {
|
|
13
|
+
const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes - offset));
|
|
14
|
+
const read = readSync(file, chunk, 0, chunk.length, offset);
|
|
15
|
+
if (read === 0)
|
|
16
|
+
break;
|
|
17
|
+
const bytes = chunk.subarray(0, read);
|
|
18
|
+
chunks.push(bytes);
|
|
19
|
+
offset += read;
|
|
20
|
+
for (const byte of bytes) {
|
|
21
|
+
if (byte === 10 && ++newlineCount >= maxRecords)
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
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
|
+
});
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
closeSync(file);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function walkJsonl(root) {
|
|
45
|
+
if (!existsSync(root))
|
|
46
|
+
return [];
|
|
47
|
+
const result = [];
|
|
48
|
+
const visit = (directory) => {
|
|
49
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
50
|
+
const path = resolve(directory, entry.name);
|
|
51
|
+
if (entry.isDirectory())
|
|
52
|
+
visit(path);
|
|
53
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".jsonl"))
|
|
54
|
+
result.push(path);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
visit(root);
|
|
58
|
+
return result.sort();
|
|
59
|
+
}
|
|
60
|
+
export function normalizeRepositoryUrl(value) {
|
|
61
|
+
const trimmed = value
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/\.git\/?$/i, "")
|
|
64
|
+
.replace(/\/$/, "");
|
|
65
|
+
const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
|
|
66
|
+
const path = scp?.[2] ??
|
|
67
|
+
(() => {
|
|
68
|
+
try {
|
|
69
|
+
return new URL(trimmed).pathname;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return trimmed.includes("/") ? trimmed : null;
|
|
73
|
+
}
|
|
74
|
+
})();
|
|
75
|
+
if (!path)
|
|
76
|
+
return null;
|
|
77
|
+
const parts = path.split("/").filter(Boolean);
|
|
78
|
+
if (parts.length < 2)
|
|
79
|
+
return null;
|
|
80
|
+
return parts.slice(-2).join("/").toLowerCase();
|
|
81
|
+
}
|
|
82
|
+
function repositoryRoot(path) {
|
|
83
|
+
return realpathSync(execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
|
|
84
|
+
encoding: "utf8",
|
|
85
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
86
|
+
timeout: 5_000,
|
|
87
|
+
}).trim());
|
|
88
|
+
}
|
|
89
|
+
export function repositoryIdentity(path) {
|
|
90
|
+
const root = repositoryRoot(path);
|
|
91
|
+
const names = execFileSync("git", ["-C", root, "remote"], {
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
94
|
+
timeout: 5_000,
|
|
95
|
+
})
|
|
96
|
+
.split(/\r?\n/)
|
|
97
|
+
.filter(Boolean);
|
|
98
|
+
const remotes = new Set();
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
const urls = execFileSync("git", ["-C", root, "remote", "get-url", "--all", name], {
|
|
101
|
+
encoding: "utf8",
|
|
102
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
103
|
+
timeout: 5_000,
|
|
104
|
+
}).split(/\r?\n/);
|
|
105
|
+
for (const url of urls) {
|
|
106
|
+
const normalized = normalizeRepositoryUrl(url);
|
|
107
|
+
if (normalized)
|
|
108
|
+
remotes.add(normalized);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return { root, remotes };
|
|
112
|
+
}
|
|
113
|
+
function isInside(candidate, root) {
|
|
114
|
+
if (!isAbsolute(candidate))
|
|
115
|
+
return false;
|
|
116
|
+
const child = relative(root, resolve(candidate));
|
|
117
|
+
return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
|
|
118
|
+
}
|
|
119
|
+
export function discoverSessions(options) {
|
|
120
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
121
|
+
const liveWindowMs = options.liveWindowMs ?? 5 * 60_000;
|
|
122
|
+
const included = [];
|
|
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);
|
|
156
|
+
}
|
|
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))
|
|
189
|
+
continue;
|
|
190
|
+
if (nowMs - modifiedAtMs < liveWindowMs) {
|
|
191
|
+
skipped.push({ path, reason: "live" });
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (!options.includeSubagents && metadata.isSubagent) {
|
|
195
|
+
skipped.push({ path, reason: "subagent" });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (options.since && (!metadata.timestamp || metadata.timestamp < options.since)) {
|
|
199
|
+
skipped.push({ path, reason: "before_since" });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const normalizedRemote = metadata.repositoryUrl
|
|
203
|
+
? normalizeRepositoryUrl(metadata.repositoryUrl)
|
|
204
|
+
: null;
|
|
205
|
+
const matchesCurrentRepository = options.repository
|
|
206
|
+
? normalizedRemote
|
|
207
|
+
? options.repository.remotes.has(normalizedRemote)
|
|
208
|
+
: Boolean(metadata.cwd && isInside(metadata.cwd, options.repository.root))
|
|
209
|
+
: false;
|
|
210
|
+
if (!options.allRepositories && !matchesCurrentRepository) {
|
|
211
|
+
skipped.push({ path, reason: "other_repository" });
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (options.allRepositories &&
|
|
215
|
+
!options.includeUnscoped &&
|
|
216
|
+
!normalizedRemote &&
|
|
217
|
+
!repositoryRootForCwd(metadata.cwd)) {
|
|
218
|
+
skipped.push({ path, reason: "unscoped" });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
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);
|
|
231
|
+
}
|
|
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
|
+
};
|
|
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;
|