@turns/dev 0.0.28 → 0.0.29

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.
@@ -13,6 +13,7 @@ import { ACP_PROFILES, lettaHeadlessEnvironment, lettaSessionIdentity, PROCESS_H
13
13
  import { asyncQuestionInteractions, asyncQuestionReplyText, isAsyncQuestionInteraction, parseAsyncQuestionReplies, } from "./codex-async-questions.js";
14
14
  import { recordLettaSessionContext } from "./letta-session-context.js";
15
15
  import { recoverAsyncQuestion } from "./codex-async-question-recovery.js";
16
+ import { existingTranscriptPath } from "./compressed-transcripts.js";
16
17
  import { BackgroundTaskState } from "./background-task-state.js";
17
18
  const socketIndex = process.argv.indexOf('--socket');
18
19
  const socketPath = socketIndex >= 0 ? path.resolve(process.argv[socketIndex + 1] || '') : '';
@@ -747,7 +748,9 @@ class CodexAppServerTransport extends LineTransport {
747
748
  : 'workspace-write',
748
749
  approvalPolicy: this.anchor.permissionMode === 'bypassPermissions' ? 'never' : 'on-request',
749
750
  };
750
- const resumePath = !createSession && fs.existsSync(this.anchor.transcriptPath) ? this.anchor.transcriptPath : '';
751
+ // Codex may have compressed or restored the rollout since discovery; it
752
+ // resumes either spelling and writes the plain `.jsonl` back.
753
+ const resumePath = createSession ? '' : existingTranscriptPath(this.anchor.transcriptPath);
751
754
  const opened = createSession
752
755
  ? await this.request('thread/start', {
753
756
  ...configuration,
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import readline from 'node:readline';
3
+ import { Readable } from 'node:stream';
3
4
  import { ASYNC_QUESTION_TOOL, asyncQuestionInteractions, parseAsyncQuestionReplies } from "./codex-async-questions.js";
5
+ import { existingTranscriptPath, isCompressedTranscript, readTranscriptSync } from "./compressed-transcripts.js";
4
6
  export function isAsyncQuestionId(value) {
5
7
  if (typeof value !== 'string')
6
8
  return false;
@@ -30,7 +32,22 @@ export async function recoverAsyncQuestion(transcriptPath, interactionId) {
30
32
  const [, callId] = JSON.parse(interactionId);
31
33
  let candidate = null;
32
34
  let delivered = false;
33
- const stream = fs.createReadStream(transcriptPath, { encoding: 'utf8' });
35
+ // Codex compresses idle rollouts and restores them on resume, so read
36
+ // whichever spelling of this anchor's rollout exists now.
37
+ const file = existingTranscriptPath(transcriptPath);
38
+ if (!file)
39
+ return null;
40
+ let stream;
41
+ try {
42
+ stream = isCompressedTranscript(file)
43
+ ? Readable.from([readTranscriptSync(file).toString('utf8')])
44
+ : fs.createReadStream(file, { encoding: 'utf8' });
45
+ }
46
+ catch (error) {
47
+ if (error?.code === 'ENOENT')
48
+ return null;
49
+ throw error;
50
+ }
34
51
  const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
35
52
  try {
36
53
  for await (const line of lines) {
@@ -0,0 +1,99 @@
1
+ import fs from 'node:fs';
2
+ import { zstdDecompressSync } from 'node:zlib';
3
+ /**
4
+ * Codex compresses idle rollouts in place (`rollout-*.jsonl` becomes
5
+ * `rollout-*.jsonl.zst`) and restores the plain file when the thread resumes.
6
+ * Both spellings are one logical transcript: descriptors keep the `.jsonl`
7
+ * source path so the turns.dev session ID is unchanged across that rename, and
8
+ * only the physical `file` names the compressed copy.
9
+ */
10
+ const COMPRESSED_SUFFIX = '.zst';
11
+ const PREFIX_CACHE_BYTES = 32 * 1024 * 1024;
12
+ const MAX_PREFIX_BYTES = 1024 * 1024;
13
+ export function isCompressedTranscript(file) {
14
+ return /\.jsonl\.zst$/iu.test(String(file || ''));
15
+ }
16
+ /** The plain `.jsonl` path a compressed rollout was made from. */
17
+ export function logicalTranscriptPath(file) {
18
+ return isCompressedTranscript(file) ? file.slice(0, -COMPRESSED_SUFFIX.length) : file;
19
+ }
20
+ function isFile(file) {
21
+ try {
22
+ return fs.statSync(file).isFile();
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ /** True when a compressed rollout should yield to its live plain sibling. */
29
+ export function compressedTranscriptIsShadowed(file) {
30
+ return isCompressedTranscript(file) && isFile(logicalTranscriptPath(file));
31
+ }
32
+ /**
33
+ * The on-disk spelling of a logical transcript right now. Codex may compress
34
+ * or restore the file between discovery and use, so callers holding either
35
+ * path get whichever one exists, preferring the plain file.
36
+ */
37
+ export function existingTranscriptPath(file) {
38
+ const plain = logicalTranscriptPath(file);
39
+ if (isFile(plain))
40
+ return plain;
41
+ if (/\.jsonl$/iu.test(plain) && isFile(`${plain}${COMPRESSED_SUFFIX}`))
42
+ return `${plain}${COMPRESSED_SUFFIX}`;
43
+ return isFile(file) ? file : '';
44
+ }
45
+ function statFingerprint(file) {
46
+ const stat = fs.statSync(file);
47
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}`;
48
+ }
49
+ // Sync zstd decoding cannot stop early, so every read decompresses the whole
50
+ // frame. A compressed rollout is idle by construction, so metadata probes
51
+ // (working directory, subagent markers, lineage) share a fingerprinted cache
52
+ // of bounded prefixes instead of re-decoding on every inventory pass. Whole
53
+ // bodies are never cached: a long rollout can decode to hundreds of MB.
54
+ const prefixes = new Map();
55
+ let prefixBytes = 0;
56
+ /** Whole decoded contents of a transcript file, compressed or not. */
57
+ export function readTranscriptSync(file) {
58
+ const contents = fs.readFileSync(file);
59
+ return isCompressedTranscript(file) ? zstdDecompressSync(contents) : contents;
60
+ }
61
+ function plainPrefix(file, maxBytes) {
62
+ const handle = fs.openSync(file, 'r');
63
+ try {
64
+ const buffer = Buffer.alloc(Math.max(0, Math.min(fs.fstatSync(handle).size, maxBytes)));
65
+ const bytesRead = fs.readSync(handle, buffer, 0, buffer.length, 0);
66
+ return buffer.subarray(0, bytesRead);
67
+ }
68
+ finally {
69
+ fs.closeSync(handle);
70
+ }
71
+ }
72
+ /** At most `maxBytes` decoded bytes from the start of a transcript file. */
73
+ export function readTranscriptPrefixSync(file, maxBytes) {
74
+ if (!isCompressedTranscript(file))
75
+ return plainPrefix(file, maxBytes);
76
+ const fingerprint = statFingerprint(file);
77
+ const cached = prefixes.get(file);
78
+ if (cached?.fingerprint === fingerprint &&
79
+ (cached.prefix.length >= maxBytes || cached.prefix.length < MAX_PREFIX_BYTES)) {
80
+ prefixes.delete(file);
81
+ prefixes.set(file, cached);
82
+ return cached.prefix.subarray(0, maxBytes);
83
+ }
84
+ const contents = readTranscriptSync(file);
85
+ if (cached) {
86
+ prefixes.delete(file);
87
+ prefixBytes -= cached.prefix.length;
88
+ }
89
+ // Copy so the cache never pins a whole decoded rollout.
90
+ const prefix = Buffer.from(contents.subarray(0, MAX_PREFIX_BYTES));
91
+ prefixes.set(file, { fingerprint, prefix });
92
+ prefixBytes += prefix.length;
93
+ while (prefixBytes > PREFIX_CACHE_BYTES && prefixes.size > 1) {
94
+ const [oldest, entry] = prefixes.entries().next().value;
95
+ prefixes.delete(oldest);
96
+ prefixBytes -= entry.prefix.length;
97
+ }
98
+ return contents.subarray(0, maxBytes);
99
+ }
@@ -4,6 +4,7 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { DatabaseSync } from 'node:sqlite';
6
6
  import { StringDecoder } from 'node:string_decoder';
7
+ import { compressedTranscriptIsShadowed, existingTranscriptPath, isCompressedTranscript, logicalTranscriptPath, readTranscriptPrefixSync, } from "./compressed-transcripts.js";
7
8
  import { readDeepseekTrajectoryHeader } from "./deepseek-trajectories.js";
8
9
  import { lettaSessionIdentity } from "./harness-registry.js";
9
10
  import { readLettaSessionContext } from "./letta-session-context.js";
@@ -38,7 +39,8 @@ const EXTRA_FILE_STORES = {
38
39
  },
39
40
  codex: {
40
41
  root: path.join(process.env.CODEX_HOME || path.join(HOME, '.codex'), 'sessions'),
41
- include: (file) => /[\\/]rollout-[^\\/]+\.jsonl$/i.test(file),
42
+ // Codex zstd-compresses idle rollouts in place; see compressed-transcripts.ts.
43
+ include: (file) => /[\\/]rollout-[^\\/]+\.jsonl(?:\.zst)?$/i.test(file),
42
44
  },
43
45
  grok: { root: path.join(HOME, '.grok', 'sessions'), include: (file) => path.basename(file) === 'chat_history.jsonl' },
44
46
  kimi: {
@@ -281,6 +283,10 @@ function descriptorNativeSessionId(descriptor) {
281
283
  nativeSessionIdFromPath(String(descriptor?.harness || ''), descriptor?.sourcePath || descriptor?.file));
282
284
  }
283
285
  function fileDescriptor(harness, file) {
286
+ // While Codex restores or recompresses a rollout both spellings can exist
287
+ // briefly; the plain file is the one a live writer appends to.
288
+ if (compressedTranscriptIsShadowed(file))
289
+ return null;
284
290
  const stat = safeStat(file);
285
291
  if (!stat?.isFile() || stat.size === 0)
286
292
  return null;
@@ -288,7 +294,7 @@ function fileDescriptor(harness, file) {
288
294
  harness,
289
295
  agent: harness,
290
296
  file,
291
- sourcePath: displayPath(file),
297
+ sourcePath: displayPath(logicalTranscriptPath(file)),
292
298
  size: stat.size,
293
299
  mtimeMs: stat.mtimeMs,
294
300
  createdAt: (stat.birthtimeMs > 0 ? stat.birthtime : stat.mtime).toISOString(),
@@ -301,14 +307,16 @@ export function nativeDescriptorForSource(harness, sourcePath) {
301
307
  const store = Object.hasOwn(EXTRA_FILE_STORES, harness) ? EXTRA_FILE_STORES[harness] : null;
302
308
  if (!store || !sourcePath || sourcePath.includes('\0'))
303
309
  return null;
304
- const file = sourcePath.startsWith('~/') ? path.join(HOME, sourcePath.slice(2)) : sourcePath;
305
- if (!path.isAbsolute(file))
310
+ const logical = sourcePath.startsWith('~/') ? path.join(HOME, sourcePath.slice(2)) : sourcePath;
311
+ if (!path.isAbsolute(logical) || !store.include(logical))
306
312
  return null;
313
+ // A source path always names the plain transcript; follow it to the
314
+ // compressed copy when that is what exists on disk now.
315
+ const physical = existingTranscriptPath(logical);
316
+ const file = physical && store.include(physical) ? physical : logical;
307
317
  const relative = path.relative(store.root, file);
308
318
  if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
309
319
  return null;
310
- if (!store.include(file))
311
- return null;
312
320
  // Discovery does not traverse symlinked entries. Keep this direct lookup
313
321
  // from admitting a transcript that an inventory walk would never discover.
314
322
  try {
@@ -383,6 +391,17 @@ function firstJsonDocument(file) {
383
391
  const stat = safeStat(file);
384
392
  if (!stat?.isFile())
385
393
  return null;
394
+ if (isCompressedTranscript(file)) {
395
+ try {
396
+ const source = readTranscriptPrefixSync(file, 1024 * 1024)
397
+ .toString('utf8')
398
+ .replace(/^\uFEFF/u, '');
399
+ return JSON.parse(source.split(/\r?\n/u, 1)[0]);
400
+ }
401
+ catch {
402
+ return null;
403
+ }
404
+ }
386
405
  let descriptor;
387
406
  try {
388
407
  descriptor = fs.openSync(file, 'r');
@@ -896,6 +915,17 @@ function filePrefix(file, maxBytes) {
896
915
  }
897
916
  }
898
917
  function recordPrefix(file, limit, maxBytes) {
918
+ if (isCompressedTranscript(file)) {
919
+ try {
920
+ // Callers split and cap the record count themselves.
921
+ return readTranscriptPrefixSync(file, maxBytes)
922
+ .toString('utf8')
923
+ .replace(/^\uFEFF/u, '');
924
+ }
925
+ catch {
926
+ return '';
927
+ }
928
+ }
899
929
  let handle;
900
930
  try {
901
931
  const stat = fs.statSync(file);
@@ -9,6 +9,7 @@ import WebSocket from 'ws';
9
9
  import { logError, logInfo } from "./logging.js";
10
10
  import { NativeInventoryWorker } from "./native-inventory-worker.js";
11
11
  import { QueueDispatchLifecycle } from "./queue-lifecycle.js";
12
+ import { isCompressedTranscript, readTranscriptSync } from "./compressed-transcripts.js";
12
13
  import { streamDeepseekTrajectoryLines } from "./deepseek-trajectories.js";
13
14
  import { normalizeAllowedDirectories, workingDirectoryIsAllowed } from "./directory-policy.js";
14
15
  import { discoverGitAssociation } from "./git-association.js";
@@ -481,6 +482,22 @@ async function* completeNativeFileLines(file, startOffset, endOffset) {
481
482
  input.destroy();
482
483
  }
483
484
  }
485
+ /** Complete lines of a compressed rollout. Offsets are decoded-byte offsets,
486
+ * which never compare with the compressed file size, so these files always
487
+ * rescan from the start (see canResumeNativeCursor). */
488
+ function* completeDecodedLines(contents) {
489
+ let lineStart = 0;
490
+ for (;;) {
491
+ const newline = contents.indexOf(0x0a, lineStart);
492
+ if (newline < 0)
493
+ return;
494
+ let line = contents.subarray(lineStart, newline);
495
+ if (line.at(-1) === 0x0d)
496
+ line = line.subarray(0, -1);
497
+ yield { raw: line.toString('utf8'), endOffset: newline + 1 };
498
+ lineStart = newline + 1;
499
+ }
500
+ }
484
501
  async function* nativeSourceRecords(descriptor, cursor) {
485
502
  if (descriptor.harness === 'deepseek') {
486
503
  for await (const raw of streamDeepseekTrajectoryLines(descriptor.file)) {
@@ -502,6 +519,10 @@ async function* nativeSourceRecords(descriptor, cursor) {
502
519
  }
503
520
  return;
504
521
  }
522
+ if (isCompressedTranscript(descriptor.file)) {
523
+ yield* completeDecodedLines(readTranscriptSync(descriptor.file));
524
+ return;
525
+ }
505
526
  yield* completeNativeFileLines(descriptor.file, Number(cursor.offset) || 0, Math.max(-1, (Number(descriptor.size) || 0) - 1));
506
527
  }
507
528
  /** Stream canonical raw events while retaining only one WebSocket batch. The
@@ -962,6 +983,7 @@ function nativeSourceIdentity(file) {
962
983
  function canResumeNativeCursor(descriptor, cursor, nextRow) {
963
984
  return Boolean(cursor &&
964
985
  !descriptor.file.endsWith('.json') &&
986
+ !isCompressedTranscript(descriptor.file) &&
965
987
  descriptor.harness !== 'deepseek' &&
966
988
  !descriptor.sourcePath.includes('.db#') &&
967
989
  cursor.file === descriptor.file &&
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turns/dev",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "description": "Upload local AI agent sessions to turns.dev",
5
5
  "license": "MIT",
6
6
  "type": "module",