@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 +20 -0
- package/dist/groundtrack-hook.js +0 -0
- package/dist/groundtrack.js +5 -2
- package/dist/session-import/cli.d.ts +21 -0
- package/dist/session-import/cli.js +335 -0
- package/dist/session-import/client.d.ts +13 -0
- package/dist/session-import/client.js +73 -0
- package/dist/session-import/codex.d.ts +37 -0
- package/dist/session-import/codex.js +134 -0
- package/dist/session-import/discovery.d.ts +22 -0
- package/dist/session-import/discovery.js +188 -0
- package/dist/session-import/prune.d.ts +5 -0
- package/dist/session-import/prune.js +120 -0
- package/dist/session-import/receipts.d.ts +20 -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 +28 -0
- package/dist/session-import/types.js +1 -0
- package/dist/setup/cli.js +1 -0
- package/package.json +15 -1
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.
|
package/dist/groundtrack-hook.js
CHANGED
|
File without changes
|
package/dist/groundtrack.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCli } from "./setup/cli.js";
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
|
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": {
|