@groundtrack/hooks 0.1.25 → 0.1.27
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 +25 -0
- package/dist/groundtrack.js +6 -1
- package/dist/session-import/canonical.js +1 -1
- package/dist/session-import/cli.d.ts +1 -2
- package/dist/session-import/cli.js +37 -45
- package/dist/session-import/codex-pages.d.ts +22 -0
- package/dist/session-import/codex-pages.js +184 -0
- package/dist/session-import/codex.js +3 -0
- package/dist/session-import/compression.d.ts +8 -0
- package/dist/session-import/compression.js +16 -0
- package/dist/session-import/discovery.d.ts +20 -3
- package/dist/session-import/discovery.js +114 -39
- package/dist/session-import/prune.js +30 -12
- package/dist/session-import/receipts.d.ts +1 -1
- package/dist/session-import/sync.d.ts +32 -0
- package/dist/session-import/sync.js +55 -0
- package/dist/session-import/types.d.ts +3 -0
- package/dist/session-import/watch-lock.d.ts +17 -0
- package/dist/session-import/watch-lock.js +105 -0
- package/dist/session-import/watch-state.d.ts +47 -0
- package/dist/session-import/watch-state.js +128 -0
- package/dist/session-import/watch.d.ts +38 -0
- package/dist/session-import/watch.js +402 -0
- package/dist/setup/cli.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -169,6 +169,31 @@ high-confidence extracted lessons—not transcript content—to the organization
|
|
|
169
169
|
Changing transcript visibility does not change this learning behavior; deleting an imported
|
|
170
170
|
session retracts its derived knowledge.
|
|
171
171
|
|
|
172
|
+
## Watch completed sessions
|
|
173
|
+
|
|
174
|
+
`groundtrack watch --once` is the sync engine behind a future scheduled watcher.
|
|
175
|
+
One pass scans session file metadata, compares it with a durable cursor, and only
|
|
176
|
+
reads and uploads files whose size or modification time changed. Files modified in
|
|
177
|
+
the last five minutes stay `active`; once they are quiet, the pass prunes and
|
|
178
|
+
uploads them using the same privacy filter and receipts as `import`.
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
GROUNDTRACK_API_KEY=... groundtrack watch --once --agent codex --dry-run
|
|
182
|
+
GROUNDTRACK_API_KEY=... groundtrack watch --once --agent codex --all-repos --yes
|
|
183
|
+
GROUNDTRACK_API_KEY=... groundtrack watch --once --agent claude --all-repos --yes
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The cursor lives at `$XDG_CONFIG_HOME/groundtrack/watch-state-<source>.json` (mode
|
|
187
|
+
`0600`). Unchanged files are not reopened, rehashed, or re-uploaded. A preview
|
|
188
|
+
without `--yes` does not write cursor state. Only one watch pass can run per source
|
|
189
|
+
at a time; a stale PID lock is recovered automatically. The loop, filesystem
|
|
190
|
+
events, and an OS timer are intentionally not part of `--once`.
|
|
191
|
+
|
|
192
|
+
Codex paginated sessions are merged before upload. The watcher groups rollout
|
|
193
|
+
pages by `session_id`, follows the `history_base.thread_id` chain, and uploads one
|
|
194
|
+
complete snapshot. A chain with a missing or still-live page is reported instead
|
|
195
|
+
of uploading a fragment.
|
|
196
|
+
|
|
172
197
|
`templates/AGENTS.md` is the source of truth for the managed repository policy.
|
|
173
198
|
After changing it, run `pnpm sync:agent-instructions`; tests reject drift between
|
|
174
199
|
the setup guide, this repository's managed `AGENTS.md` block, and the packaged template.
|
package/dist/groundtrack.js
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { runCli } from "./setup/cli.js";
|
|
3
3
|
import { runImportCli } from "./session-import/cli.js";
|
|
4
|
+
import { runWatchCli } from "./session-import/watch.js";
|
|
4
5
|
const argv = process.argv.slice(2);
|
|
5
|
-
const operation = argv[0] === "import"
|
|
6
|
+
const operation = argv[0] === "import"
|
|
7
|
+
? runImportCli(argv.slice(1))
|
|
8
|
+
: argv[0] === "watch"
|
|
9
|
+
? runWatchCli(argv.slice(1))
|
|
10
|
+
: runCli(argv);
|
|
6
11
|
operation.catch((error) => {
|
|
7
12
|
const message = error instanceof Error ? error.message : "Groundtrack command failed.";
|
|
8
13
|
if (process.argv.includes("--json"))
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const CANONICALIZATION_VERSION = 2;
|
|
2
|
-
export const CANONICAL_IMPORT_MAX_BYTES =
|
|
2
|
+
export const CANONICAL_IMPORT_MAX_BYTES = 100 * 1024 * 1024;
|
|
3
3
|
export const CANONICAL_IMPORT_MAX_RECORD_BYTES = 4 * 1024 * 1024;
|
|
4
4
|
export const CANONICAL_IMPORT_MAX_ENTRIES = 25_000;
|
|
5
5
|
export function isSessionRecord(value) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ImportAgent, ImportVisibility } from "./types.js";
|
|
2
2
|
export declare const IMPORT_USAGE: string;
|
|
3
|
-
type ImportArguments = Readonly<{
|
|
3
|
+
export type ImportArguments = Readonly<{
|
|
4
4
|
agent: ImportAgent | null;
|
|
5
5
|
repoDir?: string;
|
|
6
6
|
homeDir?: string;
|
|
@@ -20,4 +20,3 @@ type ImportArguments = Readonly<{
|
|
|
20
20
|
export declare function normalizeApiBaseUrl(value: string): string;
|
|
21
21
|
export declare function parseImportArguments(argv: readonly string[]): ImportArguments;
|
|
22
22
|
export declare function runImportCli(argv: readonly string[]): Promise<void>;
|
|
23
|
-
export {};
|
|
@@ -2,12 +2,11 @@ import { basename, resolve } from "node:path";
|
|
|
2
2
|
import * as prompts from "@clack/prompts";
|
|
3
3
|
import { isSessionImportAgent, SESSION_IMPORT_AGENTS } from "../agents.js";
|
|
4
4
|
import { discoverAgents, environment, savedAgents, } from "../setup/agents.js";
|
|
5
|
-
import {
|
|
6
|
-
import { uploadAgentSession } from "./client.js";
|
|
5
|
+
import { groupCodexPages } from "./codex-pages.js";
|
|
7
6
|
import { discoverSessions, normalizeRepositoryUrl, repositoryIdentity } from "./discovery.js";
|
|
8
7
|
import { sessionImportFormat } from "./formats.js";
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
8
|
+
import { readReceipts } from "./receipts.js";
|
|
9
|
+
import { formatBytes, prepareSession, uploadPreparedSession, } from "./sync.js";
|
|
11
10
|
const importAgentIds = SESSION_IMPORT_AGENTS.map((agent) => agent.id);
|
|
12
11
|
const importAgentChoices = importAgentIds.join("|");
|
|
13
12
|
const importAgentDescription = importAgentIds.join(", ");
|
|
@@ -137,15 +136,6 @@ export function parseImportArguments(argv) {
|
|
|
137
136
|
help,
|
|
138
137
|
};
|
|
139
138
|
}
|
|
140
|
-
function formatBytes(bytes) {
|
|
141
|
-
if (bytes < 1024)
|
|
142
|
-
return `${bytes} B`;
|
|
143
|
-
if (bytes < 1024 ** 2)
|
|
144
|
-
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
145
|
-
if (bytes < 1024 ** 3)
|
|
146
|
-
return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
147
|
-
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
148
|
-
}
|
|
149
139
|
function skippedCounts(skipped) {
|
|
150
140
|
const result = {};
|
|
151
141
|
for (const item of skipped)
|
|
@@ -207,7 +197,7 @@ async function runAgentImport(args, agent, context, interactive) {
|
|
|
207
197
|
const home = resolve(context.env[format.homeEnvironmentVariable] ||
|
|
208
198
|
resolve(context.homeDir, format.defaultHomeDirectory));
|
|
209
199
|
const repository = args.allRepositories ? undefined : repositoryIdentity(context.repoDir);
|
|
210
|
-
const discovery = discoverSessions({
|
|
200
|
+
const discovery = await discoverSessions({
|
|
211
201
|
home,
|
|
212
202
|
format,
|
|
213
203
|
repository,
|
|
@@ -220,6 +210,20 @@ async function runAgentImport(args, agent, context, interactive) {
|
|
|
220
210
|
platform: context.platform,
|
|
221
211
|
workingDirectory: context.repoDir,
|
|
222
212
|
});
|
|
213
|
+
const grouped = format.agent === "codex"
|
|
214
|
+
? await groupCodexPages({
|
|
215
|
+
candidates: discovery.included,
|
|
216
|
+
metadataByPath: discovery.metadataByPath,
|
|
217
|
+
})
|
|
218
|
+
: {
|
|
219
|
+
candidates: discovery.included.map((candidate) => ({
|
|
220
|
+
candidate,
|
|
221
|
+
pages: [candidate.path],
|
|
222
|
+
merged: false,
|
|
223
|
+
})),
|
|
224
|
+
failed: [],
|
|
225
|
+
cleanup: () => { },
|
|
226
|
+
};
|
|
223
227
|
try {
|
|
224
228
|
const apiKey = args.apiKey ?? context.env.GROUNDTRACK_IMPORT_KEY ?? context.env.GROUNDTRACK_API_KEY;
|
|
225
229
|
const providedReceiptScope = args.receiptScope ?? context.env.GROUNDTRACK_IMPORT_RECEIPT_SCOPE;
|
|
@@ -229,31 +233,21 @@ async function runAgentImport(args, agent, context, interactive) {
|
|
|
229
233
|
const stableReceiptScope = apiKey?.startsWith("gti_") ? providedReceiptScope : undefined;
|
|
230
234
|
const receipts = apiKey ? readReceipts(context) : { version: 1, receipts: {} };
|
|
231
235
|
const prepared = [];
|
|
232
|
-
const preparationErrors = [
|
|
233
|
-
|
|
236
|
+
const preparationErrors = [
|
|
237
|
+
...discovery.failed,
|
|
238
|
+
...grouped.failed,
|
|
239
|
+
];
|
|
240
|
+
for (const { candidate } of grouped.candidates) {
|
|
234
241
|
try {
|
|
235
|
-
|
|
236
|
-
emitBytes: false,
|
|
237
|
-
metadata: candidate.metadata,
|
|
238
|
-
});
|
|
239
|
-
if (pruned.prunedBytes > CANONICAL_IMPORT_MAX_BYTES)
|
|
240
|
-
throw new Error(`Pruned session exceeds ${formatBytes(CANONICAL_IMPORT_MAX_BYTES)}`);
|
|
241
|
-
const key = apiKey
|
|
242
|
-
? receiptKey({
|
|
243
|
-
apiBaseUrl: args.apiBaseUrl,
|
|
244
|
-
apiKey,
|
|
245
|
-
receiptScope: stableReceiptScope,
|
|
246
|
-
source: format.source,
|
|
247
|
-
sourceContentHash: pruned.sourceContentHash,
|
|
248
|
-
visibility: args.visibility,
|
|
249
|
-
})
|
|
250
|
-
: null;
|
|
251
|
-
prepared.push({
|
|
252
|
-
...pruned,
|
|
242
|
+
prepared.push(await prepareSession({
|
|
253
243
|
candidate,
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
244
|
+
format,
|
|
245
|
+
apiBaseUrl: args.apiBaseUrl,
|
|
246
|
+
apiKey: apiKey ?? null,
|
|
247
|
+
receiptScope: stableReceiptScope,
|
|
248
|
+
visibility: args.visibility,
|
|
249
|
+
receipts,
|
|
250
|
+
}));
|
|
257
251
|
}
|
|
258
252
|
catch (error) {
|
|
259
253
|
preparationErrors.push({
|
|
@@ -324,22 +318,19 @@ async function runAgentImport(args, agent, context, interactive) {
|
|
|
324
318
|
continue;
|
|
325
319
|
}
|
|
326
320
|
try {
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
});
|
|
331
|
-
const result = await uploadAgentSession({
|
|
321
|
+
const result = await uploadPreparedSession({
|
|
322
|
+
prepared: item,
|
|
323
|
+
format,
|
|
332
324
|
apiBaseUrl: args.apiBaseUrl,
|
|
333
325
|
apiKey,
|
|
334
|
-
source: format.source,
|
|
335
|
-
session,
|
|
336
326
|
visibility: args.visibility,
|
|
327
|
+
context,
|
|
328
|
+
receipts,
|
|
337
329
|
});
|
|
338
330
|
if (result.duplicate)
|
|
339
331
|
duplicate += 1;
|
|
340
332
|
else
|
|
341
333
|
imported += 1;
|
|
342
|
-
saveReceipt(context, receipts, item.receiptKey, result.sessionId);
|
|
343
334
|
}
|
|
344
335
|
catch (error) {
|
|
345
336
|
failures.push({
|
|
@@ -365,6 +356,7 @@ async function runAgentImport(args, agent, context, interactive) {
|
|
|
365
356
|
return true;
|
|
366
357
|
}
|
|
367
358
|
finally {
|
|
359
|
+
grouped.cleanup();
|
|
368
360
|
discovery.cleanup?.();
|
|
369
361
|
}
|
|
370
362
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AgentSessionCandidate, AgentSessionMetadata, FailedAgentSession } from "./types.js";
|
|
2
|
+
export type GroupedCodexCandidate = Readonly<{
|
|
3
|
+
candidate: AgentSessionCandidate;
|
|
4
|
+
pages: readonly string[];
|
|
5
|
+
merged: boolean;
|
|
6
|
+
}>;
|
|
7
|
+
export type GroupedCodexCandidates = Readonly<{
|
|
8
|
+
candidates: GroupedCodexCandidate[];
|
|
9
|
+
failed: FailedAgentSession[];
|
|
10
|
+
cleanup(): void;
|
|
11
|
+
}>;
|
|
12
|
+
/**
|
|
13
|
+
* Codex can split one logical session across several rollout pages. Each page is
|
|
14
|
+
* a delta, not a snapshot, so importing pages independently produces several
|
|
15
|
+
* dashboard rows and a fragment for the newest one. This groups the pages by
|
|
16
|
+
* `session_id`, walks the `history_base.thread_id` chain, and returns one merged
|
|
17
|
+
* canonical candidate for the whole session.
|
|
18
|
+
*/
|
|
19
|
+
export declare function groupCodexPages(input: {
|
|
20
|
+
candidates: readonly AgentSessionCandidate[];
|
|
21
|
+
metadataByPath: ReadonlyMap<string, AgentSessionMetadata>;
|
|
22
|
+
}): Promise<GroupedCodexCandidates>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { createReadStream, createWriteStream, mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { once } from "node:events";
|
|
6
|
+
import { finished } from "node:stream/promises";
|
|
7
|
+
import { isSessionRecord } from "./canonical.js";
|
|
8
|
+
import { createSessionDecompressor, isCompressedSession } from "./compression.js";
|
|
9
|
+
function pageId(path, externalSessionId) {
|
|
10
|
+
const name = basename(path).replace(/\.jsonl(\.zst)?$/i, "");
|
|
11
|
+
const suffix = /_([0-9a-f-]{36})$/i.exec(name)?.[1];
|
|
12
|
+
return suffix ?? externalSessionId;
|
|
13
|
+
}
|
|
14
|
+
function orderedChain(pages) {
|
|
15
|
+
if (pages.length === 0)
|
|
16
|
+
return null;
|
|
17
|
+
const roots = pages.filter((page) => page.baseThreadId === null);
|
|
18
|
+
if (roots.length !== 1)
|
|
19
|
+
return null;
|
|
20
|
+
const children = new Map();
|
|
21
|
+
for (const page of pages) {
|
|
22
|
+
if (page.baseThreadId === null)
|
|
23
|
+
continue;
|
|
24
|
+
const list = children.get(page.baseThreadId) ?? [];
|
|
25
|
+
list.push(page);
|
|
26
|
+
children.set(page.baseThreadId, list);
|
|
27
|
+
}
|
|
28
|
+
if ([...children.values()].some((list) => list.length !== 1))
|
|
29
|
+
return null;
|
|
30
|
+
const ordered = [];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
let current = roots[0];
|
|
33
|
+
while (current) {
|
|
34
|
+
if (seen.has(current.pageId))
|
|
35
|
+
return null;
|
|
36
|
+
seen.add(current.pageId);
|
|
37
|
+
ordered.push(current);
|
|
38
|
+
current = children.get(current.pageId)?.[0];
|
|
39
|
+
}
|
|
40
|
+
return ordered.length === pages.length ? ordered : null;
|
|
41
|
+
}
|
|
42
|
+
async function writeLine(output, line) {
|
|
43
|
+
if (!output.write(`${line}\n`))
|
|
44
|
+
await once(output, "drain");
|
|
45
|
+
}
|
|
46
|
+
async function mergePages(chain, outputPath) {
|
|
47
|
+
const output = createWriteStream(outputPath, { mode: 0o600 });
|
|
48
|
+
try {
|
|
49
|
+
for (const [index, page] of chain.entries()) {
|
|
50
|
+
const input = createReadStream(page.path);
|
|
51
|
+
const source = isCompressedSession(page.path)
|
|
52
|
+
? input.pipe(createSessionDecompressor())
|
|
53
|
+
: input;
|
|
54
|
+
input.on("error", (error) => source.destroy(error));
|
|
55
|
+
const lines = createInterface({ input: source, crlfDelay: Infinity });
|
|
56
|
+
try {
|
|
57
|
+
for await (const line of lines) {
|
|
58
|
+
if (!line.trim())
|
|
59
|
+
continue;
|
|
60
|
+
// Only the root page's session_meta is authoritative. Continuation
|
|
61
|
+
// pages repeat the header; keeping it would duplicate metadata records.
|
|
62
|
+
if (index > 0) {
|
|
63
|
+
try {
|
|
64
|
+
const record = JSON.parse(line);
|
|
65
|
+
if (isSessionRecord(record) && record.type === "session_meta")
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Keep malformed lines so pruning can classify them.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
await writeLine(output, line);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
lines.close();
|
|
77
|
+
source.destroy();
|
|
78
|
+
input.destroy();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
output.end();
|
|
84
|
+
await finished(output);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Codex can split one logical session across several rollout pages. Each page is
|
|
89
|
+
* a delta, not a snapshot, so importing pages independently produces several
|
|
90
|
+
* dashboard rows and a fragment for the newest one. This groups the pages by
|
|
91
|
+
* `session_id`, walks the `history_base.thread_id` chain, and returns one merged
|
|
92
|
+
* canonical candidate for the whole session.
|
|
93
|
+
*/
|
|
94
|
+
export async function groupCodexPages(input) {
|
|
95
|
+
const candidates = [];
|
|
96
|
+
const includedBySession = new Map();
|
|
97
|
+
for (const candidate of input.candidates) {
|
|
98
|
+
if (candidate.metadata.isSubagent) {
|
|
99
|
+
candidates.push({ candidate, pages: [candidate.path], merged: false });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const list = includedBySession.get(candidate.metadata.externalSessionId) ?? [];
|
|
103
|
+
list.push(candidate);
|
|
104
|
+
includedBySession.set(candidate.metadata.externalSessionId, list);
|
|
105
|
+
}
|
|
106
|
+
const allPathsBySession = new Map();
|
|
107
|
+
for (const [path, metadata] of input.metadataByPath) {
|
|
108
|
+
if (metadata.isSubagent)
|
|
109
|
+
continue;
|
|
110
|
+
const list = allPathsBySession.get(metadata.externalSessionId) ?? [];
|
|
111
|
+
list.push(path);
|
|
112
|
+
allPathsBySession.set(metadata.externalSessionId, list);
|
|
113
|
+
}
|
|
114
|
+
const failed = [];
|
|
115
|
+
let temporaryDirectory = null;
|
|
116
|
+
for (const [externalSessionId, included] of includedBySession) {
|
|
117
|
+
const allPaths = allPathsBySession.get(externalSessionId) ?? [];
|
|
118
|
+
if (allPaths.length !== included.length) {
|
|
119
|
+
failed.push({
|
|
120
|
+
path: included[0].path,
|
|
121
|
+
error: "Codex session has rollout pages outside this import scope or still live; wait for the session to finish or widen the scope.",
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (included.length === 1) {
|
|
126
|
+
if (included[0].metadata.historyBaseThreadId) {
|
|
127
|
+
failed.push({
|
|
128
|
+
path: included[0].path,
|
|
129
|
+
error: "Codex session page chain is incomplete; the base page is missing.",
|
|
130
|
+
});
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
candidates.push({
|
|
134
|
+
candidate: included[0],
|
|
135
|
+
pages: [included[0].path],
|
|
136
|
+
merged: false,
|
|
137
|
+
});
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const pages = included.map((candidate) => ({
|
|
141
|
+
path: candidate.path,
|
|
142
|
+
pageId: pageId(candidate.path, externalSessionId),
|
|
143
|
+
baseThreadId: candidate.metadata.historyBaseThreadId ?? null,
|
|
144
|
+
}));
|
|
145
|
+
const chain = orderedChain(pages);
|
|
146
|
+
if (!chain) {
|
|
147
|
+
failed.push({
|
|
148
|
+
path: included[0].path,
|
|
149
|
+
error: "Codex session page chain is incomplete or branched.",
|
|
150
|
+
});
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
temporaryDirectory ??= mkdtempSync(join(tmpdir(), "groundtrack-codex-"));
|
|
154
|
+
const mergedPath = join(temporaryDirectory, `${externalSessionId.replaceAll(/[^a-zA-Z0-9-]/g, "_")}.jsonl`);
|
|
155
|
+
try {
|
|
156
|
+
await mergePages(chain, mergedPath);
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
failed.push({
|
|
160
|
+
path: included[0].path,
|
|
161
|
+
error: error instanceof Error ? error.message : String(error),
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const root = included.find((candidate) => candidate.path === chain[0].path);
|
|
166
|
+
candidates.push({
|
|
167
|
+
candidate: {
|
|
168
|
+
path: mergedPath,
|
|
169
|
+
metadata: root.metadata,
|
|
170
|
+
modifiedAt: new Date(Math.max(...included.map((candidate) => candidate.modifiedAt?.getTime() ?? 0))),
|
|
171
|
+
},
|
|
172
|
+
pages: chain.map((page) => page.path),
|
|
173
|
+
merged: true,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
candidates,
|
|
178
|
+
failed,
|
|
179
|
+
cleanup: () => {
|
|
180
|
+
if (temporaryDirectory)
|
|
181
|
+
rmSync(temporaryDirectory, { recursive: true, force: true });
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -164,6 +164,7 @@ function discoveryMetadata(record) {
|
|
|
164
164
|
return null;
|
|
165
165
|
const timestampValue = sessionString(payload.timestamp) ?? sessionString(record.timestamp);
|
|
166
166
|
const timestamp = timestampValue ? new Date(timestampValue) : null;
|
|
167
|
+
const historyBase = isSessionRecord(payload.history_base) ? payload.history_base : null;
|
|
167
168
|
return {
|
|
168
169
|
externalSessionId,
|
|
169
170
|
timestamp: timestamp && !Number.isNaN(timestamp.getTime()) ? timestamp : null,
|
|
@@ -173,6 +174,8 @@ function discoveryMetadata(record) {
|
|
|
173
174
|
repositoryCommitSha: sessionString(git.commit_hash),
|
|
174
175
|
originator: sessionString(payload.originator),
|
|
175
176
|
isSubagent: isSubagentMetadata(payload),
|
|
177
|
+
historyBaseThreadId: sessionString(historyBase?.thread_id),
|
|
178
|
+
historyMode: sessionString(payload.history_mode),
|
|
176
179
|
};
|
|
177
180
|
}
|
|
178
181
|
export const codexSessionFormat = {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
|
+
/**
|
|
3
|
+
* Codex compresses sessions older than a few weeks into `.jsonl.zst`. The file is
|
|
4
|
+
* a standard zstd frame containing the same JSONL records, so decompressing
|
|
5
|
+
* before hashing keeps the content hash identical to an uncompressed copy.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isCompressedSession(path: string): boolean;
|
|
8
|
+
export declare function createSessionDecompressor(): Transform;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
|
+
import * as zlib from "node:zlib";
|
|
3
|
+
/**
|
|
4
|
+
* Codex compresses sessions older than a few weeks into `.jsonl.zst`. The file is
|
|
5
|
+
* a standard zstd frame containing the same JSONL records, so decompressing
|
|
6
|
+
* before hashing keeps the content hash identical to an uncompressed copy.
|
|
7
|
+
*/
|
|
8
|
+
export function isCompressedSession(path) {
|
|
9
|
+
return path.toLowerCase().endsWith(".jsonl.zst");
|
|
10
|
+
}
|
|
11
|
+
export function createSessionDecompressor() {
|
|
12
|
+
const create = zlib.createZstdDecompress;
|
|
13
|
+
if (typeof create !== "function")
|
|
14
|
+
throw new Error("Reading .jsonl.zst sessions requires Node 22.19 or newer.");
|
|
15
|
+
return create();
|
|
16
|
+
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { SessionImportFormat } from "./format.js";
|
|
2
|
-
import type { AgentSessionCandidate, FailedAgentSession, SkippedAgentSession } from "./types.js";
|
|
2
|
+
import type { AgentSessionCandidate, AgentSessionMetadata, FailedAgentSession, SkippedAgentSession } from "./types.js";
|
|
3
|
+
export type SessionFileStat = Readonly<{
|
|
4
|
+
size: number;
|
|
5
|
+
mtimeMs: number;
|
|
6
|
+
}>;
|
|
7
|
+
/**
|
|
8
|
+
* A metadata-only snapshot of the session files. This is the cheap half of a
|
|
9
|
+
* watch pass: it walks directories and calls stat, but never opens a session.
|
|
10
|
+
*/
|
|
11
|
+
export declare function statSessionFiles(format: SessionImportFormat, home: string): Map<string, SessionFileStat>;
|
|
3
12
|
export declare function normalizeRepositoryUrl(value: string): string | null;
|
|
4
13
|
export type RepositoryIdentity = Readonly<{
|
|
5
14
|
root: string;
|
|
@@ -16,6 +25,11 @@ export type DiscoverSessionsOptions = Readonly<{
|
|
|
16
25
|
since: Date | null;
|
|
17
26
|
nowMs?: number;
|
|
18
27
|
liveWindowMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Metadata previously read from a file whose (size, mtime) is unchanged.
|
|
30
|
+
* The caller is responsible for invalidating entries when either changes.
|
|
31
|
+
*/
|
|
32
|
+
metadataCache?: ReadonlyMap<string, AgentSessionMetadata>;
|
|
19
33
|
resolveRepositoryRoot?: (path: string) => string;
|
|
20
34
|
resolveRepositoryIdentity?: (path: string) => RepositoryIdentity;
|
|
21
35
|
userHome?: string;
|
|
@@ -23,9 +37,12 @@ export type DiscoverSessionsOptions = Readonly<{
|
|
|
23
37
|
platform?: NodeJS.Platform;
|
|
24
38
|
workingDirectory?: string;
|
|
25
39
|
}>;
|
|
26
|
-
export declare function discoverSessions(options: DiscoverSessionsOptions): {
|
|
40
|
+
export declare function discoverSessions(options: DiscoverSessionsOptions): Promise<{
|
|
27
41
|
included: AgentSessionCandidate[];
|
|
28
42
|
skipped: SkippedAgentSession[];
|
|
29
43
|
failed: FailedAgentSession[];
|
|
44
|
+
metadataByPath: Map<string, AgentSessionMetadata>;
|
|
45
|
+
cacheHits: number;
|
|
46
|
+
cacheMisses: number;
|
|
30
47
|
cleanup?: () => void;
|
|
31
|
-
}
|
|
48
|
+
}>;
|