@groundtrack/hooks 0.1.8 → 0.1.9

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 CHANGED
@@ -87,6 +87,26 @@ The lower-level `runDoctor` API remains for existing consumers. New setup uses
87
87
  client-specific plans for MCP, hooks/plugins, and instructions, so it can preview
88
88
  and repair several selected agents safely.
89
89
 
90
+ ## Import Codex sessions
91
+
92
+ Import completed Codex sessions from `CODEX_HOME` (default `~/.codex`) with an API key
93
+ created in Groundtrack Settings. The default scope is the current Git repository;
94
+ subagents and files modified in the last five minutes are excluded.
95
+
96
+ ```sh
97
+ GROUNDTRACK_API_KEY=... groundtrack import --dry-run
98
+ GROUNDTRACK_API_KEY=... groundtrack import --yes
99
+ GROUNDTRACK_API_KEY=... groundtrack import --all-repos --include-unscoped --yes
100
+ ```
101
+
102
+ The command streams source files through a deterministic privacy filter before upload.
103
+ Reasoning, system/developer instructions, turn context other than the model name, and
104
+ recognized credentials are removed locally. Uploads are sequential and retry only
105
+ transient failures. Successful imports are recorded atomically under
106
+ `$XDG_CONFIG_HOME/groundtrack`; receipts are scoped to the endpoint, API-key fingerprint,
107
+ visibility, source hash, and canonicalization version. Use `--json` for one machine-readable
108
+ result object and `--url` for a preview or self-hosted deployment.
109
+
90
110
  `templates/AGENTS.md` is the source of truth for the managed repository policy.
91
111
  After changing it, run `pnpm sync:agent-instructions`; tests reject drift between
92
112
  the setup guide, this repository's managed `AGENTS.md` block, and the packaged template.
File without changes
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { runCli } from "./setup/cli.js";
3
- runCli(process.argv.slice(2)).catch((error) => {
4
- const message = error instanceof Error ? error.message : "Groundtrack setup failed.";
3
+ import { runImportCli } from "./session-import/cli.js";
4
+ const argv = process.argv.slice(2);
5
+ const operation = argv[0] === "import" ? runImportCli(argv.slice(1)) : runCli(argv);
6
+ operation.catch((error) => {
7
+ const message = error instanceof Error ? error.message : "Groundtrack command failed.";
5
8
  if (process.argv.includes("--json"))
6
9
  process.stdout.write(`${JSON.stringify({ error: message, applied: false })}\n`);
7
10
  else
@@ -0,0 +1,21 @@
1
+ import type { ImportVisibility } from "./types.js";
2
+ export declare const IMPORT_USAGE = "Usage:\n groundtrack import [--agent codex] [--repo PATH | --all-repos] [--since DATE]\n [--include-subagents] [--include-unscoped]\n [--visibility private|organization] [--dry-run] [--yes] [--json]\n\nImport options:\n --agent codex Session format to discover (default: codex)\n --repo PATH Import sessions for this Git repository (default: current repository)\n --all-repos Import sessions carrying repository metadata\n --include-unscoped With --all-repos, also import sessions without repository metadata\n --since DATE Import sessions at or after an ISO timestamp or local YYYY-MM-DD\n --include-subagents Include subagent, forked-agent, and guardian sessions\n --visibility VALUE private (default) or organization\n --api-key KEY API key; prefer the GROUNDTRACK_API_KEY environment variable\n --url URL Groundtrack app or API URL (default: https://app.groundtrack.dev)\n --dry-run Discover and prune without authentication or network calls\n --yes, -y Upload without another confirmation\n --json Emit exactly one machine-readable result object\n";
3
+ type ImportArguments = Readonly<{
4
+ repoDir?: string;
5
+ homeDir?: string;
6
+ allRepositories: boolean;
7
+ includeUnscoped: boolean;
8
+ includeSubagents: boolean;
9
+ visibility: ImportVisibility;
10
+ since: Date | null;
11
+ dryRun: boolean;
12
+ yes: boolean;
13
+ json: boolean;
14
+ apiKey?: string;
15
+ apiBaseUrl: string;
16
+ help: boolean;
17
+ }>;
18
+ export declare function normalizeApiBaseUrl(value: string): string;
19
+ export declare function parseImportArguments(argv: readonly string[]): ImportArguments;
20
+ export declare function runImportCli(argv: readonly string[]): Promise<void>;
21
+ export {};
@@ -0,0 +1,335 @@
1
+ import { resolve } from "node:path";
2
+ import * as prompts from "@clack/prompts";
3
+ import { environment } from "../setup/agents.js";
4
+ import { uploadCodexSession } from "./client.js";
5
+ import { discoverCodexSessions, normalizeRepositoryUrl, repositoryIdentity } from "./discovery.js";
6
+ import { pruneCodexSession } from "./prune.js";
7
+ import { readReceipts, receiptKey, saveReceipt } from "./receipts.js";
8
+ import { CODEX_CANONICAL_IMPORT_MAX_BYTES } from "./codex.js";
9
+ export const IMPORT_USAGE = `Usage:
10
+ groundtrack import [--agent codex] [--repo PATH | --all-repos] [--since DATE]
11
+ [--include-subagents] [--include-unscoped]
12
+ [--visibility private|organization] [--dry-run] [--yes] [--json]
13
+
14
+ Import options:
15
+ --agent codex Session format to discover (default: codex)
16
+ --repo PATH Import sessions for this Git repository (default: current repository)
17
+ --all-repos Import sessions carrying repository metadata
18
+ --include-unscoped With --all-repos, also import sessions without repository metadata
19
+ --since DATE Import sessions at or after an ISO timestamp or local YYYY-MM-DD
20
+ --include-subagents Include subagent, forked-agent, and guardian sessions
21
+ --visibility VALUE private (default) or organization
22
+ --api-key KEY API key; prefer the GROUNDTRACK_API_KEY environment variable
23
+ --url URL Groundtrack app or API URL (default: https://app.groundtrack.dev)
24
+ --dry-run Discover and prune without authentication or network calls
25
+ --yes, -y Upload without another confirmation
26
+ --json Emit exactly one machine-readable result object
27
+ `;
28
+ function parseDate(value) {
29
+ const date = /^\d{4}-\d{2}-\d{2}$/.test(value) ? new Date(`${value}T00:00:00`) : new Date(value);
30
+ if (Number.isNaN(date.getTime()))
31
+ throw new Error(`Invalid --since date: ${value}`);
32
+ return date;
33
+ }
34
+ export function normalizeApiBaseUrl(value) {
35
+ const url = new URL(value);
36
+ if ((url.protocol !== "https:" &&
37
+ !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) ||
38
+ url.username ||
39
+ url.password ||
40
+ url.search ||
41
+ url.hash)
42
+ throw new Error("Use an HTTPS Groundtrack URL (or local HTTP), without credentials or query parameters.");
43
+ url.pathname = url.pathname.replace(/\/(?:api(?:\/mcp)?)\/?$/, "");
44
+ return `${url.href.replace(/\/$/, "")}/api`;
45
+ }
46
+ export function parseImportArguments(argv) {
47
+ const args = [...argv];
48
+ const valueAfter = (index, flag) => {
49
+ const value = args[index + 1];
50
+ if (!value || value.startsWith("-"))
51
+ throw new Error(`${flag} requires a value.`);
52
+ return value;
53
+ };
54
+ let repoDir;
55
+ let homeDir;
56
+ let allRepositories = false;
57
+ let includeUnscoped = false;
58
+ let includeSubagents = false;
59
+ let visibility = "private";
60
+ let since = null;
61
+ let dryRun = false;
62
+ let yes = false;
63
+ let json = false;
64
+ let apiKey;
65
+ let apiBaseUrl = normalizeApiBaseUrl("https://app.groundtrack.dev");
66
+ let help = false;
67
+ for (let index = 0; index < args.length; index += 1) {
68
+ const arg = args[index];
69
+ if (arg === "--agent") {
70
+ const agent = valueAfter(index++, arg);
71
+ if (agent !== "codex")
72
+ throw new Error("Session import currently supports --agent codex.");
73
+ }
74
+ else if (arg === "--repo")
75
+ repoDir = valueAfter(index++, arg);
76
+ else if (arg === "--home")
77
+ homeDir = valueAfter(index++, arg);
78
+ else if (arg === "--all-repos")
79
+ allRepositories = true;
80
+ else if (arg === "--include-unscoped")
81
+ includeUnscoped = true;
82
+ else if (arg === "--include-subagents")
83
+ includeSubagents = true;
84
+ else if (arg === "--visibility") {
85
+ const value = valueAfter(index++, arg);
86
+ if (value !== "private" && value !== "organization")
87
+ throw new Error("--visibility must be private or organization.");
88
+ visibility = value;
89
+ }
90
+ else if (arg === "--since")
91
+ since = parseDate(valueAfter(index++, arg));
92
+ else if (arg === "--api-key")
93
+ apiKey = valueAfter(index++, arg);
94
+ else if (arg === "--url")
95
+ apiBaseUrl = normalizeApiBaseUrl(valueAfter(index++, arg));
96
+ else if (arg === "--dry-run")
97
+ dryRun = true;
98
+ else if (arg === "--yes" || arg === "-y")
99
+ yes = true;
100
+ else if (arg === "--json")
101
+ json = true;
102
+ else if (arg === "--help" || arg === "-h")
103
+ help = true;
104
+ else
105
+ throw new Error(`Unknown import option: ${arg}. Run groundtrack import --help.`);
106
+ }
107
+ if (allRepositories && repoDir)
108
+ throw new Error("Choose --repo or --all-repos, not both.");
109
+ if (includeUnscoped && !allRepositories)
110
+ throw new Error("Use --include-unscoped together with --all-repos.");
111
+ return {
112
+ repoDir,
113
+ homeDir,
114
+ allRepositories,
115
+ includeUnscoped,
116
+ includeSubagents,
117
+ visibility,
118
+ since,
119
+ dryRun,
120
+ yes,
121
+ json,
122
+ apiKey,
123
+ apiBaseUrl,
124
+ help,
125
+ };
126
+ }
127
+ function formatBytes(bytes) {
128
+ if (bytes < 1024)
129
+ return `${bytes} B`;
130
+ if (bytes < 1024 ** 2)
131
+ return `${(bytes / 1024).toFixed(1)} KB`;
132
+ if (bytes < 1024 ** 3)
133
+ return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
134
+ return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
135
+ }
136
+ function skippedCounts(skipped) {
137
+ const result = {};
138
+ for (const item of skipped)
139
+ result[item.reason] = (result[item.reason] ?? 0) + 1;
140
+ return result;
141
+ }
142
+ function printPlan(input) {
143
+ const originalBytes = input.sessions.reduce((sum, session) => sum + session.originalBytes, 0);
144
+ const prunedBytes = input.sessions.reduce((sum, session) => sum + session.prunedBytes, 0);
145
+ const skippedByReason = skippedCounts(input.skipped);
146
+ const repositories = sessionRepositoryCounts(input.sessions);
147
+ const timestamps = input.sessions
148
+ .map((item) => item.candidate.metadata.timestamp?.getTime())
149
+ .filter((value) => value !== undefined);
150
+ process.stdout.write([
151
+ `\nGroundtrack · import Codex sessions`,
152
+ `Scope: ${input.scope}`,
153
+ `Sessions: ${input.sessions.length} (${input.sessions.filter((item) => item.alreadyImported).length} in local receipts)`,
154
+ ...(timestamps.length
155
+ ? [
156
+ `Date range: ${new Date(Math.min(...timestamps)).toISOString()} to ${new Date(Math.max(...timestamps)).toISOString()}`,
157
+ ]
158
+ : []),
159
+ ...Object.entries(repositories).map(([repository, count]) => ` ${repository}: ${count}`),
160
+ `Upload size: ${formatBytes(prunedBytes)} after pruning (${formatBytes(originalBytes)} raw)`,
161
+ `Skipped: ${input.skipped.length}${Object.entries(skippedByReason)
162
+ .map(([reason, count]) => ` · ${reason} ${count}`)
163
+ .join("")}`,
164
+ ...(input.failed ? [`Unreadable/oversized: ${input.failed}`] : []),
165
+ "",
166
+ ].join("\n"));
167
+ }
168
+ function sessionRepositoryCounts(sessions) {
169
+ const result = {};
170
+ for (const session of sessions) {
171
+ const repository = session.candidate.metadata.repositoryUrl
172
+ ? normalizeRepositoryUrl(session.candidate.metadata.repositoryUrl)
173
+ : null;
174
+ const label = repository ?? session.candidate.metadata.cwd ?? "(no repository)";
175
+ result[label] = (result[label] ?? 0) + 1;
176
+ }
177
+ return result;
178
+ }
179
+ function sessionDateRange(sessions) {
180
+ const timestamps = sessions
181
+ .map((item) => item.candidate.metadata.timestamp?.getTime())
182
+ .filter((value) => value !== undefined);
183
+ return timestamps.length
184
+ ? {
185
+ from: new Date(Math.min(...timestamps)).toISOString(),
186
+ to: new Date(Math.max(...timestamps)).toISOString(),
187
+ }
188
+ : null;
189
+ }
190
+ export async function runImportCli(argv) {
191
+ const args = parseImportArguments(argv);
192
+ if (args.help) {
193
+ process.stdout.write(IMPORT_USAGE);
194
+ return;
195
+ }
196
+ const context = environment({ repoDir: args.repoDir, homeDir: args.homeDir });
197
+ const codexHome = resolve(context.env.CODEX_HOME || resolve(context.homeDir, ".codex"));
198
+ const repository = args.allRepositories ? undefined : repositoryIdentity(context.repoDir);
199
+ const discovery = discoverCodexSessions({
200
+ codexHome,
201
+ repository,
202
+ allRepositories: args.allRepositories,
203
+ includeUnscoped: args.includeUnscoped,
204
+ includeSubagents: args.includeSubagents,
205
+ since: args.since,
206
+ });
207
+ const apiKey = args.apiKey ?? context.env.GROUNDTRACK_API_KEY;
208
+ const receipts = apiKey ? readReceipts(context) : { version: 1, receipts: {} };
209
+ const prepared = [];
210
+ const preparationErrors = [];
211
+ for (const candidate of discovery.included) {
212
+ try {
213
+ const pruned = await pruneCodexSession(candidate.path, { emitBytes: false });
214
+ if (pruned.prunedBytes > CODEX_CANONICAL_IMPORT_MAX_BYTES)
215
+ throw new Error(`Pruned session exceeds ${formatBytes(CODEX_CANONICAL_IMPORT_MAX_BYTES)}`);
216
+ const key = apiKey
217
+ ? receiptKey({
218
+ apiBaseUrl: args.apiBaseUrl,
219
+ apiKey,
220
+ sourceContentHash: pruned.sourceContentHash,
221
+ visibility: args.visibility,
222
+ })
223
+ : null;
224
+ prepared.push({
225
+ candidate,
226
+ sourceContentHash: pruned.sourceContentHash,
227
+ prunedBytes: pruned.prunedBytes,
228
+ originalBytes: pruned.originalBytes,
229
+ receiptKey: key,
230
+ alreadyImported: Boolean(key && receipts.receipts[key]),
231
+ });
232
+ }
233
+ catch (error) {
234
+ preparationErrors.push({
235
+ path: candidate.path,
236
+ error: error instanceof Error ? error.message : String(error),
237
+ });
238
+ }
239
+ }
240
+ const scope = args.allRepositories ? "all repositories" : repository.root;
241
+ const plan = {
242
+ scope,
243
+ selected: prepared.length,
244
+ alreadyImported: prepared.filter((item) => item.alreadyImported).length,
245
+ originalBytes: prepared.reduce((sum, item) => sum + item.originalBytes, 0),
246
+ prunedBytes: prepared.reduce((sum, item) => sum + item.prunedBytes, 0),
247
+ repositories: sessionRepositoryCounts(prepared),
248
+ dateRange: sessionDateRange(prepared),
249
+ skipped: skippedCounts(discovery.skipped),
250
+ failed: preparationErrors,
251
+ };
252
+ if (args.dryRun) {
253
+ if (args.json)
254
+ process.stdout.write(`${JSON.stringify({ dryRun: true, plan }, null, 2)}\n`);
255
+ else
256
+ printPlan({
257
+ scope,
258
+ sessions: prepared,
259
+ skipped: discovery.skipped,
260
+ failed: preparationErrors.length,
261
+ });
262
+ if (preparationErrors.length)
263
+ process.exitCode = 1;
264
+ return;
265
+ }
266
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY && !args.json);
267
+ if (!args.json)
268
+ printPlan({
269
+ scope,
270
+ sessions: prepared,
271
+ skipped: discovery.skipped,
272
+ failed: preparationErrors.length,
273
+ });
274
+ if (!apiKey)
275
+ throw new Error("Set GROUNDTRACK_API_KEY or pass --api-key before uploading.");
276
+ if (!args.yes && !interactive)
277
+ throw new Error("Import needs confirmation. Use --yes, or run it in an interactive terminal.");
278
+ if (!args.yes) {
279
+ const approved = await prompts.confirm({
280
+ message: `Upload ${prepared.filter((item) => !item.alreadyImported).length} Codex sessions?`,
281
+ initialValue: false,
282
+ });
283
+ if (prompts.isCancel(approved) || !approved) {
284
+ prompts.cancel("Nothing uploaded.");
285
+ return;
286
+ }
287
+ }
288
+ let imported = 0;
289
+ let duplicate = 0;
290
+ let receiptSkipped = 0;
291
+ const failures = [...preparationErrors];
292
+ for (const item of prepared) {
293
+ if (item.alreadyImported) {
294
+ receiptSkipped += 1;
295
+ continue;
296
+ }
297
+ try {
298
+ const session = await pruneCodexSession(item.candidate.path);
299
+ if (session.sourceContentHash !== item.sourceContentHash ||
300
+ session.prunedBytes !== item.prunedBytes)
301
+ throw new Error("Session changed after the import plan was created.");
302
+ const result = await uploadCodexSession({
303
+ apiBaseUrl: args.apiBaseUrl,
304
+ apiKey,
305
+ session,
306
+ visibility: args.visibility,
307
+ });
308
+ if (result.duplicate)
309
+ duplicate += 1;
310
+ else
311
+ imported += 1;
312
+ saveReceipt(context, receipts, item.receiptKey, result.sessionId);
313
+ }
314
+ catch (error) {
315
+ failures.push({
316
+ path: item.candidate.path,
317
+ error: error instanceof Error ? error.message : String(error),
318
+ });
319
+ }
320
+ }
321
+ const result = {
322
+ imported,
323
+ duplicate,
324
+ skipped: receiptSkipped + discovery.skipped.length,
325
+ failed: failures,
326
+ sessionsUrl: args.apiBaseUrl.replace(/\/api$/, "/sessions"),
327
+ plan,
328
+ };
329
+ if (args.json)
330
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
331
+ else
332
+ process.stdout.write(`Imported ${imported}; duplicates ${duplicate}; skipped ${result.skipped}; failed ${failures.length}.\n${result.sessionsUrl}\n`);
333
+ if (failures.length)
334
+ process.exitCode = 1;
335
+ }
@@ -0,0 +1,13 @@
1
+ import type { ImportVisibility, PrunedCodexSession } from "./types.js";
2
+ type ImportResponse = Readonly<{
3
+ duplicate: boolean;
4
+ sessionId: string;
5
+ }>;
6
+ export declare function uploadCodexSession(input: {
7
+ apiBaseUrl: string;
8
+ apiKey: string;
9
+ session: PrunedCodexSession;
10
+ visibility: ImportVisibility;
11
+ fetch?: typeof globalThis.fetch;
12
+ }): Promise<ImportResponse>;
13
+ export {};
@@ -0,0 +1,73 @@
1
+ import { CODEX_CANONICALIZATION_VERSION } from "./codex.js";
2
+ function retryable(status) {
3
+ return status === 408 || status === 429 || status >= 500;
4
+ }
5
+ function retryDelay(response, attempt) {
6
+ const retryAfter = response.headers.get("retry-after");
7
+ if (retryAfter && /^\d+$/.test(retryAfter))
8
+ return Math.min(Number(retryAfter) * 1000, 10_000);
9
+ return 250 * 2 ** attempt;
10
+ }
11
+ function wait(milliseconds) {
12
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
13
+ }
14
+ async function responseError(response) {
15
+ try {
16
+ const body = await response.json();
17
+ if (body && typeof body === "object" && "error" in body && typeof body.error === "string")
18
+ return body.error;
19
+ }
20
+ catch {
21
+ // Fall through to the status text.
22
+ }
23
+ return response.statusText || `HTTP ${response.status}`;
24
+ }
25
+ export async function uploadCodexSession(input) {
26
+ const request = input.fetch ?? globalThis.fetch;
27
+ let lastError = null;
28
+ for (let attempt = 0; attempt < 3; attempt += 1) {
29
+ try {
30
+ const response = await request(`${input.apiBaseUrl}/sessions/imports`, {
31
+ method: "POST",
32
+ headers: {
33
+ "content-type": "application/vnd.groundtrack.session+jsonl; version=1",
34
+ "x-api-key": input.apiKey,
35
+ "x-groundtrack-canonicalization": String(CODEX_CANONICALIZATION_VERSION),
36
+ "x-groundtrack-content-hash": input.session.sourceContentHash,
37
+ "x-groundtrack-visibility": input.visibility,
38
+ },
39
+ body: input.session.bytes,
40
+ signal: AbortSignal.timeout(300_000),
41
+ });
42
+ if (!response.ok) {
43
+ const error = new Error(`Import failed (${response.status}): ${await responseError(response)}`);
44
+ if (!retryable(response.status) || attempt === 2)
45
+ throw error;
46
+ await wait(retryDelay(response, attempt));
47
+ lastError = error;
48
+ continue;
49
+ }
50
+ const body = await response.json();
51
+ if (!body ||
52
+ typeof body !== "object" ||
53
+ !("duplicate" in body) ||
54
+ typeof body.duplicate !== "boolean" ||
55
+ !("session" in body) ||
56
+ !body.session ||
57
+ typeof body.session !== "object" ||
58
+ !("id" in body.session) ||
59
+ typeof body.session.id !== "string")
60
+ throw new Error("Import endpoint returned an invalid response.");
61
+ return { duplicate: body.duplicate, sessionId: body.session.id };
62
+ }
63
+ catch (error) {
64
+ lastError = error instanceof Error ? error : new Error(String(error));
65
+ if (attempt === 2 ||
66
+ (lastError.message.startsWith("Import failed") &&
67
+ !/\((408|429|5\d\d)\)/.test(lastError.message)))
68
+ throw lastError;
69
+ await wait(250 * 2 ** attempt);
70
+ }
71
+ }
72
+ throw lastError ?? new Error("Import failed.");
73
+ }
@@ -0,0 +1,37 @@
1
+ type UnknownRecord = Record<string, unknown>;
2
+ export declare const CODEX_CANONICALIZATION_VERSION = 1;
3
+ export declare const CODEX_CANONICAL_IMPORT_MAX_BYTES: number;
4
+ export declare const CODEX_CANONICAL_IMPORT_MAX_RECORD_BYTES: number;
5
+ export type CanonicalSessionEntry = Readonly<{
6
+ kind: "user_message" | "assistant_message" | "tool_call" | "tool_result";
7
+ occurredAt: string | null;
8
+ payload: Record<string, unknown>;
9
+ }>;
10
+ export type CanonicalCodexMetadata = Readonly<{
11
+ type: "session";
12
+ version: 1;
13
+ externalSessionId: string;
14
+ repositoryUrl: string | null;
15
+ repositoryBranch: string | null;
16
+ repositoryCommitSha: string | null;
17
+ workingDirectory: string | null;
18
+ originator: string | null;
19
+ model: string | null;
20
+ startedAt: string | null;
21
+ endedAt: string | null;
22
+ statistics: {
23
+ totalRecordCount: number;
24
+ importedEntryCount: number;
25
+ skippedRecordCount: number;
26
+ unsupportedRecordCount: number;
27
+ malformedRecordCount: number;
28
+ sensitiveRecordCount: number;
29
+ };
30
+ }>;
31
+ export declare function isSessionRecord(value: unknown): value is UnknownRecord;
32
+ export declare function sessionString(value: unknown): string | null;
33
+ export declare function isSensitiveCodexRecord(record: UnknownRecord): boolean;
34
+ export declare function isKnownSkippedCodexRecord(record: UnknownRecord): boolean;
35
+ export declare function normalizeCodexResponseItem(record: UnknownRecord): CanonicalSessionEntry | null;
36
+ export declare function codexSessionMetadata(record: UnknownRecord): Omit<CanonicalCodexMetadata, "model" | "startedAt" | "endedAt" | "statistics"> | null;
37
+ export {};
@@ -0,0 +1,134 @@
1
+ import { containsInternalInstructionText, redactCredentialText, sanitizeRepositoryUrl, sanitizeSessionValue, sanitizeWorkingDirectory, } from "./redaction.js";
2
+ export const CODEX_CANONICALIZATION_VERSION = 1;
3
+ export const CODEX_CANONICAL_IMPORT_MAX_BYTES = 50 * 1024 * 1024;
4
+ export const CODEX_CANONICAL_IMPORT_MAX_RECORD_BYTES = 4 * 1024 * 1024;
5
+ export function isSessionRecord(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value);
7
+ }
8
+ export function sessionString(value) {
9
+ return typeof value === "string" && value.length > 0 ? value : null;
10
+ }
11
+ function messageText(content) {
12
+ if (!Array.isArray(content))
13
+ return null;
14
+ const text = content
15
+ .filter(isSessionRecord)
16
+ .filter((part) => part.type === "input_text" || part.type === "output_text")
17
+ .map((part) => sessionString(part.text))
18
+ .filter((part) => part !== null)
19
+ .join("\n");
20
+ return text ? sanitizeSessionValue(text) : null;
21
+ }
22
+ function messageContainsInternalInstructions(payload) {
23
+ if (!Array.isArray(payload.content))
24
+ return false;
25
+ return payload.content
26
+ .filter(isSessionRecord)
27
+ .map((part) => sessionString(part.text) ?? "")
28
+ .some(containsInternalInstructionText);
29
+ }
30
+ export function isSensitiveCodexRecord(record) {
31
+ const type = sessionString(record.type);
32
+ const payload = isSessionRecord(record.payload) ? record.payload : {};
33
+ const payloadType = sessionString(payload.type);
34
+ const role = sessionString(payload.role);
35
+ return (type === "turn_context" ||
36
+ type === "world_state" ||
37
+ type === "compacted" ||
38
+ type === "inter_agent_communication_metadata" ||
39
+ payloadType === "reasoning" ||
40
+ payloadType === "agent_reasoning" ||
41
+ payloadType === "agent_message" ||
42
+ role === "system" ||
43
+ role === "developer" ||
44
+ (type === "response_item" &&
45
+ payloadType === "message" &&
46
+ messageContainsInternalInstructions(payload)));
47
+ }
48
+ export function isKnownSkippedCodexRecord(record) {
49
+ const type = sessionString(record.type);
50
+ const payload = isSessionRecord(record.payload) ? record.payload : {};
51
+ return (type === "session_meta" ||
52
+ type === "token_usage_record" ||
53
+ type === "event_msg" ||
54
+ sessionString(payload.type) === "reasoning");
55
+ }
56
+ function structuredValue(value) {
57
+ if (typeof value !== "string")
58
+ return sanitizeSessionValue(value);
59
+ try {
60
+ return sanitizeSessionValue(JSON.parse(value));
61
+ }
62
+ catch {
63
+ return sanitizeSessionValue(value);
64
+ }
65
+ }
66
+ export function normalizeCodexResponseItem(record) {
67
+ if (record.type !== "response_item" || !isSessionRecord(record.payload))
68
+ return null;
69
+ const payload = record.payload;
70
+ const payloadType = sessionString(payload.type);
71
+ const role = sessionString(payload.role);
72
+ const occurredAt = sessionString(record.timestamp);
73
+ if (payloadType === "message") {
74
+ if ((role !== "user" && role !== "assistant") || messageContainsInternalInstructions(payload))
75
+ return null;
76
+ const text = messageText(payload.content);
77
+ if (!text)
78
+ return null;
79
+ const phase = role === "assistant" ? sessionString(payload.phase) : null;
80
+ return {
81
+ kind: role === "user" ? "user_message" : "assistant_message",
82
+ occurredAt,
83
+ payload: {
84
+ text,
85
+ ...(phase ? { phase } : {}),
86
+ },
87
+ };
88
+ }
89
+ if (payloadType === "custom_tool_call" || payloadType === "function_call") {
90
+ const toolName = sessionString(payload.name);
91
+ if (!toolName)
92
+ return null;
93
+ return {
94
+ kind: "tool_call",
95
+ occurredAt,
96
+ payload: {
97
+ callId: sessionString(payload.call_id),
98
+ toolName,
99
+ input: structuredValue(payload.input ?? payload.arguments),
100
+ },
101
+ };
102
+ }
103
+ if (payloadType === "custom_tool_call_output" || payloadType === "function_call_output") {
104
+ return {
105
+ kind: "tool_result",
106
+ occurredAt,
107
+ payload: {
108
+ callId: sessionString(payload.call_id),
109
+ output: structuredValue(payload.output),
110
+ },
111
+ };
112
+ }
113
+ return null;
114
+ }
115
+ export function codexSessionMetadata(record) {
116
+ if (record.type !== "session_meta" || !isSessionRecord(record.payload))
117
+ return null;
118
+ const payload = record.payload;
119
+ const git = isSessionRecord(payload.git) ? payload.git : {};
120
+ const externalSessionId = sessionString(payload.session_id) ?? sessionString(payload.id);
121
+ if (!externalSessionId)
122
+ return null;
123
+ const originator = sessionString(payload.originator);
124
+ return {
125
+ type: "session",
126
+ version: 1,
127
+ externalSessionId,
128
+ repositoryUrl: sanitizeRepositoryUrl(git.repository_url),
129
+ repositoryBranch: sessionString(git.branch),
130
+ repositoryCommitSha: sessionString(git.commit_hash),
131
+ workingDirectory: sanitizeWorkingDirectory(payload.cwd),
132
+ originator: originator ? redactCredentialText(originator) : null,
133
+ };
134
+ }
@@ -0,0 +1,22 @@
1
+ import type { CodexSessionCandidate, CodexSessionMetadata, SkippedCodexSession } from "./types.js";
2
+ export declare function parseCodexMetadata(line: string): CodexSessionMetadata | null;
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 DiscoverCodexSessionsOptions = Readonly<{
10
+ codexHome: string;
11
+ repository?: RepositoryIdentity;
12
+ allRepositories: boolean;
13
+ includeUnscoped: boolean;
14
+ includeSubagents: boolean;
15
+ since: Date | null;
16
+ nowMs?: number;
17
+ liveWindowMs?: number;
18
+ }>;
19
+ export declare function discoverCodexSessions(options: DiscoverCodexSessionsOptions): {
20
+ included: CodexSessionCandidate[];
21
+ skipped: SkippedCodexSession[];
22
+ };
@@ -0,0 +1,188 @@
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
+ 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) {
11
+ const file = openSync(path, "r");
12
+ try {
13
+ const chunks = [];
14
+ let offset = 0;
15
+ while (offset < maxBytes) {
16
+ const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes - offset));
17
+ const read = readSync(file, chunk, 0, chunk.length, offset);
18
+ if (read === 0)
19
+ break;
20
+ 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;
26
+ }
27
+ return Buffer.concat(chunks).toString("utf8").replace(/\r$/, "");
28
+ }
29
+ finally {
30
+ closeSync(file);
31
+ }
32
+ }
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
+ function walkJsonl(root) {
68
+ if (!existsSync(root))
69
+ return [];
70
+ const result = [];
71
+ const visit = (directory) => {
72
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
73
+ const path = resolve(directory, entry.name);
74
+ if (entry.isDirectory())
75
+ visit(path);
76
+ else if (entry.isFile() && entry.name.toLowerCase().endsWith(".jsonl"))
77
+ result.push(path);
78
+ }
79
+ };
80
+ visit(root);
81
+ return result.sort();
82
+ }
83
+ export function normalizeRepositoryUrl(value) {
84
+ const trimmed = value
85
+ .trim()
86
+ .replace(/\.git\/?$/i, "")
87
+ .replace(/\/$/, "");
88
+ const scp = trimmed.match(/^[^@\s]+@([^:\s]+):(.+)$/);
89
+ const path = scp?.[2] ??
90
+ (() => {
91
+ try {
92
+ return new URL(trimmed).pathname;
93
+ }
94
+ catch {
95
+ return trimmed.includes("/") ? trimmed : null;
96
+ }
97
+ })();
98
+ if (!path)
99
+ return null;
100
+ const parts = path.split("/").filter(Boolean);
101
+ if (parts.length < 2)
102
+ return null;
103
+ return parts.slice(-2).join("/").toLowerCase();
104
+ }
105
+ export function repositoryIdentity(path) {
106
+ const root = realpathSync(execFileSync("git", ["-C", path, "rev-parse", "--show-toplevel"], {
107
+ encoding: "utf8",
108
+ stdio: ["ignore", "pipe", "ignore"],
109
+ timeout: 5_000,
110
+ }).trim());
111
+ const names = execFileSync("git", ["-C", root, "remote"], {
112
+ encoding: "utf8",
113
+ stdio: ["ignore", "pipe", "ignore"],
114
+ timeout: 5_000,
115
+ })
116
+ .split(/\r?\n/)
117
+ .filter(Boolean);
118
+ const remotes = new Set();
119
+ for (const name of names) {
120
+ const urls = execFileSync("git", ["-C", root, "remote", "get-url", "--all", name], {
121
+ encoding: "utf8",
122
+ stdio: ["ignore", "pipe", "ignore"],
123
+ timeout: 5_000,
124
+ }).split(/\r?\n/);
125
+ for (const url of urls) {
126
+ const normalized = normalizeRepositoryUrl(url);
127
+ if (normalized)
128
+ remotes.add(normalized);
129
+ }
130
+ }
131
+ return { root, remotes };
132
+ }
133
+ function isInside(candidate, root) {
134
+ if (!isAbsolute(candidate))
135
+ return false;
136
+ const child = relative(root, resolve(candidate));
137
+ return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
138
+ }
139
+ export function discoverCodexSessions(options) {
140
+ const nowMs = options.nowMs ?? Date.now();
141
+ const liveWindowMs = options.liveWindowMs ?? 5 * 60_000;
142
+ const roots = [
143
+ resolve(options.codexHome, "sessions"),
144
+ resolve(options.codexHome, "archived_sessions"),
145
+ ];
146
+ 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;
153
+ }
154
+ const stat = lstatSync(path);
155
+ if (!stat.isFile())
156
+ continue;
157
+ if (nowMs - stat.mtimeMs < liveWindowMs) {
158
+ skipped.push({ path, reason: "live" });
159
+ continue;
160
+ }
161
+ if (!options.includeSubagents && metadata.isSubagent) {
162
+ skipped.push({ path, reason: "subagent" });
163
+ continue;
164
+ }
165
+ if (options.since && (!metadata.timestamp || metadata.timestamp < options.since)) {
166
+ skipped.push({ path, reason: "before_since" });
167
+ continue;
168
+ }
169
+ const normalizedRemote = metadata.repositoryUrl
170
+ ? normalizeRepositoryUrl(metadata.repositoryUrl)
171
+ : null;
172
+ const matchesCurrentRepository = options.repository
173
+ ? normalizedRemote
174
+ ? options.repository.remotes.has(normalizedRemote)
175
+ : Boolean(metadata.cwd && isInside(metadata.cwd, options.repository.root))
176
+ : false;
177
+ if (!options.allRepositories && !matchesCurrentRepository) {
178
+ skipped.push({ path, reason: "other_repository" });
179
+ continue;
180
+ }
181
+ if (options.allRepositories && !normalizedRemote && !options.includeUnscoped) {
182
+ skipped.push({ path, reason: "unscoped" });
183
+ continue;
184
+ }
185
+ included.push({ path, metadata });
186
+ }
187
+ return { included, skipped };
188
+ }
@@ -0,0 +1,5 @@
1
+ import type { CodexSessionAnalysis, PrunedCodexSession } from "./types.js";
2
+ export declare function pruneCodexSession(path: string): Promise<PrunedCodexSession>;
3
+ export declare function pruneCodexSession(path: string, options: Readonly<{
4
+ emitBytes: false;
5
+ }>): Promise<CodexSessionAnalysis>;
@@ -0,0 +1,120 @@
1
+ import { createHash } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
3
+ import { stat } from "node:fs/promises";
4
+ import { createInterface } from "node:readline";
5
+ import { Transform } from "node:stream";
6
+ import { CODEX_CANONICAL_IMPORT_MAX_RECORD_BYTES, codexSessionMetadata, isSessionRecord, isKnownSkippedCodexRecord, isSensitiveCodexRecord, normalizeCodexResponseItem, sessionString, } from "./codex.js";
7
+ function recordTimestamp(record) {
8
+ const value = sessionString(record.timestamp);
9
+ if (!value)
10
+ return null;
11
+ const date = new Date(value);
12
+ return Number.isNaN(date.getTime()) ? null : date;
13
+ }
14
+ export async function pruneCodexSession(path, options = {}) {
15
+ const before = await stat(path);
16
+ const hash = createHash("sha256");
17
+ const encoder = new TextEncoder();
18
+ const entryChunks = [];
19
+ let entryBytes = 0;
20
+ let entryCount = 0;
21
+ const timestamps = [];
22
+ let metadata = null;
23
+ let model = null;
24
+ let originalRecords = 0;
25
+ let malformedRecordCount = 0;
26
+ let sensitiveRecordCount = 0;
27
+ let unsupportedRecordCount = 0;
28
+ const hashingStream = new Transform({
29
+ transform(chunk, _encoding, callback) {
30
+ hash.update(chunk);
31
+ callback(null, chunk);
32
+ },
33
+ });
34
+ const input = createReadStream(path);
35
+ input.pipe(hashingStream);
36
+ const lines = createInterface({ input: hashingStream, crlfDelay: Infinity });
37
+ for await (const line of lines) {
38
+ if (!line.trim())
39
+ continue;
40
+ originalRecords += 1;
41
+ let record;
42
+ try {
43
+ record = JSON.parse(line);
44
+ }
45
+ catch {
46
+ malformedRecordCount += 1;
47
+ continue;
48
+ }
49
+ if (!isSessionRecord(record)) {
50
+ malformedRecordCount += 1;
51
+ continue;
52
+ }
53
+ metadata ??= codexSessionMetadata(record);
54
+ const timestamp = recordTimestamp(record);
55
+ if (timestamp)
56
+ timestamps.push(timestamp);
57
+ if (record.type === "turn_context" && isSessionRecord(record.payload))
58
+ model ??= sessionString(record.payload.model);
59
+ const sensitive = isSensitiveCodexRecord(record);
60
+ if (sensitive)
61
+ sensitiveRecordCount += 1;
62
+ const entry = normalizeCodexResponseItem(record);
63
+ if (entry) {
64
+ const chunk = encoder.encode(`${JSON.stringify(entry)}\n`);
65
+ if (chunk.byteLength > CODEX_CANONICAL_IMPORT_MAX_RECORD_BYTES)
66
+ throw new Error(`Canonical session record is too large: ${path}`);
67
+ if (options.emitBytes !== false)
68
+ entryChunks.push(chunk);
69
+ entryBytes += chunk.byteLength;
70
+ entryCount += 1;
71
+ }
72
+ else if (!isKnownSkippedCodexRecord(record) && !sensitive)
73
+ unsupportedRecordCount += 1;
74
+ }
75
+ const after = await stat(path);
76
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
77
+ throw new Error(`Session changed while it was being read: ${path}`);
78
+ if (!metadata)
79
+ throw new Error(`Session metadata is missing: ${path}`);
80
+ if (entryCount === 0)
81
+ throw new Error(`Session has no supported trace entries: ${path}`);
82
+ const startedAt = timestamps.length
83
+ ? new Date(Math.min(...timestamps.map((timestamp) => timestamp.getTime()))).toISOString()
84
+ : null;
85
+ const endedAt = timestamps.length
86
+ ? new Date(Math.max(...timestamps.map((timestamp) => timestamp.getTime()))).toISOString()
87
+ : null;
88
+ const header = {
89
+ ...metadata,
90
+ model,
91
+ startedAt,
92
+ endedAt,
93
+ statistics: {
94
+ totalRecordCount: originalRecords,
95
+ importedEntryCount: entryCount,
96
+ skippedRecordCount: originalRecords - entryCount,
97
+ unsupportedRecordCount,
98
+ malformedRecordCount,
99
+ sensitiveRecordCount,
100
+ },
101
+ };
102
+ const headerBytes = encoder.encode(`${JSON.stringify(header)}\n`);
103
+ if (headerBytes.byteLength > CODEX_CANONICAL_IMPORT_MAX_RECORD_BYTES)
104
+ throw new Error(`Canonical session metadata is too large: ${path}`);
105
+ const analysis = {
106
+ sourceContentHash: hash.digest("hex"),
107
+ originalBytes: before.size,
108
+ prunedBytes: headerBytes.byteLength + entryBytes,
109
+ };
110
+ if (options.emitBytes === false)
111
+ return analysis;
112
+ const bytes = new Uint8Array(analysis.prunedBytes);
113
+ bytes.set(headerBytes);
114
+ let offset = headerBytes.byteLength;
115
+ for (const chunk of entryChunks) {
116
+ bytes.set(chunk, offset);
117
+ offset += chunk.byteLength;
118
+ }
119
+ return { ...analysis, bytes };
120
+ }
@@ -0,0 +1,20 @@
1
+ import type { SetupEnvironment } from "../setup/agents.js";
2
+ import type { ImportVisibility } from "./types.js";
3
+ type Receipt = Readonly<{
4
+ sessionId: string;
5
+ importedAt: string;
6
+ }>;
7
+ type ReceiptFile = {
8
+ version: 1;
9
+ receipts: Record<string, Receipt>;
10
+ };
11
+ export declare function receiptPath(context: SetupEnvironment): string;
12
+ export declare function readReceipts(context: SetupEnvironment): ReceiptFile;
13
+ export declare function receiptKey(input: {
14
+ apiBaseUrl: string;
15
+ apiKey: string;
16
+ sourceContentHash: string;
17
+ visibility: ImportVisibility;
18
+ }): string;
19
+ export declare function saveReceipt(context: SetupEnvironment, file: ReceiptFile, key: string, sessionId: string): void;
20
+ export {};
@@ -0,0 +1,44 @@
1
+ import { createHash } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { readJson } from "../setup/config.js";
4
+ import { readFile, writeAtomic } from "../setup/files.js";
5
+ import { stateDir } from "../setup/agents.js";
6
+ import { CODEX_CANONICALIZATION_VERSION } from "./codex.js";
7
+ export function receiptPath(context) {
8
+ return join(stateDir(context), "session-import-receipts.json");
9
+ }
10
+ export function readReceipts(context) {
11
+ const text = readFile(receiptPath(context));
12
+ if (text === null)
13
+ return { version: 1, receipts: {} };
14
+ const value = readJson(text);
15
+ if (value.version !== 1 || !value.receipts || typeof value.receipts !== "object")
16
+ throw new Error("Groundtrack's session import receipt file is invalid.");
17
+ const receipts = {};
18
+ for (const [key, receipt] of Object.entries(value.receipts)) {
19
+ if (receipt &&
20
+ typeof receipt === "object" &&
21
+ "sessionId" in receipt &&
22
+ typeof receipt.sessionId === "string" &&
23
+ "importedAt" in receipt &&
24
+ typeof receipt.importedAt === "string")
25
+ receipts[key] = { sessionId: receipt.sessionId, importedAt: receipt.importedAt };
26
+ }
27
+ return { version: 1, receipts };
28
+ }
29
+ export function receiptKey(input) {
30
+ return createHash("sha256")
31
+ .update([
32
+ input.apiBaseUrl,
33
+ createHash("sha256").update(input.apiKey).digest("hex"),
34
+ "codex_cli",
35
+ CODEX_CANONICALIZATION_VERSION,
36
+ input.visibility,
37
+ input.sourceContentHash,
38
+ ].join("\0"))
39
+ .digest("hex");
40
+ }
41
+ export function saveReceipt(context, file, key, sessionId) {
42
+ file.receipts[key] = { sessionId, importedAt: new Date().toISOString() };
43
+ writeAtomic(receiptPath(context), `${JSON.stringify(file, null, 2)}\n`, 0o600);
44
+ }
@@ -0,0 +1,6 @@
1
+ export declare function redactCredentialText(value: string): string;
2
+ export declare function sanitizeRepositoryUrl(value: unknown): string | null;
3
+ export declare function sanitizeWorkingDirectory(value: unknown): string | null;
4
+ export declare function sanitizeSessionValue(value: string): string;
5
+ export declare function sanitizeSessionValue(value: unknown): unknown;
6
+ export declare function containsInternalInstructionText(value: string): boolean;
@@ -0,0 +1,61 @@
1
+ const sensitiveKeyPattern = /(?:^|[_-])(?:api[_-]?key|token|authorization|auth|password|passwd|secret|private[_-]?key|cookie|credentials?)(?:$|[_-])/i;
2
+ const internalInstructionMarkers = [
3
+ "<local-command-caveat>",
4
+ "<system-reminder>",
5
+ "<environment_context>",
6
+ "<recommended_plugins>",
7
+ "# AGENTS.md instructions",
8
+ "<permissions instructions>",
9
+ "<skills_instructions>",
10
+ "<app-context>",
11
+ ];
12
+ function isRecord(value) {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value);
14
+ }
15
+ function isSensitiveKey(value) {
16
+ return sensitiveKeyPattern.test(value.replace(/([a-z0-9])([A-Z])/g, "$1_$2"));
17
+ }
18
+ export function redactCredentialText(value) {
19
+ return value
20
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]")
21
+ .replace(/\b(authorization\s*:\s*(?:bearer|basic)\s+)[^\s,;]+/gi, "$1[REDACTED]")
22
+ .replace(/\b((?:[a-z0-9_-]*(?:api[_-]?key|token|password|passwd|secret|private[_-]?key|credentials?))\s*[=:]\s*)[^\s,;]+/gi, "$1[REDACTED]")
23
+ .replace(/(?:^|\s)((?:--)?(?:api-key|token|password|secret)\s+)[^\s,;]+/gi, "$1$2[REDACTED]")
24
+ .replace(/\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{12,})\b/g, "[REDACTED]");
25
+ }
26
+ export function sanitizeRepositoryUrl(value) {
27
+ if (typeof value !== "string" || value.length === 0)
28
+ return null;
29
+ try {
30
+ const url = new URL(value);
31
+ url.username = "";
32
+ url.password = "";
33
+ return url.toString();
34
+ }
35
+ catch {
36
+ return redactCredentialText(value);
37
+ }
38
+ }
39
+ export function sanitizeWorkingDirectory(value) {
40
+ if (typeof value !== "string" || value.length === 0)
41
+ return null;
42
+ return redactCredentialText(value)
43
+ .replace(/^\/Users\/[^/]+(?=\/|$)/, "~")
44
+ .replace(/^\/home\/[^/]+(?=\/|$)/, "~")
45
+ .replace(/^[A-Za-z]:\\Users\\[^\\]+(?=\\|$)/i, "~");
46
+ }
47
+ export function sanitizeSessionValue(value) {
48
+ if (typeof value === "string")
49
+ return redactCredentialText(value);
50
+ if (Array.isArray(value))
51
+ return value.map(sanitizeSessionValue);
52
+ if (!isRecord(value))
53
+ return value;
54
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
55
+ key,
56
+ isSensitiveKey(key) ? "[REDACTED]" : sanitizeSessionValue(nested),
57
+ ]));
58
+ }
59
+ export function containsInternalInstructionText(value) {
60
+ return internalInstructionMarkers.some((marker) => value.includes(marker));
61
+ }
@@ -0,0 +1,28 @@
1
+ export type ImportVisibility = "private" | "organization";
2
+ export type CodexSessionMetadata = Readonly<{
3
+ externalSessionId: string;
4
+ timestamp: Date | null;
5
+ cwd: string | null;
6
+ repositoryUrl: string | null;
7
+ repositoryBranch: string | null;
8
+ repositoryCommitSha: string | null;
9
+ originator: string | null;
10
+ isSubagent: boolean;
11
+ }>;
12
+ export type CodexSessionCandidate = Readonly<{
13
+ path: string;
14
+ metadata: CodexSessionMetadata;
15
+ }>;
16
+ export type DiscoverySkipReason = "invalid_metadata" | "live" | "subagent" | "before_since" | "other_repository" | "unscoped";
17
+ export type SkippedCodexSession = Readonly<{
18
+ path: string;
19
+ reason: DiscoverySkipReason;
20
+ }>;
21
+ export type CodexSessionAnalysis = Readonly<{
22
+ sourceContentHash: string;
23
+ originalBytes: number;
24
+ prunedBytes: number;
25
+ }>;
26
+ export type PrunedCodexSession = CodexSessionAnalysis & Readonly<{
27
+ bytes: Uint8Array<ArrayBuffer>;
28
+ }>;
@@ -0,0 +1 @@
1
+ export {};
package/dist/setup/cli.js CHANGED
@@ -17,6 +17,7 @@ Usage:
17
17
  groundtrack setup --all-detected --yes Configure detected agents
18
18
  groundtrack status Check selected agents
19
19
  groundtrack doctor [--fix] Check or repair using the same flow
20
+ groundtrack import [options] Import local coding-agent sessions
20
21
  groundtrack version
21
22
 
22
23
  Options:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@groundtrack/hooks",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -41,6 +41,20 @@
41
41
  "workerd": "./src/agents.ts",
42
42
  "development": "./src/agents.ts",
43
43
  "default": "./dist/agents.js"
44
+ },
45
+ "./session-import/redaction": {
46
+ "types": "./src/session-import/redaction.ts",
47
+ "browser": "./src/session-import/redaction.ts",
48
+ "workerd": "./src/session-import/redaction.ts",
49
+ "development": "./src/session-import/redaction.ts",
50
+ "default": "./dist/session-import/redaction.js"
51
+ },
52
+ "./session-import/codex": {
53
+ "types": "./src/session-import/codex.ts",
54
+ "browser": "./src/session-import/codex.ts",
55
+ "workerd": "./src/session-import/codex.ts",
56
+ "development": "./src/session-import/codex.ts",
57
+ "default": "./dist/session-import/codex.js"
44
58
  }
45
59
  },
46
60
  "publishConfig": {