@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 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.
@@ -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" ? runImportCli(argv.slice(1)) : runCli(argv);
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 = 50 * 1024 * 1024;
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 { CANONICAL_IMPORT_MAX_BYTES } from "./canonical.js";
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 { pruneSession } from "./prune.js";
10
- import { readReceipts, receiptKey, saveReceipt } from "./receipts.js";
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 = [...discovery.failed];
233
- for (const candidate of discovery.included) {
236
+ const preparationErrors = [
237
+ ...discovery.failed,
238
+ ...grouped.failed,
239
+ ];
240
+ for (const { candidate } of grouped.candidates) {
234
241
  try {
235
- const pruned = await pruneSession(candidate.path, format, {
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
- receiptKey: key,
255
- alreadyImported: Boolean(key && receipts.receipts[key]),
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 session = await pruneSession(item.candidate.path, format, {
328
- plan: item,
329
- metadata: item.candidate.metadata,
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
+ }>;