@myagentroam/agent 0.10.60 → 0.10.61

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.
@@ -11,8 +11,8 @@ import { contextGcBaseFromRecords, contextGcBaseSequence, contextGcMinimumUsageG
11
11
  import { bindToolResultResources, cleanupUnreferencedToolResources, completeToolResultResources, copyToolResultResources, readToolResultResource, toolResultResourceFiles, writeToolResultResource } from '../tool-result-resources.js';
12
12
  import { beginSessionStorageTransaction, isUnjournaledTransactionFile, recoverSessionStorageTransaction } from '../transaction-recovery.js';
13
13
  import { extensionCheckpointFromPayload, indexExtensionCheckpoint, restoreExtensionCheckpoint, updateSessionCurrentState } from './current-state.js';
14
- import { decodeSessionListCursor, encodeSessionListCursor, mergeFirstUserMessagePreviewCache, normalizeSessionSource, persistSessionIndex, readSessionIndex, sessionListFilter, sessionMatchesFilter, sessionTreeIds } from './directory-index.js';
15
- import { deriveExecutionState, executionModelFromRecord, isCompactCheckpoint, sameResumeIndex, userMessagePreviewFromRecord } from './execution-state.js';
14
+ import { decodeSessionListCursor, encodeSessionListCursor, mergeSessionDerivedCache, normalizeSessionSource, persistSessionIndex, readSessionIndex, sessionListFilter, sessionMatchesFilter, sessionTreeIds } from './directory-index.js';
15
+ import { deriveExecutionState, executionModelFromRecord, isCompactCheckpoint, isNativeUserTurn, sameResumeIndex, userMessagePreviewFromRecord } from './execution-state.js';
16
16
  import { forkedPayload, renameIfExists, restoreRenamedFiles, retainedSessionTitle, stageSessionSegments, stageUnreferencedImages } from './history.js';
17
17
  import { MAX_INPUT_IMAGES, copyGeneratedImages, copyInputImages, generatedImageFileNames, inputImageReference, inputImageReferences, inputImageReferencesFromRecords, readInputImageBytes, recoverPendingInputImages, validInputImageFileName, writeInputImages } from './images.js';
18
18
  import { MAX_SEGMENT_BYTES, appendRecordBytes, readRecordPageFromTail, readRecords, readRecordsFromHead, readRecordsFromTail, repairSegmentTail, segmentNumberFromPath } from './records.js';
@@ -158,7 +158,7 @@ export class JsonlSessionStore {
158
158
  closed: false,
159
159
  indexDirty: false,
160
160
  dirtySessionVersions: new Map(),
161
- previewOnlyDirtySessions: new Set(),
161
+ derivedOnlyDirtySessions: new Set(),
162
162
  pendingPublishIds: new Set(),
163
163
  codeStateWrites: new Map(),
164
164
  recordWrites: new Map(),
@@ -185,15 +185,15 @@ export class JsonlSessionStore {
185
185
  if (!disk)
186
186
  throw new MarAgentError('MAR_AGENT_SESSION_CORRUPT', 'Session index is missing.');
187
187
  const dirty = new Map(this.#shared.dirtySessionVersions);
188
- const previewOnly = new Set(this.#shared.previewOnlyDirtySessions);
188
+ const derivedOnly = new Set(this.#shared.derivedOnlyDirtySessions);
189
189
  const sessions = new Map(disk.sessions.map((session) => [session.id, session]));
190
190
  const created = new Set(createdIds);
191
191
  for (const session of this.#shared.index.sessions) {
192
192
  const current = sessions.get(session.id);
193
193
  // Disk membership is authoritative: a stale in-memory snapshot cannot revive a deletion.
194
194
  if (created.has(session.id) || (current && dirty.has(session.id)))
195
- sessions.set(session.id, current && previewOnly.has(session.id)
196
- ? mergeFirstUserMessagePreviewCache(current, session)
195
+ sessions.set(session.id, current && derivedOnly.has(session.id)
196
+ ? mergeSessionDerivedCache(current, session)
197
197
  : structuredClone(session));
198
198
  }
199
199
  for (const deletedId of typeof deletedIds === 'string' ? [deletedIds] : (deletedIds ?? []))
@@ -202,7 +202,7 @@ export class JsonlSessionStore {
202
202
  version: 1,
203
203
  revision: createdIds.length > 0 ||
204
204
  deletedIds !== undefined ||
205
- [...dirty.keys()].some((id) => !previewOnly.has(id))
205
+ [...dirty.keys()].some((id) => !derivedOnly.has(id))
206
206
  ? Math.max(disk.revision, this.#shared.index.revision) + 1
207
207
  : disk.revision,
208
208
  sessions: [...sessions.values()]
@@ -214,7 +214,7 @@ export class JsonlSessionStore {
214
214
  continue;
215
215
  if (this.#shared.dirtySessionVersions.get(id) === version) {
216
216
  this.#shared.dirtySessionVersions.delete(id);
217
- this.#shared.previewOnlyDirtySessions.delete(id);
217
+ this.#shared.derivedOnlyDirtySessions.delete(id);
218
218
  }
219
219
  }
220
220
  for (const id of created)
@@ -257,8 +257,8 @@ export class JsonlSessionStore {
257
257
  const current = local.get(session.id);
258
258
  if (!current || !this.#shared.dirtySessionVersions.has(session.id))
259
259
  return session;
260
- return this.#shared.previewOnlyDirtySessions.has(session.id)
261
- ? mergeFirstUserMessagePreviewCache(session, current)
260
+ return this.#shared.derivedOnlyDirtySessions.has(session.id)
261
+ ? mergeSessionDerivedCache(session, current)
262
262
  : current;
263
263
  })
264
264
  .concat(this.#shared.index.sessions.filter((session) => this.#shared.pendingPublishIds.has(session.id) && !published.has(session.id)))
@@ -299,6 +299,7 @@ export class JsonlSessionStore {
299
299
  automaticTitlePending: input.title === undefined,
300
300
  resume: {
301
301
  version: 1,
302
+ userTurns: 0,
302
303
  todoRevision: 0,
303
304
  executionModelIndexed: true,
304
305
  firstUserMessageIndexed: true
@@ -366,7 +367,7 @@ export class JsonlSessionStore {
366
367
  }
367
368
  }
368
369
  }
369
- sessionDirectory(sessionId) {
370
+ #sessionDirectory(sessionId) {
370
371
  this.#assertOpen();
371
372
  if (!this.#shared.index.sessions.some((session) => session.id === sessionId) &&
372
373
  !this.#shared.pendingSessions.has(sessionId))
@@ -374,7 +375,7 @@ export class JsonlSessionStore {
374
375
  return join(this.#shared.root, 'sessions', sessionId);
375
376
  }
376
377
  async readContextualWorldState(sessionId) {
377
- const path = join(this.sessionDirectory(sessionId), CONTEXTUAL_WORLD_STATE_FILE);
378
+ const path = join(this.#sessionDirectory(sessionId), CONTEXTUAL_WORLD_STATE_FILE);
378
379
  try {
379
380
  const pathInfo = await lstat(path);
380
381
  if (!pathInfo.isFile())
@@ -403,7 +404,7 @@ export class JsonlSessionStore {
403
404
  }
404
405
  }
405
406
  async writeContextualWorldState(sessionId, state) {
406
- const directory = this.sessionDirectory(sessionId);
407
+ const directory = this.#sessionDirectory(sessionId);
407
408
  const meta = this.#shared.index.sessions.find((item) => item.id === sessionId);
408
409
  if (!meta)
409
410
  throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
@@ -431,21 +432,21 @@ export class JsonlSessionStore {
431
432
  return [];
432
433
  if (images.length > MAX_INPUT_IMAGES)
433
434
  throw new MarAgentError('MAR_AGENT_HOST_LIMIT_EXCEEDED', 'Too many image inputs were provided.');
434
- const directory = join(this.sessionDirectory(sessionId), 'input_images');
435
+ const directory = join(this.#sessionDirectory(sessionId), 'input_images');
435
436
  return writeInputImages(directory, images);
436
437
  }
437
438
  async writeToolResultResource(sessionId, text) {
438
439
  this.#assertOpen();
439
440
  if (!this.#shared.index.sessions.some((item) => item.id === sessionId))
440
441
  throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
441
- return writeToolResultResource(this.sessionDirectory(sessionId), text);
442
+ return writeToolResultResource(this.#sessionDirectory(sessionId), text);
442
443
  }
443
444
  async readToolResultResource(sessionId, input) {
444
445
  this.#assertOpen();
445
446
  await this.#refreshIndex();
446
447
  if (!this.#shared.index.sessions.some((item) => item.id === sessionId))
447
448
  throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
448
- return readToolResultResource(this.sessionDirectory(sessionId), sessionId, input, async (sequence) => {
449
+ return readToolResultResource(this.#sessionDirectory(sessionId), sessionId, input, async (sequence) => {
449
450
  await this.#refreshIndex();
450
451
  return (await this.readPage(sessionId, { beforeSequence: sequence + 1, limit: 1 })).records.find((record) => record.sequence === sequence);
451
452
  });
@@ -455,7 +456,7 @@ export class JsonlSessionStore {
455
456
  const references = inputImageReferences(value);
456
457
  if (references.length === 0)
457
458
  return [];
458
- const directory = join(this.sessionDirectory(sessionId), 'input_images');
459
+ const directory = join(this.#sessionDirectory(sessionId), 'input_images');
459
460
  const images = [];
460
461
  for (const reference of references) {
461
462
  const data = await readInputImageBytes(directory, reference);
@@ -485,7 +486,7 @@ export class JsonlSessionStore {
485
486
  const reference = inputImageReference(record.payload.images[input.imageIndex]);
486
487
  if (reference === undefined)
487
488
  return undefined;
488
- const data = await readInputImageBytes(join(this.sessionDirectory(sessionId), 'input_images'), reference);
489
+ const data = await readInputImageBytes(join(this.#sessionDirectory(sessionId), 'input_images'), reference);
489
490
  return {
490
491
  name: reference.name,
491
492
  mimeType: reference.mimeType,
@@ -520,6 +521,7 @@ export class JsonlSessionStore {
520
521
  automaticTitlePending: false,
521
522
  resume: {
522
523
  version: 1,
524
+ userTurns: 0,
523
525
  todoRevision: 0,
524
526
  executionModelIndexed: true,
525
527
  firstUserMessageIndexed: true
@@ -566,7 +568,7 @@ export class JsonlSessionStore {
566
568
  });
567
569
  }
568
570
  // A fork is unpublished until every segment and referenced image is durable.
569
- const staged = await stageSessionSegments(this.sessionDirectory(target.id), copied, 'fork');
571
+ const staged = await stageSessionSegments(this.#sessionDirectory(target.id), copied, 'fork');
570
572
  for (const file of staged.files)
571
573
  await rename(file.temporaryPath, file.path);
572
574
  target.segments = staged.segments;
@@ -574,9 +576,9 @@ export class JsonlSessionStore {
574
576
  this.#shared.segments.set(target.id, staged.segments.length);
575
577
  for (const record of copied)
576
578
  this.#applyRecordToResumeIndex(target, record);
577
- await copyToolResultResources(this.sessionDirectory(sourceSessionId), this.sessionDirectory(target.id), copied);
578
- await copyGeneratedImages(this.sessionDirectory(sourceSessionId), this.sessionDirectory(target.id), generatedImageFileNames(retained));
579
- await copyInputImages(this.sessionDirectory(sourceSessionId), this.sessionDirectory(target.id), inputImageReferencesFromRecords(retained));
579
+ await copyToolResultResources(this.#sessionDirectory(sourceSessionId), this.#sessionDirectory(target.id), copied);
580
+ await copyGeneratedImages(this.#sessionDirectory(sourceSessionId), this.#sessionDirectory(target.id), generatedImageFileNames(retained));
581
+ await copyInputImages(this.#sessionDirectory(sourceSessionId), this.#sessionDirectory(target.id), inputImageReferencesFromRecords(retained));
580
582
  target.lastActivityAt = target.createdAt;
581
583
  this.#shared.pendingSessions.delete(target.id);
582
584
  this.#shared.index = {
@@ -654,102 +656,7 @@ export class JsonlSessionStore {
654
656
  const retained = records
655
657
  .slice(0, boundaryIndex + 1)
656
658
  .filter((record) => record.type !== 'responses.continuation');
657
- const staged = await stageSessionSegments(this.sessionDirectory(sessionId), retained);
658
- const transactionId = randomUUID();
659
- const targetNextSequence = (retained.at(-1)?.sequence ?? 0) + 1;
660
- const contextualWorldStatePath = join(this.sessionDirectory(sessionId), CONTEXTUAL_WORLD_STATE_FILE);
661
- const contextualWorldStateBackup = `${contextualWorldStatePath}.${targetNextSequence}.${transactionId}.rollback-backup`;
662
- let contextualWorldStateStaged = false;
663
- let generatedTrash = [];
664
- let inputTrash = [];
665
- const originalMeta = structuredClone(meta);
666
- const originalIndexRevision = this.#shared.index.revision;
667
- const originalSegment = this.#shared.segments.get(sessionId);
668
- const originalPaths = await this.#segmentPaths(sessionId);
669
- const backups = originalPaths.map((path) => ({
670
- path,
671
- backup: `${path}.${transactionId}.rollback-backup`
672
- }));
673
- const transaction = {
674
- version: 1,
675
- id: transactionId,
676
- kind: 'rollback',
677
- originalSegments: originalPaths.map((path) => basename(path)),
678
- stagedSegments: staged.files.map((item) => ({
679
- path: basename(item.path),
680
- temporary: basename(item.temporaryPath)
681
- })),
682
- sidecarBackup: basename(contextualWorldStateBackup)
683
- };
684
- const installedPaths = [];
685
- let committed = false;
686
- try {
687
- await beginSessionStorageTransaction(this.sessionDirectory(sessionId), transaction);
688
- contextualWorldStateStaged = await renameIfExists(contextualWorldStatePath, contextualWorldStateBackup);
689
- generatedTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'generated_images', new Set(generatedImageFileNames(retained)), transactionId);
690
- inputTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'input_images', new Set(inputImageReferencesFromRecords(retained).map((image) => image.fileName)), transactionId);
691
- inputTrash.push(...(await stageUnreferencedImages(this.sessionDirectory(sessionId), 'tool_results', toolResultResourceFiles(retained), transactionId)));
692
- for (const item of backups)
693
- await rename(item.path, item.backup);
694
- for (const item of staged.files) {
695
- await rename(item.temporaryPath, item.path);
696
- installedPaths.push(item.path);
697
- }
698
- const state = deriveExecutionState(retained);
699
- const completedAt = new Date().toISOString();
700
- meta.title = retainedSessionTitle(retained, meta.title);
701
- meta.lastActivityAt = completedAt;
702
- meta.nextSequence = targetNextSequence;
703
- meta.storageTransactionId = transactionId;
704
- meta.automaticTitlePending = state.automaticTitlePending;
705
- meta.resume = state.resume;
706
- meta.segments = staged.segments;
707
- this.#shared.segments.set(sessionId, staged.segments.at(-1)?.number ?? 1);
708
- this.#shared.index.revision++;
709
- this.#markIndexDirty(sessionId);
710
- await this.#saveIndex();
711
- committed = true;
712
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
713
- return {
714
- id: meta.id,
715
- title: meta.title,
716
- workspace: meta.workspace,
717
- createdAt: meta.createdAt,
718
- lastActivityAt: meta.lastActivityAt,
719
- source: normalizeSessionSource(meta.source)
720
- };
721
- }
722
- catch (error) {
723
- if (!committed) {
724
- committed = await this.#storageTransactionCommitted(sessionId, transactionId);
725
- if (committed) {
726
- this.#shared.dirtySessionVersions.delete(sessionId);
727
- this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
728
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
729
- }
730
- }
731
- if (!committed) {
732
- await Promise.all(installedPaths.map((path) => rm(path, { force: true }))).catch(() => undefined);
733
- await restoreRenamedFiles(backups.map((item) => ({ from: item.backup, to: item.path })));
734
- if (contextualWorldStateStaged)
735
- await restoreRenamedFiles([
736
- { from: contextualWorldStateBackup, to: contextualWorldStatePath }
737
- ]);
738
- await restoreRenamedFiles([...generatedTrash, ...inputTrash]);
739
- restoreSessionMeta(meta, originalMeta);
740
- this.#shared.index.revision = originalIndexRevision;
741
- if (originalSegment === undefined)
742
- this.#shared.segments.delete(sessionId);
743
- else
744
- this.#shared.segments.set(sessionId, originalSegment);
745
- this.#markIndexDirty(sessionId);
746
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), originalMeta.storageTransactionId);
747
- }
748
- throw error;
749
- }
750
- finally {
751
- await Promise.all(staged.files.map((item) => rm(item.temporaryPath, { force: true }))).catch(() => undefined);
752
- }
659
+ return await this.#replaceHistory(meta, retained, { kind: 'rollback' });
753
660
  }
754
661
  finally {
755
662
  await release();
@@ -817,100 +724,120 @@ export class JsonlSessionStore {
817
724
  }
818
725
  }
819
726
  ];
820
- const staged = await stageSessionSegments(this.sessionDirectory(sessionId), retained);
821
- const transactionId = randomUUID();
822
- const contextualWorldStatePath = join(this.sessionDirectory(sessionId), CONTEXTUAL_WORLD_STATE_FILE);
823
- const contextualWorldStateBackup = `${contextualWorldStatePath}.2.${transactionId}.clear-backup`;
824
- let contextualWorldStateStaged = false;
825
- let generatedTrash = [];
826
- let inputTrash = [];
827
- const originalMeta = structuredClone(meta);
828
- const originalIndexRevision = this.#shared.index.revision;
829
- const originalSegment = this.#shared.segments.get(sessionId);
830
- const originalPaths = await this.#segmentPaths(sessionId);
831
- const backups = originalPaths.map((path) => ({
832
- path,
833
- backup: `${path}.${transactionId}.clear-backup`
834
- }));
835
- const transaction = {
836
- version: 1,
837
- id: transactionId,
838
- kind: 'clear',
839
- originalSegments: originalPaths.map((path) => basename(path)),
840
- stagedSegments: staged.files.map((item) => ({
841
- path: basename(item.path),
842
- temporary: basename(item.temporaryPath)
843
- })),
844
- sidecarBackup: basename(contextualWorldStateBackup)
845
- };
846
- const installedPaths = [];
847
- let committed = false;
848
- try {
849
- await beginSessionStorageTransaction(this.sessionDirectory(sessionId), transaction);
850
- contextualWorldStateStaged = await renameIfExists(contextualWorldStatePath, contextualWorldStateBackup);
851
- generatedTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'generated_images', new Set(), transactionId);
852
- inputTrash = await stageUnreferencedImages(this.sessionDirectory(sessionId), 'input_images', new Set(), transactionId);
853
- inputTrash.push(...(await stageUnreferencedImages(this.sessionDirectory(sessionId), 'tool_results', new Set(), transactionId)));
854
- for (const item of backups)
855
- await rename(item.path, item.backup);
856
- for (const item of staged.files) {
857
- await rename(item.temporaryPath, item.path);
858
- installedPaths.push(item.path);
859
- }
860
- meta.lastActivityAt = clearedAt;
861
- meta.nextSequence = retained.length + 1;
727
+ return await this.#replaceHistory(meta, retained, { kind: 'clear', clearedAt });
728
+ }
729
+ finally {
730
+ await release();
731
+ }
732
+ }
733
+ /** Called only while rollback or clear holds the maintenance lease. */
734
+ async #replaceHistory(meta, retained, operation) {
735
+ const sessionId = meta.id;
736
+ const staged = await stageSessionSegments(this.#sessionDirectory(sessionId), retained);
737
+ const transactionId = randomUUID();
738
+ const targetNextSequence = operation.kind === 'clear' ? 2 : (retained.at(-1)?.sequence ?? 0) + 1;
739
+ const contextualWorldStatePath = join(this.#sessionDirectory(sessionId), CONTEXTUAL_WORLD_STATE_FILE);
740
+ const contextualWorldStateBackup = `${contextualWorldStatePath}.${targetNextSequence}.${transactionId}.${operation.kind}-backup`;
741
+ let contextualWorldStateStaged = false;
742
+ let generatedTrash = [];
743
+ let inputTrash = [];
744
+ const originalMeta = structuredClone(meta);
745
+ const originalIndexRevision = this.#shared.index.revision;
746
+ const originalSegment = this.#shared.segments.get(sessionId);
747
+ const originalPaths = await this.#segmentPaths(sessionId);
748
+ const backups = originalPaths.map((path) => ({
749
+ path,
750
+ backup: `${path}.${transactionId}.${operation.kind}-backup`
751
+ }));
752
+ const transaction = {
753
+ version: 1,
754
+ id: transactionId,
755
+ kind: operation.kind,
756
+ originalSegments: originalPaths.map((path) => basename(path)),
757
+ stagedSegments: staged.files.map((item) => ({
758
+ path: basename(item.path),
759
+ temporary: basename(item.temporaryPath)
760
+ })),
761
+ sidecarBackup: basename(contextualWorldStateBackup)
762
+ };
763
+ const installedPaths = [];
764
+ let committed = false;
765
+ try {
766
+ await beginSessionStorageTransaction(this.#sessionDirectory(sessionId), transaction);
767
+ contextualWorldStateStaged = await renameIfExists(contextualWorldStatePath, contextualWorldStateBackup);
768
+ generatedTrash = await stageUnreferencedImages(this.#sessionDirectory(sessionId), 'generated_images', operation.kind === 'rollback' ? new Set(generatedImageFileNames(retained)) : new Set(), transactionId);
769
+ inputTrash = await stageUnreferencedImages(this.#sessionDirectory(sessionId), 'input_images', operation.kind === 'rollback'
770
+ ? new Set(inputImageReferencesFromRecords(retained).map((image) => image.fileName))
771
+ : new Set(), transactionId);
772
+ inputTrash.push(...(await stageUnreferencedImages(this.#sessionDirectory(sessionId), 'tool_results', operation.kind === 'rollback' ? toolResultResourceFiles(retained) : new Set(), transactionId)));
773
+ for (const item of backups)
774
+ await rename(item.path, item.backup);
775
+ for (const item of staged.files) {
776
+ await rename(item.temporaryPath, item.path);
777
+ installedPaths.push(item.path);
778
+ }
779
+ if (operation.kind === 'rollback') {
780
+ const state = deriveExecutionState(retained);
781
+ const completedAt = new Date().toISOString();
782
+ meta.title = retainedSessionTitle(retained, meta.title);
783
+ meta.lastActivityAt = completedAt;
784
+ meta.nextSequence = targetNextSequence;
862
785
  meta.storageTransactionId = transactionId;
863
- meta.resume = { version: 1, todoRevision: 0, firstUserMessageIndexed: true };
864
- meta.segments = staged.segments;
865
- this.#shared.segments.set(sessionId, 1);
866
- this.#shared.index.revision++;
867
- this.#markIndexDirty(sessionId);
868
- await this.#saveIndex();
869
- committed = true;
870
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
871
- return {
872
- id: meta.id,
873
- title: meta.title,
874
- workspace: meta.workspace,
875
- createdAt: meta.createdAt,
876
- lastActivityAt: meta.lastActivityAt,
877
- source: normalizeSessionSource(meta.source)
878
- };
786
+ meta.automaticTitlePending = state.automaticTitlePending;
787
+ meta.resume = state.resume;
879
788
  }
880
- catch (error) {
881
- if (!committed) {
882
- committed = await this.#storageTransactionCommitted(sessionId, transactionId);
883
- if (committed) {
884
- this.#shared.dirtySessionVersions.delete(sessionId);
885
- this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
886
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), transactionId);
887
- }
888
- }
889
- if (!committed) {
890
- await Promise.all(installedPaths.map((path) => rm(path, { force: true }))).catch(() => undefined);
891
- await restoreRenamedFiles(backups.map((item) => ({ from: item.backup, to: item.path })));
892
- if (contextualWorldStateStaged)
893
- await restoreRenamedFiles([
894
- { from: contextualWorldStateBackup, to: contextualWorldStatePath }
895
- ]);
896
- await restoreRenamedFiles([...generatedTrash, ...inputTrash]);
897
- restoreSessionMeta(meta, originalMeta);
898
- this.#shared.index.revision = originalIndexRevision;
899
- if (originalSegment === undefined)
900
- this.#shared.segments.delete(sessionId);
901
- else
902
- this.#shared.segments.set(sessionId, originalSegment);
903
- this.#markIndexDirty(sessionId);
904
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), originalMeta.storageTransactionId);
789
+ else {
790
+ meta.lastActivityAt = operation.clearedAt;
791
+ meta.nextSequence = targetNextSequence;
792
+ meta.storageTransactionId = transactionId;
793
+ meta.resume = { version: 1, userTurns: 0, todoRevision: 0, firstUserMessageIndexed: true };
794
+ }
795
+ meta.segments = staged.segments;
796
+ this.#shared.segments.set(sessionId, operation.kind === 'clear' ? 1 : (staged.segments.at(-1)?.number ?? 1));
797
+ this.#shared.index.revision++;
798
+ this.#markIndexDirty(sessionId);
799
+ await this.#saveIndex();
800
+ committed = true;
801
+ await recoverSessionStorageTransaction(this.#sessionDirectory(sessionId), transactionId);
802
+ return {
803
+ id: meta.id,
804
+ title: meta.title,
805
+ workspace: meta.workspace,
806
+ createdAt: meta.createdAt,
807
+ lastActivityAt: meta.lastActivityAt,
808
+ source: normalizeSessionSource(meta.source)
809
+ };
810
+ }
811
+ catch (error) {
812
+ if (!committed) {
813
+ committed = await this.#storageTransactionCommitted(sessionId, transactionId);
814
+ if (committed) {
815
+ this.#shared.dirtySessionVersions.delete(sessionId);
816
+ this.#shared.indexDirty = this.#shared.dirtySessionVersions.size > 0;
817
+ await recoverSessionStorageTransaction(this.#sessionDirectory(sessionId), transactionId);
905
818
  }
906
- throw error;
907
819
  }
908
- finally {
909
- await Promise.all(staged.files.map((item) => rm(item.temporaryPath, { force: true }))).catch(() => undefined);
820
+ if (!committed) {
821
+ await Promise.all(installedPaths.map((path) => rm(path, { force: true }))).catch(() => undefined);
822
+ await restoreRenamedFiles(backups.map((item) => ({ from: item.backup, to: item.path })));
823
+ if (contextualWorldStateStaged)
824
+ await restoreRenamedFiles([
825
+ { from: contextualWorldStateBackup, to: contextualWorldStatePath }
826
+ ]);
827
+ await restoreRenamedFiles([...generatedTrash, ...inputTrash]);
828
+ restoreSessionMeta(meta, originalMeta);
829
+ this.#shared.index.revision = originalIndexRevision;
830
+ if (originalSegment === undefined)
831
+ this.#shared.segments.delete(sessionId);
832
+ else
833
+ this.#shared.segments.set(sessionId, originalSegment);
834
+ this.#markIndexDirty(sessionId);
835
+ await recoverSessionStorageTransaction(this.#sessionDirectory(sessionId), originalMeta.storageTransactionId);
910
836
  }
837
+ throw error;
911
838
  }
912
839
  finally {
913
- await release();
840
+ await Promise.all(staged.files.map((item) => rm(item.temporaryPath, { force: true }))).catch(() => undefined);
914
841
  }
915
842
  }
916
843
  async getSession(sessionId) {
@@ -970,7 +897,7 @@ export class JsonlSessionStore {
970
897
  payload: input.payload
971
898
  };
972
899
  JSON.stringify(record); // Reject malformed payloads before binding resources.
973
- await bindToolResultResources(this.sessionDirectory(sessionId), record, (sequence) => this.#readRecordAtSequence(before, sequence), true);
900
+ await bindToolResultResources(this.#sessionDirectory(sessionId), record, (sequence) => this.#readRecordAtSequence(before, sequence), true);
974
901
  const serialized = `${JSON.stringify(record)}\n`;
975
902
  let segment = await this.#currentSegment(sessionId);
976
903
  let path = this.#segmentPath(sessionId, segment);
@@ -990,6 +917,12 @@ export class JsonlSessionStore {
990
917
  }
991
918
  catch (error) {
992
919
  // A failed write/sync/close can leave a complete record on disk.
920
+ const current = this.#shared.index.sessions.find((item) => item.id === sessionId) ?? before;
921
+ if (current.resume?.userTurns !== undefined) {
922
+ delete current.resume.userTurns;
923
+ if (published)
924
+ this.#markIndexDirty(sessionId);
925
+ }
993
926
  const lease = this.#shared.writerLeases.get(sessionId);
994
927
  if (lease) {
995
928
  delete lease.codeState;
@@ -1024,7 +957,7 @@ export class JsonlSessionStore {
1024
957
  this.#markIndexDirty(sessionId);
1025
958
  }
1026
959
  updateSessionCurrentState(this.#shared.writerLeases.get(sessionId), record.type, serialized);
1027
- await completeToolResultResources(this.sessionDirectory(sessionId), record);
960
+ await completeToolResultResources(this.#sessionDirectory(sessionId), record);
1028
961
  if (record.type === 'turn.user' &&
1029
962
  isObject(record.payload) &&
1030
963
  Array.isArray(record.payload.images))
@@ -1032,7 +965,7 @@ export class JsonlSessionStore {
1032
965
  if (isObject(image) &&
1033
966
  typeof image.fileName === 'string' &&
1034
967
  validInputImageFileName(image.fileName))
1035
- await rm(join(this.sessionDirectory(sessionId), 'input_images', `.${image.fileName}.pending`), { force: true }).catch(() => undefined);
968
+ await rm(join(this.#sessionDirectory(sessionId), 'input_images', `.${image.fileName}.pending`), { force: true }).catch(() => undefined);
1036
969
  return record;
1037
970
  }
1038
971
  async read(sessionId) {
@@ -1169,9 +1102,6 @@ export class JsonlSessionStore {
1169
1102
  return this.#rebuildExecutionState(meta);
1170
1103
  }
1171
1104
  }
1172
- async readContext(sessionId) {
1173
- return (await this.readExecutionState(sessionId)).contextRecords;
1174
- }
1175
1105
  /** Compare the planned sequence inside the existing record writer queue. */
1176
1106
  async commitContextGc(sessionId, draft, planningRecords) {
1177
1107
  this.#assertOpen();
@@ -1391,7 +1321,7 @@ export class JsonlSessionStore {
1391
1321
  const current = disk.sessions.find((item) => item.id === sessionId);
1392
1322
  if (!current)
1393
1323
  throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
1394
- await recoverSessionStorageTransaction(this.sessionDirectory(sessionId), current.storageTransactionId);
1324
+ await recoverSessionStorageTransaction(this.#sessionDirectory(sessionId), current.storageTransactionId);
1395
1325
  await recoverContextualWorldStateTransactions(this.#shared.root, current);
1396
1326
  await this.#refreshIndex();
1397
1327
  await this.#reconcileTail(sessionId);
@@ -1512,6 +1442,8 @@ export class JsonlSessionStore {
1512
1442
  const resume = meta.resume;
1513
1443
  if (resume?.version !== 1 || !Number.isSafeInteger(resume.todoRevision))
1514
1444
  return;
1445
+ if (resume.userTurns !== undefined && isNativeUserTurn(record))
1446
+ resume.userTurns++;
1515
1447
  if (resume.firstUserMessageIndexed === true && resume.firstUserMessagePreview === undefined) {
1516
1448
  const preview = userMessagePreviewFromRecord(record);
1517
1449
  if (preview !== undefined)
@@ -1602,6 +1534,73 @@ export class JsonlSessionStore {
1602
1534
  contextRecords: state.contextRecords
1603
1535
  };
1604
1536
  }
1537
+ /** Counts native input records, not the post-Compact model context. */
1538
+ async userTurnCount(sessionId) {
1539
+ return this.#serializeRecordOperation(sessionId, async () => {
1540
+ this.#assertOpen();
1541
+ await this.#refreshIndex();
1542
+ const before = this.#shared.index.sessions.find((session) => session.id === sessionId);
1543
+ if (!before)
1544
+ throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
1545
+ const indexed = before.resume?.userTurns;
1546
+ if (indexed !== undefined && this.#shared.writerLeases.has(sessionId))
1547
+ return indexed;
1548
+ const nextSequence = before.nextSequence;
1549
+ const transactionId = before.storageTransactionId;
1550
+ const paths = await this.#segmentPaths(sessionId);
1551
+ let count = indexed ?? 0;
1552
+ let lastSequence = 0;
1553
+ let reachedPrefix = false;
1554
+ if (indexed !== undefined) {
1555
+ // No local writer: a previous writer may have committed bytes but not its index.
1556
+ for (const path of [...paths].reverse()) {
1557
+ for await (const record of readRecordsFromTail(path)) {
1558
+ lastSequence = Math.max(lastSequence, record.sequence);
1559
+ if (record.sequence < nextSequence) {
1560
+ reachedPrefix = true;
1561
+ break;
1562
+ }
1563
+ if (isNativeUserTurn(record))
1564
+ count++;
1565
+ }
1566
+ if (reachedPrefix)
1567
+ break;
1568
+ }
1569
+ }
1570
+ if (indexed === undefined || lastSequence < nextSequence - 1) {
1571
+ count = 0;
1572
+ lastSequence = 0;
1573
+ for (const path of paths) {
1574
+ for await (const record of readRecordsFromHead(path)) {
1575
+ lastSequence = record.sequence;
1576
+ if (isNativeUserTurn(record))
1577
+ count++;
1578
+ }
1579
+ }
1580
+ }
1581
+ await this.#refreshIndex();
1582
+ const current = this.#shared.index.sessions.find((session) => session.id === sessionId);
1583
+ if (!current)
1584
+ throw new MarAgentError('MAR_AGENT_SESSION_NOT_FOUND', 'Session was not found.');
1585
+ if (current.storageTransactionId !== transactionId) {
1586
+ // The indexed prefix no longer belongs to this history. Fall back to the
1587
+ // ordinary physical read boundary without caching or a conflict error.
1588
+ count = 0;
1589
+ for (const path of await this.#segmentPaths(sessionId))
1590
+ for await (const record of readRecordsFromHead(path))
1591
+ if (isNativeUserTurn(record))
1592
+ count++;
1593
+ return count;
1594
+ }
1595
+ if (current.nextSequence === nextSequence &&
1596
+ lastSequence === nextSequence - 1 &&
1597
+ current.resume?.userTurns === undefined) {
1598
+ (current.resume ??= { version: 1, todoRevision: 0 }).userTurns = count;
1599
+ this.#markIndexDirty(sessionId, true);
1600
+ }
1601
+ return count;
1602
+ });
1603
+ }
1605
1604
  async firstUserMessagePreview(sessionId) {
1606
1605
  this.#assertOpen();
1607
1606
  await this.#refreshIndex();
@@ -1750,11 +1749,11 @@ export class JsonlSessionStore {
1750
1749
  if (this.#leaseClosed || this.#shared.closed)
1751
1750
  throw new MarAgentError('MAR_AGENT_DISPOSED', 'Session store is closed.');
1752
1751
  }
1753
- #markIndexDirty(sessionId, previewOnly = false) {
1754
- if (!previewOnly)
1755
- this.#shared.previewOnlyDirtySessions.delete(sessionId);
1752
+ #markIndexDirty(sessionId, derivedOnly = false) {
1753
+ if (!derivedOnly)
1754
+ this.#shared.derivedOnlyDirtySessions.delete(sessionId);
1756
1755
  else if (!this.#shared.dirtySessionVersions.has(sessionId))
1757
- this.#shared.previewOnlyDirtySessions.add(sessionId);
1756
+ this.#shared.derivedOnlyDirtySessions.add(sessionId);
1758
1757
  this.#shared.indexDirty = true;
1759
1758
  this.#shared.dirtySessionVersions.set(sessionId, (this.#shared.dirtySessionVersions.get(sessionId) ?? 0) + 1);
1760
1759
  }
@@ -1796,8 +1795,8 @@ export class JsonlSessionStore {
1796
1795
  const latestPath = paths.at(-1);
1797
1796
  await repairSegmentTail(latestPath);
1798
1797
  const records = await readRecords(latestPath);
1799
- await recoverPendingInputImages(this.sessionDirectory(sessionId), () => this.#readRecords(sessionId));
1800
- await cleanupUnreferencedToolResources(this.sessionDirectory(sessionId), () => this.#readRecords(sessionId));
1798
+ await recoverPendingInputImages(this.#sessionDirectory(sessionId), () => this.#readRecords(sessionId));
1799
+ await cleanupUnreferencedToolResources(this.#sessionDirectory(sessionId), () => this.#readRecords(sessionId));
1801
1800
  const last = records.at(-1);
1802
1801
  const match = /events-(\d{6})\.jsonl$/.exec(latestPath);
1803
1802
  const segment = Number(match?.[1] ?? 1);