@makerbi/remodex 2.3.2 → 2.5.6

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,12 +4,17 @@
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,
10
14
  isContextualUserText,
11
15
  isUserRoleItem,
12
16
  responseItemMessageText: sharedResponseItemMessageText,
17
+ sanitizeUserRoleItem,
13
18
  visibleUserPromptText,
14
19
  } = require("./desktop-ipc-shared");
15
20
 
@@ -165,7 +170,7 @@ function readSessionJsonlMetadataFromFile(filePath, {
165
170
  metadataHeadBytes = DEFAULT_SESSION_JSONL_METADATA_HEAD_BYTES,
166
171
  } = {}) {
167
172
  if (!filePath) {
168
- return { threadId: "", cwd: "" };
173
+ return emptySessionJsonlMetadata();
169
174
  }
170
175
  if (!supportsBoundedSessionJsonlReads(fsModule)) {
171
176
  return parseSessionJsonlMetadata(fsModule.readFileSync(filePath, "utf8"));
@@ -174,7 +179,7 @@ function readSessionJsonlMetadataFromFile(filePath, {
174
179
  const stat = fsModule.statSync(filePath);
175
180
  const snapshotSize = Math.max(0, Number(stat?.size) || 0);
176
181
  if (snapshotSize === 0) {
177
- return { threadId: "", cwd: "" };
182
+ return emptySessionJsonlMetadata();
178
183
  }
179
184
  const fileHandle = fsModule.openSync(filePath, "r");
180
185
  try {
@@ -185,12 +190,23 @@ function readSessionJsonlMetadataFromFile(filePath, {
185
190
  fsModule
186
191
  );
187
192
  const metadata = parseSessionJsonlInitialMetadata(head.toString("utf8"));
188
- 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
+ };
189
199
  } finally {
190
200
  fsModule.closeSync(fileHandle);
191
201
  }
192
202
  }
193
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
+
194
210
  function supportsBoundedSessionJsonlReads(fsModule) {
195
211
  return typeof fsModule?.statSync === "function"
196
212
  && typeof fsModule?.openSync === "function"
@@ -286,6 +302,11 @@ function parseSessionJsonlInitialMetadata(content) {
286
302
  let threadId = "";
287
303
  let cwd = "";
288
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 = "";
289
310
  const raw = String(content || "");
290
311
  let lineStart = 0;
291
312
  while (lineStart < raw.length) {
@@ -313,18 +334,24 @@ function parseSessionJsonlInitialMetadata(content) {
313
334
  timeZone = normalizeString(payload?.timezone)
314
335
  || normalizeString(payload?.timeZone)
315
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);
316
341
  break;
317
342
  } catch {
318
343
  // Metadata is expected at the head; an incomplete oversized line is not trusted.
319
344
  }
320
345
  }
321
- return { threadId, cwd, timeZone };
346
+ return { threadId, cwd, timeZone, forkedFromId, threadSource };
322
347
  }
323
348
 
324
349
  // Extracts thread-level context that app-server history can omit for desktop-origin runs.
325
350
  function parseSessionJsonlMetadata(content) {
326
351
  let threadId = "";
327
352
  let cwd = "";
353
+ let forkedFromId = "";
354
+ let threadSource = "";
328
355
 
329
356
  const raw = String(content || "");
330
357
  let lineStart = 0;
@@ -357,13 +384,17 @@ function parseSessionJsonlMetadata(content) {
357
384
  cwd ||= normalizeString(payload?.cwd)
358
385
  || normalizeString(payload?.current_working_directory)
359
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);
360
391
 
361
392
  if (threadId && cwd) {
362
393
  break;
363
394
  }
364
395
  }
365
396
 
366
- return { threadId, cwd };
397
+ return { threadId, cwd, forkedFromId, threadSource };
367
398
  }
368
399
 
369
400
  function parseSessionJsonlTurns(content, {
@@ -558,36 +589,39 @@ function parseSessionJsonlTurns(content, {
558
589
  if (!payload) {
559
590
  continue;
560
591
  }
561
- rememberToolCallForHistory(payload, toolCallsByCallId);
562
- if (shouldSkipResponseItemForHistory(payload, skippedCallIds)) {
563
- continue;
564
- }
565
- const turn = ensureTurn(
566
- turns,
567
- turnsById,
568
- responseItemTurnId(payload) || activeTurnId || `turn-line-${sourceLineNumber}`,
569
- sessionThreadId,
570
- entry.timestamp
571
- );
572
- applyHistoryTimeZone(turn, sessionTimeZone);
573
- const item = normalizeResponseItemForHistory(payload, sourceLineNumber, {
574
- cwd: sessionCwd,
575
- toolCallsByCallId,
576
- });
577
- if (item) {
578
- if (shouldSkipDuplicateProposedPlanMessage(turn, item)) {
592
+ const projectedPayloads = expandExecWrapperToolCall(payload);
593
+ for (const projectedPayload of projectedPayloads) {
594
+ rememberToolCallForHistory(projectedPayload, toolCallsByCallId);
595
+ if (shouldSkipResponseItemForHistory(projectedPayload, skippedCallIds)) {
579
596
  continue;
580
597
  }
581
- const itemTimestamp = historyItemTimestamp(item, entry.timestamp);
582
- if (itemTimestamp && !item.createdAt) {
583
- item.createdAt = itemTimestamp;
584
- }
585
- if (itemTimestamp && !item.timestamp) {
586
- 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);
587
624
  }
588
- applyHistoryTimeZone(item, sessionTimeZone);
589
- applyHistoryAssistantSourceAlias(item, turn.id, assistantAliasOccurrencesByBaseKey);
590
- addHistoryItemToTurn(turn, item);
591
625
  }
592
626
  }
593
627
  }
@@ -862,7 +896,10 @@ function normalizeResponseItemForHistory(payload, lineNumber, { cwd = "", toolCa
862
896
  item.role = "assistant";
863
897
  }
864
898
 
865
- return item;
899
+ // A single user item can carry injected context next to the real request.
900
+ // Sanitize here so history readers (including the thread/read JSONL merge,
901
+ // which runs after the relay sanitizer) never rebuild the hidden fragments.
902
+ return isUserRoleItem(item) ? sanitizeUserRoleItem(item) : item;
866
903
  }
867
904
 
868
905
  function applyHistoryAssistantSourceAlias(item, turnId, occurrencesByBaseKey = new Map()) {
@@ -1306,6 +1343,13 @@ function shouldSkipResponseItemForHistory(payload, skippedCallIds) {
1306
1343
  return true;
1307
1344
  }
1308
1345
 
1346
+ if (type === "tool_call" && isOrchestrationWaitCall(payload)) {
1347
+ if (callId) {
1348
+ skippedCallIds.add(callId);
1349
+ }
1350
+ return true;
1351
+ }
1352
+
1309
1353
  if (type === "tool_call" && isSubagentOrchestrationCall(payload)) {
1310
1354
  if (callId) {
1311
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
+ };