@makerbi/remodex 2.4.0 → 3.1.0

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.
@@ -20,6 +20,13 @@ const DEFAULT_CONTEXT_READ_SCAN_BYTES = 512 * 1024;
20
20
  const DEFAULT_CONTEXT_READ_CANDIDATE_LIMIT = 128;
21
21
  const DEFAULT_RECENT_ROLLOUT_CANDIDATE_LIMIT = 24;
22
22
  const DEFAULT_RECENT_ROLLOUT_LOOKBACK_MS = 15 * 60 * 1000;
23
+ const ROLLOUT_CANDIDATE_CACHE_TTL_MS = 2_000;
24
+ const ROLLOUT_THREAD_POSITIVE_CACHE_TTL_MS = 2_000;
25
+ const ROLLOUT_THREAD_NEGATIVE_CACHE_TTL_MS = 1_500;
26
+ const ROLLOUT_LOOKUP_CACHE_MAX_ROOTS = 32;
27
+ const ROLLOUT_THREAD_MEMO_MAX_SIZE = 2_000;
28
+ const ROLLOUT_THREAD_CONTENT_CACHE_MAX_SIZE = 4_000;
29
+ const rolloutLookupCachesByFsModule = new WeakMap();
23
30
 
24
31
  // Polls one rollout file until it materializes and then reports size growth.
25
32
  function createThreadRolloutActivityWatcher({
@@ -350,12 +357,35 @@ function findRecentRolloutFileForContextRead(
350
357
  threadLookupScanBytes = DEFAULT_THREAD_LOOKUP_SCAN_BYTES,
351
358
  } = {}
352
359
  ) {
360
+ const currentTime = now();
361
+ const cache = rolloutLookupCacheForFsModule(fsModule);
362
+ const threadMemoKey = threadId && !turnId
363
+ ? rolloutThreadMemoKey(root, threadId)
364
+ : "";
365
+ if (threadMemoKey) {
366
+ const positive = cache.positiveThreadPaths.get(threadMemoKey);
367
+ if (positive) {
368
+ if (positive.expiresAt > currentTime && fsModule.existsSync(positive.filePath)) {
369
+ return positive.filePath;
370
+ }
371
+ cache.positiveThreadPaths.delete(threadMemoKey);
372
+ }
373
+
374
+ const negativeExpiresAt = cache.negativeThreadLookups.get(threadMemoKey) || 0;
375
+ if (negativeExpiresAt > currentTime) {
376
+ return null;
377
+ }
378
+ cache.negativeThreadLookups.delete(threadMemoKey);
379
+ }
380
+
353
381
  const candidates = collectRecentRolloutFiles(root, {
354
382
  fsModule,
355
383
  candidateLimit,
356
384
  modifiedAfterMs: 0,
385
+ now,
357
386
  });
358
387
  if (candidates.length === 0) {
388
+ rememberNegativeThreadLookup(cache, threadMemoKey, currentTime);
359
389
  return null;
360
390
  }
361
391
 
@@ -373,8 +403,10 @@ function findRecentRolloutFileForContextRead(
373
403
  if (threadId) {
374
404
  const threadScopedRollout = findPreferredRolloutFileForThread(root, candidates, threadId, {
375
405
  fsModule,
406
+ now,
376
407
  });
377
408
  if (threadScopedRollout) {
409
+ rememberPositiveThreadPath(cache, threadMemoKey, threadScopedRollout, currentTime);
378
410
  return threadScopedRollout;
379
411
  }
380
412
 
@@ -383,23 +415,33 @@ function findRecentRolloutFileForContextRead(
383
415
  fsModule,
384
416
  scanBytes: threadLookupScanBytes,
385
417
  })) {
418
+ rememberPositiveThreadPath(cache, threadMemoKey, candidate.filePath, currentTime);
386
419
  return candidate.filePath;
387
420
  }
388
421
  }
389
422
  }
390
423
 
424
+ rememberNegativeThreadLookup(cache, threadMemoKey, currentTime);
391
425
  return null;
392
426
  }
393
427
 
394
428
  // Keeps the fast "recent files first" path, but falls back to a full-tree scan
395
429
  // so older valid thread rollouts still recover after many newer sessions exist.
396
- function findPreferredRolloutFileForThread(root, candidates, threadId, { fsModule = fs } = {}) {
430
+ function findPreferredRolloutFileForThread(
431
+ root,
432
+ candidates,
433
+ threadId,
434
+ {
435
+ fsModule = fs,
436
+ now = () => Date.now(),
437
+ } = {}
438
+ ) {
397
439
  const recentMatch = findMostRecentRolloutFileForThread(candidates, threadId);
398
440
  if (recentMatch) {
399
441
  return recentMatch;
400
442
  }
401
443
 
402
- return findNewestRolloutFileForThread(root, threadId, { fsModule });
444
+ return findNewestRolloutFileForThread(root, threadId, { fsModule, now });
403
445
  }
404
446
 
405
447
  // Prefers the newest filename-scoped rollout for a thread instead of the first
@@ -413,45 +455,29 @@ function findMostRecentRolloutFileForThread(candidates, threadId) {
413
455
  return match?.filePath || null;
414
456
  }
415
457
 
416
- // Scans the whole sessions tree only when the recent candidate window missed the
458
+ // Uses the complete sorted candidate set when the recent slice missed the
417
459
  // thread, still preferring the newest matching rollout instead of the first hit.
418
- function findNewestRolloutFileForThread(root, threadId, { fsModule = fs } = {}) {
419
- if (!threadId || !fsModule.existsSync(root)) {
460
+ function findNewestRolloutFileForThread(
461
+ root,
462
+ threadId,
463
+ {
464
+ fsModule = fs,
465
+ now = () => Date.now(),
466
+ } = {}
467
+ ) {
468
+ if (!threadId) {
420
469
  return null;
421
470
  }
422
471
 
423
- const stack = [root];
424
- let newestMatch = null;
425
-
426
- while (stack.length > 0) {
427
- const current = stack.pop();
428
- const entries = fsModule.readdirSync(current, { withFileTypes: true });
429
-
430
- for (const entry of entries) {
431
- const fullPath = path.join(current, entry.name);
432
- if (entry.isDirectory()) {
433
- stack.push(fullPath);
434
- continue;
435
- }
436
-
437
- if (!entry.isFile()
438
- || !entry.name.startsWith("rollout-")
439
- || !entry.name.endsWith(".jsonl")
440
- || !entry.name.includes(threadId)) {
441
- continue;
442
- }
443
-
444
- const candidate = {
445
- filePath: fullPath,
446
- mtimeMs: fsModule.statSync(fullPath).mtimeMs,
447
- };
448
- if (!newestMatch || compareRolloutFileOrder(candidate, newestMatch) < 0) {
449
- newestMatch = candidate;
450
- }
451
- }
452
- }
453
-
454
- return newestMatch?.filePath || null;
472
+ return findMostRecentRolloutFileForThread(
473
+ collectRecentRolloutFiles(root, {
474
+ fsModule,
475
+ candidateLimit: Number.POSITIVE_INFINITY,
476
+ modifiedAfterMs: 0,
477
+ now,
478
+ }),
479
+ threadId
480
+ );
455
481
  }
456
482
 
457
483
  function collectRecentRolloutFiles(
@@ -460,12 +486,33 @@ function collectRecentRolloutFiles(
460
486
  fsModule = fs,
461
487
  candidateLimit = DEFAULT_RECENT_ROLLOUT_CANDIDATE_LIMIT,
462
488
  modifiedAfterMs = 0,
489
+ now = () => Date.now(),
463
490
  } = {}
464
491
  ) {
492
+ const cache = rolloutLookupCacheForFsModule(fsModule);
493
+ const currentTime = now();
494
+ const cached = cache.candidatesByRoot.get(root);
495
+ let candidates = cached?.candidates;
496
+ if (!cached || currentTime - cached.createdAt >= ROLLOUT_CANDIDATE_CACHE_TTL_MS) {
497
+ candidates = scanRolloutFiles(root, fsModule);
498
+ cache.candidatesByRoot.delete(root);
499
+ cache.candidatesByRoot.set(root, {
500
+ candidates,
501
+ createdAt: currentTime,
502
+ });
503
+ evictOldestCacheEntries(cache.candidatesByRoot, ROLLOUT_LOOKUP_CACHE_MAX_ROOTS);
504
+ }
505
+
506
+ const filtered = modifiedAfterMs > 0
507
+ ? candidates.filter(({ mtimeMs }) => mtimeMs >= modifiedAfterMs)
508
+ : candidates;
509
+ return filtered.slice(0, candidateLimit);
510
+ }
511
+
512
+ function scanRolloutFiles(root, fsModule) {
465
513
  if (!fsModule.existsSync(root)) {
466
514
  return [];
467
515
  }
468
-
469
516
  const stack = [root];
470
517
  const candidates = [];
471
518
 
@@ -487,10 +534,6 @@ function collectRecentRolloutFiles(
487
534
  }
488
535
 
489
536
  const stat = fsModule.statSync(fullPath);
490
- if (modifiedAfterMs > 0 && stat.mtimeMs < modifiedAfterMs) {
491
- continue;
492
- }
493
-
494
537
  candidates.push({
495
538
  filePath: fullPath,
496
539
  mtimeMs: stat.mtimeMs,
@@ -503,17 +546,7 @@ function collectRecentRolloutFiles(
503
546
  || path.basename(rhs.filePath).localeCompare(path.basename(lhs.filePath))
504
547
  || rhs.filePath.localeCompare(lhs.filePath)
505
548
  );
506
- return candidates.slice(0, candidateLimit);
507
- }
508
-
509
- // Keeps rollout selection deterministic when filesystem timestamp resolution
510
- // reports equal mtimes for several rollout candidates.
511
- function compareRolloutFileOrder(lhs, rhs) {
512
- if (lhs.mtimeMs !== rhs.mtimeMs) {
513
- return rhs.mtimeMs - lhs.mtimeMs;
514
- }
515
-
516
- return rhs.filePath.localeCompare(lhs.filePath);
549
+ return candidates;
517
550
  }
518
551
 
519
552
  function rolloutFileContainsTurnId(
@@ -555,6 +588,11 @@ function rolloutFileContainsThreadId(
555
588
  }
556
589
 
557
590
  const stat = fsModule.statSync(filePath);
591
+ const cache = rolloutLookupCacheForFsModule(fsModule);
592
+ const cacheKey = `${filePath}\0${threadId}\0${stat.size}\0${scanBytes}`;
593
+ if (cache.threadContentMatches.has(cacheKey)) {
594
+ return cache.threadContentMatches.get(cacheKey);
595
+ }
558
596
  const chunk = readFileSlice(
559
597
  filePath,
560
598
  Math.max(0, stat.size - Math.min(stat.size, scanBytes)),
@@ -562,15 +600,98 @@ function rolloutFileContainsThreadId(
562
600
  fsModule
563
601
  );
564
602
  if (!chunk) {
603
+ rememberThreadContentMatch(cache, cacheKey, false);
565
604
  return false;
566
605
  }
567
606
 
568
- return (
607
+ const matches = (
569
608
  chunk.includes(`"thread_id":"${threadId}"`)
570
609
  || chunk.includes(`"threadId":"${threadId}"`)
571
610
  || chunk.includes(`"conversation_id":"${threadId}"`)
572
611
  || chunk.includes(`"conversationId":"${threadId}"`)
573
612
  );
613
+ rememberThreadContentMatch(cache, cacheKey, matches);
614
+ return matches;
615
+ }
616
+
617
+ function rolloutLookupCacheForFsModule(fsModule) {
618
+ let cache = rolloutLookupCachesByFsModule.get(fsModule);
619
+ if (!cache) {
620
+ cache = {
621
+ candidatesByRoot: new Map(),
622
+ positiveThreadPaths: new Map(),
623
+ negativeThreadLookups: new Map(),
624
+ threadContentMatches: new Map(),
625
+ };
626
+ rolloutLookupCachesByFsModule.set(fsModule, cache);
627
+ }
628
+ return cache;
629
+ }
630
+
631
+ function rolloutThreadMemoKey(root, threadId) {
632
+ return `${root}\0${threadId}`;
633
+ }
634
+
635
+ function rememberPositiveThreadPath(cache, memoKey, filePath, currentTime) {
636
+ if (!memoKey) {
637
+ return;
638
+ }
639
+ cache.negativeThreadLookups.delete(memoKey);
640
+ cache.positiveThreadPaths.delete(memoKey);
641
+ cache.positiveThreadPaths.set(memoKey, {
642
+ filePath,
643
+ expiresAt: currentTime + ROLLOUT_THREAD_POSITIVE_CACHE_TTL_MS,
644
+ });
645
+ evictOldestCacheEntries(cache.positiveThreadPaths, ROLLOUT_THREAD_MEMO_MAX_SIZE);
646
+ }
647
+
648
+ function rememberNegativeThreadLookup(cache, memoKey, currentTime) {
649
+ if (!memoKey) {
650
+ return;
651
+ }
652
+ cache.negativeThreadLookups.delete(memoKey);
653
+ cache.negativeThreadLookups.set(
654
+ memoKey,
655
+ currentTime + ROLLOUT_THREAD_NEGATIVE_CACHE_TTL_MS
656
+ );
657
+ evictOldestCacheEntries(cache.negativeThreadLookups, ROLLOUT_THREAD_MEMO_MAX_SIZE);
658
+ }
659
+
660
+ function rememberThreadContentMatch(cache, cacheKey, matches) {
661
+ cache.threadContentMatches.set(cacheKey, matches);
662
+ evictOldestCacheEntries(
663
+ cache.threadContentMatches,
664
+ ROLLOUT_THREAD_CONTENT_CACHE_MAX_SIZE
665
+ );
666
+ }
667
+
668
+ function evictOldestCacheEntries(cache, maxSize) {
669
+ while (cache.size > maxSize) {
670
+ cache.delete(cache.keys().next().value);
671
+ }
672
+ }
673
+
674
+ function invalidateRolloutLookupCache({
675
+ root = "",
676
+ threadId = "",
677
+ fsModule = fs,
678
+ } = {}) {
679
+ const cache = rolloutLookupCachesByFsModule.get(fsModule);
680
+ if (!cache) {
681
+ return;
682
+ }
683
+ if (!root) {
684
+ rolloutLookupCachesByFsModule.delete(fsModule);
685
+ return;
686
+ }
687
+
688
+ cache.candidatesByRoot.delete(root);
689
+ if (!threadId) {
690
+ return;
691
+ }
692
+ const memoKey = rolloutThreadMemoKey(root, threadId);
693
+ cache.positiveThreadPaths.delete(memoKey);
694
+ cache.negativeThreadLookups.delete(memoKey);
574
695
  }
575
696
 
576
697
  function formatRolloutLine(rawLine) {
@@ -845,4 +966,5 @@ module.exports = {
845
966
  resolveSessionsRoot,
846
967
  findRolloutFileForThread,
847
968
  findRecentRolloutFileForContextRead,
969
+ invalidateRolloutLookupCache,
848
970
  };
@@ -4,6 +4,10 @@
4
4
 
5
5
  const fs = require("fs");
6
6
  const { buildApplyPatchFileChangeItem } = require("./apply-patch-changes");
7
+ const {
8
+ expandExecWrapperToolCall,
9
+ isOrchestrationWaitCall,
10
+ } = require("./codex-tool-wrapper");
7
11
  const { terminalEventClosesTrackedTurn } = require("./rollout-turn-semantics");
8
12
  const {
9
13
  buildRemodexSourceItemKey,
@@ -166,7 +170,7 @@ function readSessionJsonlMetadataFromFile(filePath, {
166
170
  metadataHeadBytes = DEFAULT_SESSION_JSONL_METADATA_HEAD_BYTES,
167
171
  } = {}) {
168
172
  if (!filePath) {
169
- return { threadId: "", cwd: "" };
173
+ return emptySessionJsonlMetadata();
170
174
  }
171
175
  if (!supportsBoundedSessionJsonlReads(fsModule)) {
172
176
  return parseSessionJsonlMetadata(fsModule.readFileSync(filePath, "utf8"));
@@ -175,7 +179,7 @@ function readSessionJsonlMetadataFromFile(filePath, {
175
179
  const stat = fsModule.statSync(filePath);
176
180
  const snapshotSize = Math.max(0, Number(stat?.size) || 0);
177
181
  if (snapshotSize === 0) {
178
- return { threadId: "", cwd: "" };
182
+ return emptySessionJsonlMetadata();
179
183
  }
180
184
  const fileHandle = fsModule.openSync(filePath, "r");
181
185
  try {
@@ -186,12 +190,23 @@ function readSessionJsonlMetadataFromFile(filePath, {
186
190
  fsModule
187
191
  );
188
192
  const metadata = parseSessionJsonlInitialMetadata(head.toString("utf8"));
189
- return { threadId: metadata.threadId, cwd: metadata.cwd };
193
+ return {
194
+ threadId: metadata.threadId,
195
+ cwd: metadata.cwd,
196
+ forkedFromId: metadata.forkedFromId,
197
+ threadSource: metadata.threadSource,
198
+ };
190
199
  } finally {
191
200
  fsModule.closeSync(fileHandle);
192
201
  }
193
202
  }
194
203
 
204
+ // Every exit path of readSessionJsonlMetadataFromFile returns this shape so callers
205
+ // can read provenance without knowing which branch produced the result.
206
+ function emptySessionJsonlMetadata() {
207
+ return { threadId: "", cwd: "", forkedFromId: "", threadSource: "" };
208
+ }
209
+
195
210
  function supportsBoundedSessionJsonlReads(fsModule) {
196
211
  return typeof fsModule?.statSync === "function"
197
212
  && typeof fsModule?.openSync === "function"
@@ -287,6 +302,11 @@ function parseSessionJsonlInitialMetadata(content) {
287
302
  let threadId = "";
288
303
  let cwd = "";
289
304
  let timeZone = "";
305
+ // Provenance app-server keeps in the rollout but leaves null on thread/list
306
+ // rows: without it a forked automation thread is indistinguishable from the
307
+ // origin it copied its name and preview from.
308
+ let forkedFromId = "";
309
+ let threadSource = "";
290
310
  const raw = String(content || "");
291
311
  let lineStart = 0;
292
312
  while (lineStart < raw.length) {
@@ -314,18 +334,24 @@ function parseSessionJsonlInitialMetadata(content) {
314
334
  timeZone = normalizeString(payload?.timezone)
315
335
  || normalizeString(payload?.timeZone)
316
336
  || normalizeString(payload?.time_zone);
337
+ forkedFromId = normalizeString(payload?.forked_from_id)
338
+ || normalizeString(payload?.forkedFromId);
339
+ threadSource = normalizeString(payload?.thread_source)
340
+ || normalizeString(payload?.threadSource);
317
341
  break;
318
342
  } catch {
319
343
  // Metadata is expected at the head; an incomplete oversized line is not trusted.
320
344
  }
321
345
  }
322
- return { threadId, cwd, timeZone };
346
+ return { threadId, cwd, timeZone, forkedFromId, threadSource };
323
347
  }
324
348
 
325
349
  // Extracts thread-level context that app-server history can omit for desktop-origin runs.
326
350
  function parseSessionJsonlMetadata(content) {
327
351
  let threadId = "";
328
352
  let cwd = "";
353
+ let forkedFromId = "";
354
+ let threadSource = "";
329
355
 
330
356
  const raw = String(content || "");
331
357
  let lineStart = 0;
@@ -358,13 +384,17 @@ function parseSessionJsonlMetadata(content) {
358
384
  cwd ||= normalizeString(payload?.cwd)
359
385
  || normalizeString(payload?.current_working_directory)
360
386
  || normalizeString(payload?.working_directory);
387
+ forkedFromId ||= normalizeString(payload?.forked_from_id)
388
+ || normalizeString(payload?.forkedFromId);
389
+ threadSource ||= normalizeString(payload?.thread_source)
390
+ || normalizeString(payload?.threadSource);
361
391
 
362
392
  if (threadId && cwd) {
363
393
  break;
364
394
  }
365
395
  }
366
396
 
367
- return { threadId, cwd };
397
+ return { threadId, cwd, forkedFromId, threadSource };
368
398
  }
369
399
 
370
400
  function parseSessionJsonlTurns(content, {
@@ -559,36 +589,39 @@ function parseSessionJsonlTurns(content, {
559
589
  if (!payload) {
560
590
  continue;
561
591
  }
562
- rememberToolCallForHistory(payload, toolCallsByCallId);
563
- if (shouldSkipResponseItemForHistory(payload, skippedCallIds)) {
564
- continue;
565
- }
566
- const turn = ensureTurn(
567
- turns,
568
- turnsById,
569
- responseItemTurnId(payload) || activeTurnId || `turn-line-${sourceLineNumber}`,
570
- sessionThreadId,
571
- entry.timestamp
572
- );
573
- applyHistoryTimeZone(turn, sessionTimeZone);
574
- const item = normalizeResponseItemForHistory(payload, sourceLineNumber, {
575
- cwd: sessionCwd,
576
- toolCallsByCallId,
577
- });
578
- if (item) {
579
- if (shouldSkipDuplicateProposedPlanMessage(turn, item)) {
592
+ const projectedPayloads = expandExecWrapperToolCall(payload);
593
+ for (const projectedPayload of projectedPayloads) {
594
+ rememberToolCallForHistory(projectedPayload, toolCallsByCallId);
595
+ if (shouldSkipResponseItemForHistory(projectedPayload, skippedCallIds)) {
580
596
  continue;
581
597
  }
582
- const itemTimestamp = historyItemTimestamp(item, entry.timestamp);
583
- if (itemTimestamp && !item.createdAt) {
584
- item.createdAt = itemTimestamp;
585
- }
586
- if (itemTimestamp && !item.timestamp) {
587
- item.timestamp = itemTimestamp;
598
+ const turn = ensureTurn(
599
+ turns,
600
+ turnsById,
601
+ responseItemTurnId(projectedPayload) || activeTurnId || `turn-line-${sourceLineNumber}`,
602
+ sessionThreadId,
603
+ entry.timestamp
604
+ );
605
+ applyHistoryTimeZone(turn, sessionTimeZone);
606
+ const item = normalizeResponseItemForHistory(projectedPayload, sourceLineNumber, {
607
+ cwd: sessionCwd,
608
+ toolCallsByCallId,
609
+ });
610
+ if (item) {
611
+ if (shouldSkipDuplicateProposedPlanMessage(turn, item)) {
612
+ continue;
613
+ }
614
+ const itemTimestamp = historyItemTimestamp(item, entry.timestamp);
615
+ if (itemTimestamp && !item.createdAt) {
616
+ item.createdAt = itemTimestamp;
617
+ }
618
+ if (itemTimestamp && !item.timestamp) {
619
+ item.timestamp = itemTimestamp;
620
+ }
621
+ applyHistoryTimeZone(item, sessionTimeZone);
622
+ applyHistoryAssistantSourceAlias(item, turn.id, assistantAliasOccurrencesByBaseKey);
623
+ addHistoryItemToTurn(turn, item);
588
624
  }
589
- applyHistoryTimeZone(item, sessionTimeZone);
590
- applyHistoryAssistantSourceAlias(item, turn.id, assistantAliasOccurrencesByBaseKey);
591
- addHistoryItemToTurn(turn, item);
592
625
  }
593
626
  }
594
627
  }
@@ -1310,6 +1343,13 @@ function shouldSkipResponseItemForHistory(payload, skippedCallIds) {
1310
1343
  return true;
1311
1344
  }
1312
1345
 
1346
+ if (type === "tool_call" && isOrchestrationWaitCall(payload)) {
1347
+ if (callId) {
1348
+ skippedCallIds.add(callId);
1349
+ }
1350
+ return true;
1351
+ }
1352
+
1313
1353
  if (type === "tool_call" && isSubagentOrchestrationCall(payload)) {
1314
1354
  if (callId) {
1315
1355
  skippedCallIds.add(callId);
@@ -0,0 +1,105 @@
1
+ // FILE: thread-list-provenance.js
2
+ // Purpose: Restores fork/automation provenance that app-server drops from thread list rows.
3
+ // Layer: CLI helper
4
+ // Exports: createThreadListProvenanceEnricher
5
+ // Depends on: fs, ./session-jsonl-history, ./thread-row-enrichment
6
+
7
+ const fs = require("fs");
8
+ const { readSessionJsonlMetadataFromFile } = require("./session-jsonl-history");
9
+ const { forEachThreadRowInResponse } = require("./thread-row-enrichment");
10
+
11
+ // An entry is two short strings, so the cache stays far cheaper than re-reading
12
+ // rollout heads. Sized well above any single thread/list page: a full unpaginated
13
+ // list must not evict rows it is still walking, or every refresh re-reads the disk.
14
+ const DEFAULT_MAX_ENTRIES = 4096;
15
+
16
+ // app-server returns `forkedFromId: null` / `threadSource: null` on thread rows even
17
+ // when the rollout's session_meta carries both. A thread forked by a desktop automation
18
+ // copies the origin's name and preview, so without this the phone shows two identical
19
+ // sidebar rows with no way to tell which is which. The rollout row already carries the
20
+ // file path, and session_meta is written once at session start, so a bounded cache keyed
21
+ // by thread id keeps this to one small head read per thread ever seen.
22
+ function createThreadListProvenanceEnricher({
23
+ fsModule = fs,
24
+ maxEntries = DEFAULT_MAX_ENTRIES,
25
+ } = {}) {
26
+ const cache = new Map();
27
+
28
+ function readProvenance(threadId, filePath) {
29
+ if (cache.has(threadId)) {
30
+ const cached = cache.get(threadId);
31
+ // Refresh insertion order so the Map behaves as an LRU.
32
+ cache.delete(threadId);
33
+ cache.set(threadId, cached);
34
+ return cached;
35
+ }
36
+
37
+ let provenance = { forkedFromId: "", threadSource: "" };
38
+ let readSessionMeta = false;
39
+ try {
40
+ const metadata = readSessionJsonlMetadataFromFile(filePath, { fsModule });
41
+ // The thread id only appears once session_meta is on disk; without it the read
42
+ // hit a rotated, unreadable, or still-being-written rollout.
43
+ readSessionMeta = Boolean(normalizeString(metadata?.threadId));
44
+ provenance = {
45
+ forkedFromId: normalizeString(metadata?.forkedFromId),
46
+ threadSource: normalizeString(metadata?.threadSource),
47
+ };
48
+ } catch {
49
+ // A rotated, half-written, or unreadable rollout simply leaves the row as-is.
50
+ }
51
+
52
+ // Only a real session_meta is worth remembering: it never changes again. Caching a
53
+ // failed read instead would strand a freshly forked thread without its badge until
54
+ // the bridge restarts, so those are retried on the next list.
55
+ if (!readSessionMeta) {
56
+ return provenance;
57
+ }
58
+
59
+ cache.set(threadId, provenance);
60
+ while (cache.size > Math.max(1, maxEntries)) {
61
+ cache.delete(cache.keys().next().value);
62
+ }
63
+ return provenance;
64
+ }
65
+
66
+ function attachToThread(thread) {
67
+ const threadId = normalizeString(thread?.id) || normalizeString(thread?.threadId);
68
+ const filePath = normalizeString(thread?.path) || normalizeString(thread?.rolloutPath);
69
+ if (!thread || typeof thread !== "object" || !threadId || !filePath) {
70
+ return thread;
71
+ }
72
+ // Never overwrite what app-server did resolve; this only fills the gaps.
73
+ if (normalizeString(thread.forkedFromId) && normalizeString(thread.threadSource)) {
74
+ return thread;
75
+ }
76
+
77
+ const provenance = readProvenance(threadId, filePath);
78
+ if (!normalizeString(thread.forkedFromId) && provenance.forkedFromId) {
79
+ thread.forkedFromId = provenance.forkedFromId;
80
+ }
81
+ if (!normalizeString(thread.threadSource) && provenance.threadSource) {
82
+ thread.threadSource = provenance.threadSource;
83
+ }
84
+ return thread;
85
+ }
86
+
87
+ function enrichResponse(method, envelope) {
88
+ return forEachThreadRowInResponse(method, envelope, attachToThread);
89
+ }
90
+
91
+ return {
92
+ attachToThread,
93
+ enrichResponse,
94
+ // Exposed for focused tests so cache growth stays provable.
95
+ cacheSize: () => cache.size,
96
+ };
97
+ }
98
+
99
+ function normalizeString(value) {
100
+ return typeof value === "string" ? value.trim() : "";
101
+ }
102
+
103
+ module.exports = {
104
+ createThreadListProvenanceEnricher,
105
+ };
@@ -0,0 +1,43 @@
1
+ // FILE: thread-row-enrichment.js
2
+ // Purpose: One walk over the thread rows carried by thread/list, thread/read, and
3
+ // thread/resume responses, so every enricher shares the same shape handling
4
+ // and a response is traversed once no matter how many enrichers run on it.
5
+ // Layer: CLI helper
6
+ // Exports: forEachThreadRowInResponse
7
+ // Depends on: (none)
8
+
9
+ const THREAD_LIST_ROW_KEYS = ["data", "items", "threads"];
10
+
11
+ function forEachThreadRowInResponse(method, envelope, visit) {
12
+ if (typeof visit !== "function") {
13
+ return envelope;
14
+ }
15
+ if (!envelope || typeof envelope !== "object" || envelope.error) {
16
+ return envelope;
17
+ }
18
+ const result = envelope.result;
19
+ if (!result || typeof result !== "object") {
20
+ return envelope;
21
+ }
22
+
23
+ if (method === "thread/read" || method === "thread/resume") {
24
+ if (result.thread && typeof result.thread === "object") {
25
+ visit(result.thread);
26
+ }
27
+ return envelope;
28
+ }
29
+
30
+ if (method === "thread/list") {
31
+ const key = THREAD_LIST_ROW_KEYS.find((candidate) => Array.isArray(result[candidate]));
32
+ for (const thread of key ? result[key] : []) {
33
+ if (thread && typeof thread === "object") {
34
+ visit(thread);
35
+ }
36
+ }
37
+ }
38
+ return envelope;
39
+ }
40
+
41
+ module.exports = {
42
+ forEachThreadRowInResponse,
43
+ };