@groundtrack/hooks 0.1.24 → 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.
@@ -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 { closeSync, existsSync, lstatSync, openSync, readSync, readdirSync, realpathSync, } from "node:fs";
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
- function initialRecords(path, maxRecords, maxBytes = 1024 * 1024) {
7
- const file = openSync(path, "r");
8
- try {
9
- const chunks = [];
10
- let offset = 0;
11
- let newlineCount = 0;
12
- while (offset < maxBytes && newlineCount < maxRecords) {
13
- const chunk = Buffer.alloc(Math.min(64 * 1024, maxBytes - offset));
14
- const read = readSync(file, chunk, 0, chunk.length, offset);
15
- if (read === 0)
16
- break;
17
- const bytes = chunk.subarray(0, read);
18
- chunks.push(bytes);
19
- offset += read;
20
- for (const byte of bytes) {
21
- if (byte === 10 && ++newlineCount >= maxRecords)
22
- break;
23
- }
24
- }
25
- return Buffer.concat(chunks)
26
- .toString("utf8")
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
- return isSessionRecord(record) ? [record] : [];
30
+ if (isSessionRecord(record))
31
+ records.push(record);
34
32
  }
35
33
  catch {
36
- return [];
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
- closeSync(file);
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() && entry.name.toLowerCase().endsWith(".jsonl"))
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 discovered = options.format.discovery === "custom"
131
- ? options.format.discoverCandidates(discoveryContext)
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 options.format.sessionRoots(options.home).flatMap(walkJsonl)) {
136
- const metadata = options.format.parseDiscoveryMetadata(initialRecords(path, options.format.discoveryRecordLimit));
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 input = createReadStream(path);
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
- for await (const line of lines) {
35
- if (!line.trim())
36
- continue;
37
- let record;
38
- try {
39
- record = JSON.parse(line);
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
- catch {
42
- record = null;
43
- }
44
- accumulator.add(record);
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: before.size,
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.24",
3
+ "version": "0.1.26",
4
4
  "description": "Lifecycle hook utilities for Groundtrack MCP clients.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -84,6 +84,11 @@
84
84
  "workerd": "./src/session-import/normalize.ts",
85
85
  "development": "./src/session-import/normalize.ts",
86
86
  "default": "./dist/session-import/normalize.js"
87
+ },
88
+ "./session-import/prune": {
89
+ "types": "./src/session-import/prune.ts",
90
+ "development": "./src/session-import/prune.ts",
91
+ "default": "./dist/session-import/prune.js"
87
92
  }
88
93
  },
89
94
  "publishConfig": {
@@ -91,6 +96,7 @@
91
96
  },
92
97
  "scripts": {
93
98
  "build": "tsc -p tsconfig.json",
99
+ "fixtures:session-import": "tsx scripts/make-session-fixtures.ts",
94
100
  "lint": "vp lint",
95
101
  "prepack": "pnpm run build",
96
102
  "release:patch": "node scripts/release-patch.mjs",
@@ -110,6 +116,6 @@
110
116
  "typescript": "~7.0.2"
111
117
  },
112
118
  "engines": {
113
- "node": ">=18"
119
+ "node": ">=22.19"
114
120
  }
115
121
  }