@groundtrack/hooks 0.1.25 → 0.1.26
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/dist/session-import/cli.js +1 -1
- package/dist/session-import/compression.d.ts +8 -0
- package/dist/session-import/compression.js +16 -0
- package/dist/session-import/discovery.d.ts +2 -2
- package/dist/session-import/discovery.js +66 -37
- package/dist/session-import/prune.js +30 -12
- package/package.json +2 -2
|
@@ -207,7 +207,7 @@ async function runAgentImport(args, agent, context, interactive) {
|
|
|
207
207
|
const home = resolve(context.env[format.homeEnvironmentVariable] ||
|
|
208
208
|
resolve(context.homeDir, format.defaultHomeDirectory));
|
|
209
209
|
const repository = args.allRepositories ? undefined : repositoryIdentity(context.repoDir);
|
|
210
|
-
const discovery = discoverSessions({
|
|
210
|
+
const discovery = await discoverSessions({
|
|
211
211
|
home,
|
|
212
212
|
format,
|
|
213
213
|
repository,
|
|
@@ -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
|
+
}
|
|
@@ -23,9 +23,9 @@ export type DiscoverSessionsOptions = Readonly<{
|
|
|
23
23
|
platform?: NodeJS.Platform;
|
|
24
24
|
workingDirectory?: string;
|
|
25
25
|
}>;
|
|
26
|
-
export declare function discoverSessions(options: DiscoverSessionsOptions): {
|
|
26
|
+
export declare function discoverSessions(options: DiscoverSessionsOptions): Promise<{
|
|
27
27
|
included: AgentSessionCandidate[];
|
|
28
28
|
skipped: SkippedAgentSession[];
|
|
29
29
|
failed: FailedAgentSession[];
|
|
30
30
|
cleanup?: () => void;
|
|
31
|
-
}
|
|
31
|
+
}>;
|
|
@@ -1,45 +1,71 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { createReadStream, existsSync, lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
3
3
|
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
6
|
import { isSessionRecord } from "./canonical.js";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.split(/\r?\n/, maxRecords)
|
|
28
|
-
.flatMap((line) => {
|
|
29
|
-
if (!line.trim())
|
|
30
|
-
return [];
|
|
7
|
+
import { createSessionDecompressor, isCompressedSession } from "./compression.js";
|
|
8
|
+
async function initialRecords(path, maxRecords, maxBytes = 1024 * 1024) {
|
|
9
|
+
const input = createReadStream(path);
|
|
10
|
+
const source = isCompressedSession(path) ? input.pipe(createSessionDecompressor()) : input;
|
|
11
|
+
// `pipe()` does not forward upstream errors to the destination. Surface them on
|
|
12
|
+
// the stream being iterated so the catch below handles a vanished or unreadable
|
|
13
|
+
// file instead of crashing on an unhandled 'error' event.
|
|
14
|
+
input.on("error", (error) => source.destroy(error));
|
|
15
|
+
const decoder = new StringDecoder("utf8");
|
|
16
|
+
const records = [];
|
|
17
|
+
let pending = "";
|
|
18
|
+
let lineCount = 0;
|
|
19
|
+
let bytes = 0;
|
|
20
|
+
const consume = (line) => {
|
|
21
|
+
const nextCount = lineCount + 1;
|
|
22
|
+
const nextBytes = bytes + Buffer.byteLength(line) + 1;
|
|
23
|
+
if (nextCount > maxRecords || nextBytes > maxBytes)
|
|
24
|
+
return false;
|
|
25
|
+
lineCount = nextCount;
|
|
26
|
+
bytes = nextBytes;
|
|
27
|
+
if (line.trim()) {
|
|
31
28
|
try {
|
|
32
29
|
const record = JSON.parse(line);
|
|
33
|
-
|
|
30
|
+
if (isSessionRecord(record))
|
|
31
|
+
records.push(record);
|
|
34
32
|
}
|
|
35
33
|
catch {
|
|
36
|
-
|
|
34
|
+
// Ignore malformed lines, matching the previous behavior.
|
|
37
35
|
}
|
|
38
|
-
}
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
};
|
|
39
|
+
try {
|
|
40
|
+
for await (const chunk of source) {
|
|
41
|
+
pending += decoder.write(chunk);
|
|
42
|
+
let newline = pending.indexOf("\n");
|
|
43
|
+
while (newline >= 0) {
|
|
44
|
+
const line = pending.slice(0, newline).replace(/\r$/, "");
|
|
45
|
+
pending = pending.slice(newline + 1);
|
|
46
|
+
if (!consume(line))
|
|
47
|
+
return records;
|
|
48
|
+
newline = pending.indexOf("\n");
|
|
49
|
+
}
|
|
50
|
+
// Stop before buffering a line that cannot fit in the byte budget.
|
|
51
|
+
if (bytes + Buffer.byteLength(pending) > maxBytes)
|
|
52
|
+
return records;
|
|
53
|
+
}
|
|
54
|
+
pending += decoder.end();
|
|
55
|
+
if (pending.trim())
|
|
56
|
+
consume(pending.replace(/\r$/, ""));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A truncated or corrupt compression stream should not abort discovery.
|
|
39
60
|
}
|
|
40
61
|
finally {
|
|
41
|
-
|
|
62
|
+
// `pipe()` does not destroy the source when the destination is destroyed.
|
|
63
|
+
// Close both ends explicitly so an early-stopped `.jsonl.zst` read does not
|
|
64
|
+
// leak a file descriptor.
|
|
65
|
+
source.destroy();
|
|
66
|
+
input.destroy();
|
|
42
67
|
}
|
|
68
|
+
return records;
|
|
43
69
|
}
|
|
44
70
|
function walkJsonl(root) {
|
|
45
71
|
if (!existsSync(root))
|
|
@@ -50,7 +76,9 @@ function walkJsonl(root) {
|
|
|
50
76
|
const path = resolve(directory, entry.name);
|
|
51
77
|
if (entry.isDirectory())
|
|
52
78
|
visit(path);
|
|
53
|
-
else if (entry.isFile() &&
|
|
79
|
+
else if (entry.isFile() &&
|
|
80
|
+
(entry.name.toLowerCase().endsWith(".jsonl") ||
|
|
81
|
+
entry.name.toLowerCase().endsWith(".jsonl.zst")))
|
|
54
82
|
result.push(path);
|
|
55
83
|
}
|
|
56
84
|
};
|
|
@@ -116,7 +144,7 @@ function isInside(candidate, root) {
|
|
|
116
144
|
const child = relative(root, resolve(candidate));
|
|
117
145
|
return child === "" || (!child.startsWith(`..${sep}`) && child !== "..");
|
|
118
146
|
}
|
|
119
|
-
export function discoverSessions(options) {
|
|
147
|
+
export async function discoverSessions(options) {
|
|
120
148
|
const nowMs = options.nowMs ?? Date.now();
|
|
121
149
|
const liveWindowMs = options.liveWindowMs ?? 5 * 60_000;
|
|
122
150
|
const included = [];
|
|
@@ -127,13 +155,14 @@ export function discoverSessions(options) {
|
|
|
127
155
|
env: options.env ?? process.env,
|
|
128
156
|
platform: options.platform ?? process.platform,
|
|
129
157
|
};
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
158
|
+
const format = options.format;
|
|
159
|
+
const discovered = format.discovery === "custom"
|
|
160
|
+
? format.discoverCandidates(discoveryContext)
|
|
161
|
+
: await (async () => {
|
|
133
162
|
const candidates = [];
|
|
134
163
|
const rejected = [];
|
|
135
|
-
for (const path of
|
|
136
|
-
const metadata =
|
|
164
|
+
for (const path of format.sessionRoots(options.home).flatMap(walkJsonl)) {
|
|
165
|
+
const metadata = format.parseDiscoveryMetadata(await initialRecords(path, format.discoveryRecordLimit));
|
|
137
166
|
if (metadata)
|
|
138
167
|
candidates.push({ path, metadata });
|
|
139
168
|
else
|
|
@@ -4,6 +4,7 @@ import { stat } from "node:fs/promises";
|
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
5
|
import { Transform } from "node:stream";
|
|
6
6
|
import { CANONICAL_IMPORT_MAX_RECORD_BYTES } from "./canonical.js";
|
|
7
|
+
import { createSessionDecompressor, isCompressedSession } from "./compression.js";
|
|
7
8
|
import { createSessionNormalizationAccumulator } from "./normalize.js";
|
|
8
9
|
async function scanSession(path, format, discoveryMetadata, onEntry) {
|
|
9
10
|
const before = await stat(path);
|
|
@@ -12,6 +13,7 @@ async function scanSession(path, format, discoveryMetadata, onEntry) {
|
|
|
12
13
|
hash.update(`${format.sourceHashSeed(discoveryMetadata)}\0`);
|
|
13
14
|
const encoder = new TextEncoder();
|
|
14
15
|
let entryBytes = 0;
|
|
16
|
+
let originalBytes = 0;
|
|
15
17
|
const accumulator = createSessionNormalizationAccumulator(format.createNormalizer(discoveryMetadata), {
|
|
16
18
|
discoveryMetadata,
|
|
17
19
|
onEntry: (entry) => {
|
|
@@ -24,24 +26,40 @@ async function scanSession(path, format, discoveryMetadata, onEntry) {
|
|
|
24
26
|
});
|
|
25
27
|
const hashingStream = new Transform({
|
|
26
28
|
transform(chunk, _encoding, callback) {
|
|
29
|
+
originalBytes += chunk.length;
|
|
27
30
|
hash.update(chunk);
|
|
28
31
|
callback(null, chunk);
|
|
29
32
|
},
|
|
30
33
|
});
|
|
31
|
-
const
|
|
34
|
+
const rawInput = createReadStream(path);
|
|
35
|
+
const input = isCompressedSession(path) ? rawInput.pipe(createSessionDecompressor()) : rawInput;
|
|
36
|
+
// `pipe()` does not forward upstream errors. Move an error from the file stream
|
|
37
|
+
// to the decoder, then to the hashing stream that readline is consuming, so the
|
|
38
|
+
// loop below observes it instead of the process crashing.
|
|
39
|
+
rawInput.on("error", (error) => input.destroy(error));
|
|
40
|
+
input.on("error", (error) => hashingStream.destroy(error));
|
|
32
41
|
input.pipe(hashingStream);
|
|
33
42
|
const lines = createInterface({ input: hashingStream, crlfDelay: Infinity });
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
try {
|
|
44
|
+
for await (const line of lines) {
|
|
45
|
+
if (!line.trim())
|
|
46
|
+
continue;
|
|
47
|
+
let record;
|
|
48
|
+
try {
|
|
49
|
+
record = JSON.parse(line);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
record = null;
|
|
53
|
+
}
|
|
54
|
+
accumulator.add(record);
|
|
40
55
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
lines.close();
|
|
59
|
+
hashingStream.destroy();
|
|
60
|
+
input.destroy();
|
|
61
|
+
if (input !== rawInput)
|
|
62
|
+
rawInput.destroy();
|
|
45
63
|
}
|
|
46
64
|
const after = await stat(path);
|
|
47
65
|
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs)
|
|
@@ -52,7 +70,7 @@ async function scanSession(path, format, discoveryMetadata, onEntry) {
|
|
|
52
70
|
throw new Error(`Canonical session metadata is too large: ${path}`);
|
|
53
71
|
return {
|
|
54
72
|
sourceContentHash: hash.digest("hex"),
|
|
55
|
-
originalBytes
|
|
73
|
+
originalBytes,
|
|
56
74
|
prunedBytes: headerBytes.byteLength + entryBytes,
|
|
57
75
|
metadata: header,
|
|
58
76
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@groundtrack/hooks",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.26",
|
|
4
4
|
"description": "Lifecycle hook utilities for Groundtrack MCP clients.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -116,6 +116,6 @@
|
|
|
116
116
|
"typescript": "~7.0.2"
|
|
117
117
|
},
|
|
118
118
|
"engines": {
|
|
119
|
-
"node": ">=
|
|
119
|
+
"node": ">=22.19"
|
|
120
120
|
}
|
|
121
121
|
}
|