@groundtrack/hooks 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +28 -10
  2. package/dist/agents.d.ts +55 -0
  3. package/dist/agents.js +77 -3
  4. package/dist/groundtrack-hook.js +0 -0
  5. package/dist/groundtrack.js +0 -0
  6. package/dist/session-import/canonical.d.ts +33 -0
  7. package/dist/session-import/canonical.js +10 -0
  8. package/dist/session-import/claude.d.ts +20 -0
  9. package/dist/session-import/claude.js +237 -0
  10. package/dist/session-import/cli.d.ts +3 -2
  11. package/dist/session-import/cli.js +161 -131
  12. package/dist/session-import/client.d.ts +4 -3
  13. package/dist/session-import/client.js +4 -3
  14. package/dist/session-import/codex.d.ts +6 -33
  15. package/dist/session-import/codex.js +62 -9
  16. package/dist/session-import/cursor.d.ts +13 -0
  17. package/dist/session-import/cursor.js +268 -0
  18. package/dist/session-import/discovery.d.ts +16 -7
  19. package/dist/session-import/discovery.js +127 -70
  20. package/dist/session-import/format.d.ts +51 -0
  21. package/dist/session-import/format.js +11 -0
  22. package/dist/session-import/formats.d.ts +4 -0
  23. package/dist/session-import/formats.js +13 -0
  24. package/dist/session-import/normalize.d.ts +21 -0
  25. package/dist/session-import/normalize.js +100 -0
  26. package/dist/session-import/opencode-cli.d.ts +14 -0
  27. package/dist/session-import/opencode-cli.js +178 -0
  28. package/dist/session-import/opencode-normalizer.d.ts +8 -0
  29. package/dist/session-import/opencode-normalizer.js +230 -0
  30. package/dist/session-import/opencode.d.ts +8 -0
  31. package/dist/session-import/opencode.js +230 -0
  32. package/dist/session-import/prune.d.ts +13 -4
  33. package/dist/session-import/prune.js +45 -79
  34. package/dist/session-import/receipts.d.ts +2 -1
  35. package/dist/session-import/receipts.js +3 -3
  36. package/dist/session-import/redaction.js +5 -3
  37. package/dist/session-import/types.d.ts +21 -7
  38. package/dist/setup/agents.d.ts +32 -0
  39. package/package.json +29 -1
  40. 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 { CodexSessionCandidate, CodexSessionMetadata, SkippedCodexSession } from "./types.js";
2
- export declare function parseCodexMetadata(line: string): CodexSessionMetadata | null;
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 DiscoverCodexSessionsOptions = Readonly<{
10
- codexHome: string;
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 discoverCodexSessions(options: DiscoverCodexSessionsOptions): {
20
- included: CodexSessionCandidate[];
21
- skipped: SkippedCodexSession[];
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
- function isRecord(value) {
5
- return value !== null && typeof value === "object" && !Array.isArray(value);
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
- while (offset < maxBytes) {
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
- const newline = bytes.indexOf(10);
22
- chunks.push(newline >= 0 ? bytes.subarray(0, newline) : bytes);
23
- offset += newline >= 0 ? newline : read;
24
- if (newline >= 0)
25
- break;
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).toString("utf8").replace(/\r$/, "");
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
- export function repositoryIdentity(path) {
106
- const root = realpathSync(execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
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 discoverCodexSessions(options) {
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 skipped = [];
148
- for (const path of roots.flatMap(walkJsonl)) {
149
- const metadata = parseCodexMetadata(firstLine(path));
150
- if (!metadata) {
151
- skipped.push({ path, reason: "invalid_metadata" });
152
- continue;
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
- const stat = lstatSync(path);
155
- if (!stat.isFile())
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 - stat.mtimeMs < liveWindowMs) {
190
+ if (nowMs - modifiedAtMs < liveWindowMs) {
158
191
  skipped.push({ path, reason: "live" });
159
192
  continue;
160
193
  }
@@ -169,20 +202,44 @@ export function discoverCodexSessions(options) {
169
202
  const normalizedRemote = metadata.repositoryUrl
170
203
  ? normalizeRepositoryUrl(metadata.repositoryUrl)
171
204
  : null;
205
+ const cwdMatchesCurrentRepository = Boolean(options.repository && metadata.cwd && isInside(metadata.cwd, options.repository.root));
206
+ const currentRepositoryRemote = cwdMatchesCurrentRepository
207
+ ? soleRemote(options.repository)
208
+ : null;
209
+ const remoteMatchesCurrentRepository = Boolean(options.repository && normalizedRemote && options.repository.remotes.has(normalizedRemote));
172
210
  const matchesCurrentRepository = options.repository
173
- ? normalizedRemote
174
- ? options.repository.remotes.has(normalizedRemote)
175
- : Boolean(metadata.cwd && isInside(metadata.cwd, options.repository.root))
211
+ ? remoteMatchesCurrentRepository ||
212
+ (!normalizedRemote && cwdMatchesCurrentRepository) ||
213
+ Boolean(currentRepositoryRemote && cwdMatchesCurrentRepository)
176
214
  : false;
177
215
  if (!options.allRepositories && !matchesCurrentRepository) {
178
216
  skipped.push({ path, reason: "other_repository" });
179
217
  continue;
180
218
  }
181
- if (options.allRepositories && !normalizedRemote && !options.includeUnscoped) {
219
+ if (options.allRepositories &&
220
+ !options.includeUnscoped &&
221
+ !normalizedRemote &&
222
+ !repositoryRootForCwd(metadata.cwd)) {
182
223
  skipped.push({ path, reason: "unscoped" });
183
224
  continue;
184
225
  }
185
- included.push({ path, metadata });
226
+ const attributedRemote = remoteMatchesCurrentRepository
227
+ ? normalizedRemote
228
+ : (currentRepositoryRemote ??
229
+ normalizedRemote ??
230
+ (options.allRepositories ? soleRemote(repositoryIdentityForCwd(metadata.cwd)) : null));
231
+ included.push({
232
+ ...candidate,
233
+ metadata: { ...metadata, repositoryUrl: attributedRemote },
234
+ });
186
235
  }
187
- return { included, skipped };
236
+ if (!options.format.materializeCandidates)
237
+ return { included, skipped, failed: [] };
238
+ const materialized = options.format.materializeCandidates(included, discoveryContext);
239
+ return {
240
+ included: [...materialized.included],
241
+ skipped,
242
+ failed: [...materialized.failed],
243
+ cleanup: materialized.cleanup,
244
+ };
188
245
  }
@@ -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;