@caupulican/pi-agent-core 0.86.3 → 0.86.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.
@@ -10,6 +10,36 @@ import { normalizePath, resolvePath } from "../utils/paths.js";
10
10
  import { uuidv7 } from "../uuid.js";
11
11
  import { compactToolResultDetailsForRetention } from "./message-retention.js";
12
12
  export const CURRENT_SESSION_VERSION = 3;
13
+ /** Maximum borrowed entries one non-copying SessionManager visit may inspect. */
14
+ export const MAX_SESSION_ENTRY_VISIT_COUNT = 1_024;
15
+ function invalidSessionParentCycle(entryId) {
16
+ return new Error(`Invalid session entry graph: parent cycle detected at entry "${entryId}".`);
17
+ }
18
+ /** Visit leaf-to-root ancestry through one bounded, cycle-rejecting implementation path. */
19
+ function visitSessionAncestry(start, byId, visitor) {
20
+ let current = start;
21
+ // One external start plus every indexed node is the longest possible acyclic walk.
22
+ let remainingEntries = byId.size + 1;
23
+ while (current) {
24
+ if (remainingEntries-- === 0)
25
+ throw invalidSessionParentCycle(current.id);
26
+ if (visitor(current) === false)
27
+ return;
28
+ current = current.parentId ? byId.get(current.parentId) : undefined;
29
+ }
30
+ }
31
+ const synthesizedSessionContextMessages = new WeakSet();
32
+ function retainSynthesizedSessionContextMessage(message) {
33
+ synthesizedSessionContextMessages.add(message);
34
+ return message;
35
+ }
36
+ function cloneSessionContext(context) {
37
+ return {
38
+ messages: context.messages.map((message) => synthesizedSessionContextMessages.has(message) ? { ...message } : message),
39
+ thinkingLevel: context.thinkingLevel,
40
+ model: context.model ? { ...context.model } : null,
41
+ };
42
+ }
13
43
  function createSessionId() {
14
44
  return uuidv7();
15
45
  }
@@ -148,11 +178,9 @@ export function buildSessionContext(entries, leafId, byId) {
148
178
  }
149
179
  // Walk from leaf to root, then reverse once. Repeated front insertion makes long branches quadratic.
150
180
  const path = [];
151
- let current = leaf;
152
- while (current) {
153
- path.push(current);
154
- current = current.parentId ? byId.get(current.parentId) : undefined;
155
- }
181
+ visitSessionAncestry(leaf, byId, (entry) => {
182
+ path.push(entry);
183
+ });
156
184
  path.reverse();
157
185
  // Extract settings and find compaction
158
186
  let thinkingLevel = "off";
@@ -183,15 +211,15 @@ export function buildSessionContext(entries, leafId, byId) {
183
211
  messages.push(entry.message);
184
212
  }
185
213
  else if (entry.type === "custom_message") {
186
- messages.push(createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp));
214
+ messages.push(retainSynthesizedSessionContextMessage(createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp)));
187
215
  }
188
216
  else if (entry.type === "branch_summary" && entry.summary) {
189
- messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
217
+ messages.push(retainSynthesizedSessionContextMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)));
190
218
  }
191
219
  };
192
220
  if (compaction) {
193
221
  // Emit summary first
194
- messages.push(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp));
222
+ messages.push(retainSynthesizedSessionContextMessage(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)));
195
223
  // Find compaction index in path
196
224
  const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
197
225
  // Emit kept messages (before compaction, starting from firstKeptEntryId)
@@ -335,6 +363,13 @@ export function loadEntriesFromFile(filePath, options) {
335
363
  const ENTRY_ID_PREFIX_BYTES = 64 * 1024;
336
364
  const COMPACTED_PAYLOAD_RELEASE_MIN_CHARS = 16 * 1024;
337
365
  function indexSessionEntryFileLocations(filePath, startOffset, locations, retainedIds) {
366
+ const pendingIds = new Set();
367
+ for (const id of retainedIds) {
368
+ if (!locations.has(id))
369
+ pendingIds.add(id);
370
+ }
371
+ if (pendingIds.size === 0)
372
+ return startOffset;
338
373
  const fd = openSync(filePath, "r");
339
374
  const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
340
375
  let position = startOffset;
@@ -348,12 +383,13 @@ function indexSessionEntryFileLocations(filePath, startOffset, locations, retain
348
383
  ? prefixParts[0].toString("utf8")
349
384
  : Buffer.concat(prefixParts, prefixLength).toString("utf8");
350
385
  const id = /"id"\s*:\s*"([A-Za-z0-9_-]+)"/.exec(prefix)?.[1];
351
- if (id && retainedIds.has(id))
386
+ if (id && pendingIds.delete(id))
352
387
  locations.set(id, { offset: lineOffset, length: lineLength });
353
388
  }
354
389
  lineLength = 0;
355
390
  prefixLength = 0;
356
391
  prefixParts = [];
392
+ return pendingIds.size === 0;
357
393
  };
358
394
  try {
359
395
  while (true) {
@@ -376,8 +412,10 @@ function indexSessionEntryFileLocations(filePath, startOffset, locations, retain
376
412
  }
377
413
  if (newlineIndex === -1 || newlineIndex >= bytesRead)
378
414
  break;
379
- finishLine();
380
- lineOffset = chunkOffset + newlineIndex + 1;
415
+ const nextLineOffset = chunkOffset + newlineIndex + 1;
416
+ if (finishLine())
417
+ return nextLineOffset;
418
+ lineOffset = nextLineOffset;
381
419
  segmentStart = newlineIndex + 1;
382
420
  }
383
421
  }
@@ -689,6 +727,7 @@ export class SessionManager {
689
727
  this.entryFileLocations = new Map();
690
728
  this.coldPayloadEntryIds = new Set();
691
729
  this.indexedSessionFileBytes = 0;
730
+ this.persistenceStateUncertain = false;
692
731
  this.cwd = resolvePath(cwd);
693
732
  this.sessionDir = normalizePath(sessionDir);
694
733
  this.agentDir = agentDir;
@@ -702,6 +741,37 @@ export class SessionManager {
702
741
  }
703
742
  /** Switch to a different session file (used for resume and branching) */
704
743
  setSessionFile(sessionFile) {
744
+ const recoveringUncertainWrite = this.persistenceStateUncertain;
745
+ this.persistenceStateUncertain = true;
746
+ try {
747
+ if (recoveringUncertainWrite) {
748
+ const resolvedSessionFile = resolvePath(sessionFile);
749
+ if (existsSync(resolvedSessionFile)) {
750
+ const fileBytes = statSync(resolvedSessionFile).size;
751
+ if (fileBytes > 0) {
752
+ const fd = openSync(resolvedSessionFile, "r");
753
+ const finalByte = Buffer.allocUnsafe(1);
754
+ try {
755
+ if (readSync(fd, finalByte, 0, 1, fileBytes - 1) !== 1 || finalByte[0] !== 0x0a) {
756
+ throw new Error("Session file ends with an incomplete JSONL record after a failed write; repair it or start a new session.");
757
+ }
758
+ }
759
+ finally {
760
+ closeSync(fd);
761
+ }
762
+ }
763
+ }
764
+ }
765
+ this._setSessionFile(sessionFile);
766
+ this.persistenceStateUncertain = false;
767
+ }
768
+ catch (error) {
769
+ this.persistenceStateUncertain = true;
770
+ throw error;
771
+ }
772
+ }
773
+ _setSessionFile(sessionFile) {
774
+ this._invalidateSessionContextCache();
705
775
  this._resetEntryFileIndex(true);
706
776
  this.sessionFile = resolvePath(sessionFile);
707
777
  if (existsSync(this.sessionFile)) {
@@ -742,16 +812,18 @@ export class SessionManager {
742
812
  const header = this.fileEntries.find((e) => e.type === "session");
743
813
  this.sessionId = header?.id ?? createSessionId();
744
814
  this._ensureEntryFileLocations(this.coldPayloadEntryIds);
745
- if (migrateToCurrentVersion(this.fileEntries)) {
815
+ const migrated = migrateToCurrentVersion(this.fileEntries);
816
+ // Validate and index the complete parent graph before a migration rewrite can
817
+ // mutate the source file. Malformed cycles must fail synchronously on cold open.
818
+ this._buildIndex();
819
+ if (migrated)
746
820
  this._rewriteFile();
747
- }
748
821
  // Bound in-memory retention: oversized tool result details from disk would
749
822
  // otherwise be pinned in fileEntries for the whole process lifetime.
750
823
  for (const entry of this.fileEntries) {
751
824
  if (entry.type === "message")
752
825
  compactToolResultDetailsForRetention(entry.message);
753
826
  }
754
- this._buildIndex();
755
827
  this.flushed = true;
756
828
  this._releaseExistingCompactedMessagePayloads();
757
829
  }
@@ -782,6 +854,8 @@ export class SessionManager {
782
854
  this.labelTimestampsById.clear();
783
855
  this.leafId = null;
784
856
  this.flushed = false;
857
+ this.persistenceStateUncertain = false;
858
+ this._invalidateSessionContextCache();
785
859
  this._resetEntryFileIndex(true);
786
860
  if (this.persist) {
787
861
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
@@ -796,30 +870,63 @@ export class SessionManager {
796
870
  this.coldPayloadEntryIds.clear();
797
871
  }
798
872
  _buildIndex() {
799
- this.entries = [];
800
- this.byId.clear();
801
- this.labelsById.clear();
802
- this.labelTimestampsById.clear();
803
- this.leafId = null;
873
+ this._invalidateSessionContextCache();
874
+ const entries = [];
875
+ const byId = new Map();
876
+ const labelsById = new Map();
877
+ const labelTimestampsById = new Map();
878
+ let leafId = null;
804
879
  for (const entry of this.fileEntries) {
805
880
  if (entry.type === "session")
806
881
  continue;
807
- this.entries.push(entry);
808
- this.byId.set(entry.id, entry);
809
- this.leafId = entry.id;
882
+ if (typeof entry.id !== "string" || entry.id.length === 0) {
883
+ throw new Error("Invalid session entry graph: every entry requires a non-empty string id.");
884
+ }
885
+ if (byId.has(entry.id)) {
886
+ throw new Error(`Invalid session entry graph: duplicate entry id "${entry.id}".`);
887
+ }
888
+ if (entry.parentId !== null && typeof entry.parentId !== "string") {
889
+ throw new Error(`Invalid session entry graph: entry "${entry.id}" has a malformed parent id.`);
890
+ }
891
+ entries.push(entry);
892
+ byId.set(entry.id, entry);
893
+ leafId = entry.id;
810
894
  if (entry.type === "label") {
811
895
  if (entry.label) {
812
- this.labelsById.set(entry.targetId, entry.label);
813
- this.labelTimestampsById.set(entry.targetId, entry.timestamp);
896
+ labelsById.set(entry.targetId, entry.label);
897
+ labelTimestampsById.set(entry.targetId, entry.timestamp);
814
898
  }
815
899
  else {
816
- this.labelsById.delete(entry.targetId);
817
- this.labelTimestampsById.delete(entry.targetId);
900
+ labelsById.delete(entry.targetId);
901
+ labelTimestampsById.delete(entry.targetId);
818
902
  }
819
903
  }
820
904
  }
905
+ const settledEntryIds = new Set();
906
+ for (const entry of entries) {
907
+ if (settledEntryIds.has(entry.id))
908
+ continue;
909
+ const activeEntryIds = new Set();
910
+ const traversedEntryIds = [];
911
+ visitSessionAncestry(entry, byId, (current) => {
912
+ if (settledEntryIds.has(current.id))
913
+ return false;
914
+ if (activeEntryIds.has(current.id)) {
915
+ throw invalidSessionParentCycle(current.id);
916
+ }
917
+ activeEntryIds.add(current.id);
918
+ traversedEntryIds.push(current.id);
919
+ });
920
+ for (const entryId of traversedEntryIds)
921
+ settledEntryIds.add(entryId);
922
+ }
923
+ this.entries = entries;
924
+ this.byId = byId;
925
+ this.labelsById = labelsById;
926
+ this.labelTimestampsById = labelTimestampsById;
927
+ this.leafId = leafId;
821
928
  for (const id of this.coldPayloadEntryIds) {
822
- if (!this.byId.has(id))
929
+ if (!byId.has(id))
823
930
  this.coldPayloadEntryIds.delete(id);
824
931
  }
825
932
  }
@@ -868,27 +975,45 @@ export class SessionManager {
868
975
  return this.sessionFile;
869
976
  }
870
977
  _ensureEntryFileLocations(retainedIds) {
871
- if (!this.sessionFile || !existsSync(this.sessionFile))
978
+ if (retainedIds.size === 0 || !this.sessionFile || !existsSync(this.sessionFile))
872
979
  return;
873
980
  const fileBytes = statSync(this.sessionFile).size;
874
981
  if (fileBytes < this.indexedSessionFileBytes)
875
982
  this._resetEntryFileIndex();
876
- if (fileBytes === this.indexedSessionFileBytes)
983
+ const allLocated = () => {
984
+ for (const id of retainedIds) {
985
+ if (!this.entryFileLocations.has(id))
986
+ return false;
987
+ }
988
+ return true;
989
+ };
990
+ if (allLocated())
877
991
  return;
878
- const idsToIndex = new Set(this.coldPayloadEntryIds);
879
- for (const id of retainedIds)
880
- idsToIndex.add(id);
881
- this.indexedSessionFileBytes = indexSessionEntryFileLocations(this.sessionFile, this.indexedSessionFileBytes, this.entryFileLocations, idsToIndex);
992
+ const scanStart = this.indexedSessionFileBytes;
993
+ if (fileBytes > scanStart) {
994
+ this.indexedSessionFileBytes = indexSessionEntryFileLocations(this.sessionFile, scanStart, this.entryFileLocations, retainedIds);
995
+ if (allLocated())
996
+ return;
997
+ }
998
+ // The missing target may precede the incremental cursor. Rebuild only for the bounded
999
+ // requested set; forward sequential pages continue from the exact prior line boundary.
1000
+ if (scanStart === 0)
1001
+ return;
1002
+ this._resetEntryFileIndex();
1003
+ this.indexedSessionFileBytes = indexSessionEntryFileLocations(this.sessionFile, 0, this.entryFileLocations, retainedIds);
1004
+ }
1005
+ _getEntryFileLocation(entryId) {
1006
+ let location = this.entryFileLocations.get(entryId);
1007
+ if (location)
1008
+ return location;
1009
+ this._ensureEntryFileLocations(new Set([entryId]));
1010
+ location = this.entryFileLocations.get(entryId);
1011
+ return location;
882
1012
  }
883
1013
  _readCompactedMessageProperty(entryId, property) {
884
1014
  if (!this.sessionFile)
885
1015
  throw new Error(`Compacted session payload ${entryId} is unavailable`);
886
- let location = this.entryFileLocations.get(entryId);
887
- if (!location) {
888
- this._resetEntryFileIndex();
889
- this._ensureEntryFileLocations(new Set([entryId]));
890
- location = this.entryFileLocations.get(entryId);
891
- }
1016
+ const location = this._getEntryFileLocation(entryId);
892
1017
  if (!location)
893
1018
  throw new Error(`Compacted session payload ${entryId} is unavailable`);
894
1019
  const fd = openSync(this.sessionFile, "r");
@@ -937,30 +1062,43 @@ export class SessionManager {
937
1062
  },
938
1063
  });
939
1064
  }
1065
+ _releasableMessageProperties(entry) {
1066
+ const message = entry.message;
1067
+ const properties = [];
1068
+ for (const property of ["content", "output"]) {
1069
+ const descriptor = Object.getOwnPropertyDescriptor(message, property);
1070
+ if (descriptor &&
1071
+ !descriptor.get &&
1072
+ "value" in descriptor &&
1073
+ retainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) >=
1074
+ COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) {
1075
+ properties.push(property);
1076
+ }
1077
+ }
1078
+ return properties;
1079
+ }
940
1080
  _releaseExistingCompactedMessagePayloads() {
941
- let current = this.leafId ? this.byId.get(this.leafId) : undefined;
942
- while (current) {
1081
+ const leaf = this.leafId ? this.byId.get(this.leafId) : undefined;
1082
+ visitSessionAncestry(leaf, this.byId, (current) => {
943
1083
  if (current.type === "compaction") {
944
1084
  this._releaseCompactedMessagePayloads(current.firstKeptEntryId, current.parentId);
945
- return;
1085
+ return false;
946
1086
  }
947
- current = current.parentId ? this.byId.get(current.parentId) : undefined;
948
- }
1087
+ });
949
1088
  }
950
1089
  _releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId) {
951
1090
  if (!this.persist || !this.flushed || !this.sessionFile)
952
1091
  return;
953
1092
  const keptEntryIds = new Set();
954
- let currentId = compactionParentId;
955
1093
  let foundFirstKept = false;
956
- while (currentId) {
957
- keptEntryIds.add(currentId);
958
- if (currentId === firstKeptEntryId) {
1094
+ const compactionParent = compactionParentId ? this.byId.get(compactionParentId) : undefined;
1095
+ visitSessionAncestry(compactionParent, this.byId, (current) => {
1096
+ keptEntryIds.add(current.id);
1097
+ if (current.id === firstKeptEntryId) {
959
1098
  foundFirstKept = true;
960
- break;
1099
+ return false;
961
1100
  }
962
- currentId = this.byId.get(currentId)?.parentId ?? null;
963
- }
1101
+ });
964
1102
  if (!foundFirstKept)
965
1103
  return;
966
1104
  const retainedIds = new Set();
@@ -968,21 +1106,11 @@ export class SessionManager {
968
1106
  for (const entry of this.entries) {
969
1107
  if (entry.type !== "message")
970
1108
  continue;
971
- const message = entry.message;
972
- const properties = [];
973
- for (const property of ["content", "output"]) {
974
- const descriptor = Object.getOwnPropertyDescriptor(message, property);
975
- if (descriptor &&
976
- !descriptor.get &&
977
- "value" in descriptor &&
978
- retainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) >=
979
- COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) {
980
- retainedIds.add(entry.id);
981
- if (!keptEntryIds.has(entry.id))
982
- properties.push(property);
983
- }
984
- }
985
- if (properties.length > 0)
1109
+ const properties = this._releasableMessageProperties(entry);
1110
+ if (properties.length === 0)
1111
+ continue;
1112
+ retainedIds.add(entry.id);
1113
+ if (!keptEntryIds.has(entry.id))
986
1114
  releases.push({ entry, properties });
987
1115
  }
988
1116
  this._ensureEntryFileLocations(retainedIds);
@@ -996,42 +1124,87 @@ export class SessionManager {
996
1124
  _persist(entry) {
997
1125
  if (!this.persist || !this.sessionFile)
998
1126
  return;
999
- const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
1000
- if (!hasAssistant) {
1001
- if (this.flushed) {
1002
- this._ensureSessionFileParent(this.sessionFile);
1003
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1004
- }
1005
- else {
1006
- // Mark as not flushed so when assistant arrives, all entries get written
1007
- this.flushed = false;
1008
- }
1009
- return;
1127
+ if (this.persistenceStateUncertain) {
1128
+ throw new Error("Session persistence state is uncertain after a failed write; reopen the session file or start a new session before appending.");
1010
1129
  }
1011
- if (!this.flushed) {
1012
- this._ensureSessionFileParent(this.sessionFile);
1013
- const fd = openSync(this.sessionFile, "wx");
1014
- try {
1015
- for (const e of this.fileEntries) {
1016
- writeFileSync(fd, `${JSON.stringify(e)}\n`);
1130
+ try {
1131
+ const hasAssistant = (entry.type === "message" && entry.message.role === "assistant") ||
1132
+ this.fileEntries.some((candidate) => candidate.type === "message" && candidate.message.role === "assistant");
1133
+ if (!hasAssistant) {
1134
+ if (this.flushed) {
1135
+ this._ensureSessionFileParent(this.sessionFile);
1136
+ appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1017
1137
  }
1138
+ return;
1018
1139
  }
1019
- finally {
1020
- closeSync(fd);
1140
+ if (!this.flushed) {
1141
+ this._ensureSessionFileParent(this.sessionFile);
1142
+ const fd = openSync(this.sessionFile, "wx");
1143
+ try {
1144
+ for (const candidate of this.fileEntries) {
1145
+ writeFileSync(fd, `${JSON.stringify(candidate)}\n`);
1146
+ }
1147
+ writeFileSync(fd, `${JSON.stringify(entry)}\n`);
1148
+ }
1149
+ finally {
1150
+ closeSync(fd);
1151
+ }
1152
+ this.flushed = true;
1153
+ }
1154
+ else {
1155
+ this._ensureSessionFileParent(this.sessionFile);
1156
+ appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1021
1157
  }
1022
- this.flushed = true;
1023
1158
  }
1024
- else {
1025
- this._ensureSessionFileParent(this.sessionFile);
1026
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1159
+ catch (error) {
1160
+ // append/write/close failures may leave a partial JSONL suffix. Do not publish the
1161
+ // entry in memory, and fence every later append until an explicit reload owns the
1162
+ // surviving canonical prefix.
1163
+ this.persistenceStateUncertain = true;
1164
+ throw error;
1027
1165
  }
1028
1166
  }
1029
1167
  _appendEntry(entry) {
1168
+ this._persist(entry);
1030
1169
  this.fileEntries.push(entry);
1031
1170
  this.entries.push(entry);
1032
1171
  this.byId.set(entry.id, entry);
1033
1172
  this.leafId = entry.id;
1034
- this._persist(entry);
1173
+ this._advanceSessionContextCache(entry);
1174
+ }
1175
+ _invalidateSessionContextCache() {
1176
+ this.sessionContextCache = undefined;
1177
+ }
1178
+ /** Advance a materialized linear projection without walking its immutable ancestry again. */
1179
+ _advanceSessionContextCache(entry) {
1180
+ const cached = this.sessionContextCache;
1181
+ if (!cached || cached.leafId !== entry.parentId || entry.type === "compaction") {
1182
+ this._invalidateSessionContextCache();
1183
+ return;
1184
+ }
1185
+ switch (entry.type) {
1186
+ case "message":
1187
+ cached.context.messages.push(entry.message);
1188
+ if (entry.message.role === "assistant") {
1189
+ cached.context.model = { provider: entry.message.provider, modelId: entry.message.model };
1190
+ }
1191
+ break;
1192
+ case "custom_message":
1193
+ cached.context.messages.push(retainSynthesizedSessionContextMessage(createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp)));
1194
+ break;
1195
+ case "branch_summary":
1196
+ if (entry.summary) {
1197
+ cached.context.messages.push(retainSynthesizedSessionContextMessage(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp)));
1198
+ }
1199
+ break;
1200
+ case "thinking_level_change":
1201
+ cached.context.thinkingLevel = entry.thinkingLevel;
1202
+ break;
1203
+ case "model_change":
1204
+ cached.context.model = { provider: entry.provider, modelId: entry.modelId };
1205
+ break;
1206
+ }
1207
+ cached.leafId = entry.id;
1035
1208
  }
1036
1209
  /** Append a message as child of current leaf, then advance leaf. Returns entry id.
1037
1210
  * Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
@@ -1220,11 +1393,10 @@ export class SessionManager {
1220
1393
  getBranch(fromId) {
1221
1394
  const path = [];
1222
1395
  const startId = fromId ?? this.leafId;
1223
- let current = startId ? this.byId.get(startId) : undefined;
1224
- while (current) {
1225
- path.push(current);
1226
- current = current.parentId ? this.byId.get(current.parentId) : undefined;
1227
- }
1396
+ const start = startId ? this.byId.get(startId) : undefined;
1397
+ visitSessionAncestry(start, this.byId, (entry) => {
1398
+ path.push(entry);
1399
+ });
1228
1400
  path.reverse();
1229
1401
  return path;
1230
1402
  }
@@ -1237,20 +1409,30 @@ export class SessionManager {
1237
1409
  */
1238
1410
  getLatestCustomEntryOnBranch(customType, fromId) {
1239
1411
  const startId = fromId ?? this.leafId;
1240
- let current = startId ? this.byId.get(startId) : undefined;
1241
- while (current) {
1242
- if (current.type === "custom" && current.customType === customType)
1243
- return current;
1244
- current = current.parentId ? this.byId.get(current.parentId) : undefined;
1245
- }
1246
- return undefined;
1412
+ const start = startId ? this.byId.get(startId) : undefined;
1413
+ let match;
1414
+ visitSessionAncestry(start, this.byId, (entry) => {
1415
+ if (entry.type !== "custom" || entry.customType !== customType)
1416
+ return;
1417
+ match = entry;
1418
+ return false;
1419
+ });
1420
+ return match;
1247
1421
  }
1248
1422
  /**
1249
1423
  * Build the session context (what gets sent to the LLM).
1250
1424
  * Uses tree traversal from current leaf.
1251
1425
  */
1252
1426
  buildSessionContext() {
1253
- return buildSessionContext(this.getEntries(), this.leafId, this.byId);
1427
+ let cached = this.sessionContextCache;
1428
+ if (!cached || cached.leafId !== this.leafId) {
1429
+ cached = {
1430
+ leafId: this.leafId,
1431
+ context: buildSessionContext(this.entries, this.leafId, this.byId),
1432
+ };
1433
+ this.sessionContextCache = cached;
1434
+ }
1435
+ return cloneSessionContext(cached.context);
1254
1436
  }
1255
1437
  /**
1256
1438
  * Get session header.
@@ -1276,6 +1458,94 @@ export class SessionManager {
1276
1458
  getEntryCount() {
1277
1459
  return this.entries.length;
1278
1460
  }
1461
+ /**
1462
+ * Release large own payload properties from one already-persisted message without reopening the
1463
+ * session. Calling this immediately after append advances the bounded file-location cursor only
1464
+ * across the new suffix; cold getters restore the exact property from that canonical JSONL line.
1465
+ */
1466
+ releasePersistedMessagePayload(entryId) {
1467
+ const entry = this.byId.get(entryId);
1468
+ if (!entry || entry.type !== "message") {
1469
+ throw new TypeError(`Session entry ${entryId} is not a persisted message.`);
1470
+ }
1471
+ if (!this.persist || !this.flushed || !this.sessionFile) {
1472
+ throw new Error(`Session message ${entryId} is not durably persisted.`);
1473
+ }
1474
+ if (this.persistenceStateUncertain) {
1475
+ throw new Error("Session persistence state is uncertain after a failed write; reopen the session file before releasing payloads.");
1476
+ }
1477
+ const properties = this._releasableMessageProperties(entry);
1478
+ if (properties.length === 0)
1479
+ return;
1480
+ const location = this._getEntryFileLocation(entryId);
1481
+ if (!location)
1482
+ throw new Error(`Persisted session message ${entryId} has no canonical file location.`);
1483
+ for (const property of properties)
1484
+ this._releaseMessageProperty(entry, property);
1485
+ }
1486
+ /**
1487
+ * Visit one bounded entry range without allocating an entries slice or cloning payloads.
1488
+ * Entries are borrowed read-only for the callback duration. `persistedBytes` is a conservative
1489
+ * serialized-size signal for disk-backed cold payloads; infinity means the payload cannot be
1490
+ * restored through the bounded index and consumers must skip it.
1491
+ */
1492
+ visitEntries(startIndex, maxEntries, visitor) {
1493
+ if (!Number.isSafeInteger(startIndex) || startIndex < 0 || startIndex > this.entries.length) {
1494
+ throw new TypeError("Session entry visit start index is invalid.");
1495
+ }
1496
+ if (!Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > MAX_SESSION_ENTRY_VISIT_COUNT) {
1497
+ throw new TypeError(`Session entry visit count must be from 1 through ${MAX_SESSION_ENTRY_VISIT_COUNT}.`);
1498
+ }
1499
+ const endIndex = Math.min(this.entries.length, startIndex + maxEntries);
1500
+ const visitedColdPayloadEntryIds = new Set();
1501
+ for (let index = startIndex; index < endIndex; index += 1) {
1502
+ const entryId = this.entries[index].id;
1503
+ if (this.coldPayloadEntryIds.has(entryId))
1504
+ visitedColdPayloadEntryIds.add(entryId);
1505
+ }
1506
+ if (visitedColdPayloadEntryIds.size > 0)
1507
+ this._ensureEntryFileLocations(visitedColdPayloadEntryIds);
1508
+ for (let index = startIndex; index < endIndex; index += 1) {
1509
+ const entry = this.entries[index];
1510
+ const coldPayload = this.coldPayloadEntryIds.has(entry.id);
1511
+ const persistedBytes = coldPayload
1512
+ ? (this.entryFileLocations.get(entry.id)?.length ?? Number.POSITIVE_INFINITY)
1513
+ : undefined;
1514
+ visitor(entry, index, persistedBytes);
1515
+ }
1516
+ return endIndex;
1517
+ }
1518
+ /**
1519
+ * Read only a bounded UTF-8 prefix of one persisted entry's canonical JSONL record.
1520
+ * This lets owners classify cold payloads without invoking a disk-backed message getter or
1521
+ * allocating the complete retained line. Returns undefined for in-memory or unknown entries.
1522
+ */
1523
+ readEntryJsonPrefix(entryId, maxBytes) {
1524
+ if (!this.sessionFile || !this.byId.has(entryId))
1525
+ return undefined;
1526
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > ENTRY_ID_PREFIX_BYTES) {
1527
+ throw new TypeError(`Session entry JSON prefix bytes must be from 1 through ${ENTRY_ID_PREFIX_BYTES}.`);
1528
+ }
1529
+ const location = this._getEntryFileLocation(entryId);
1530
+ if (!location)
1531
+ return undefined;
1532
+ const bytesToRead = Math.min(maxBytes, location.length);
1533
+ const buffer = Buffer.allocUnsafe(bytesToRead);
1534
+ const fd = openSync(this.sessionFile, "r");
1535
+ let bytesRead = 0;
1536
+ try {
1537
+ while (bytesRead < bytesToRead) {
1538
+ const count = readSync(fd, buffer, bytesRead, bytesToRead - bytesRead, location.offset + bytesRead);
1539
+ if (count === 0)
1540
+ break;
1541
+ bytesRead += count;
1542
+ }
1543
+ }
1544
+ finally {
1545
+ closeSync(fd);
1546
+ }
1547
+ return buffer.subarray(0, bytesRead).toString("utf8");
1548
+ }
1279
1549
  /**
1280
1550
  * Return recent user-entered prompt text for input recall, oldest entry first.
1281
1551
  * This walks only the active branch and does not build/render full session context.
@@ -1285,15 +1555,16 @@ export class SessionManager {
1285
1555
  if (maxEntries === 0)
1286
1556
  return [];
1287
1557
  const history = [];
1288
- let entry = this.leafId ? this.byId.get(this.leafId) : undefined;
1289
- while (entry && history.length < maxEntries) {
1558
+ const leaf = this.leafId ? this.byId.get(this.leafId) : undefined;
1559
+ visitSessionAncestry(leaf, this.byId, (entry) => {
1290
1560
  if (entry.type === "message" && entry.message.role === "user") {
1291
1561
  const text = this.getUserInputText(entry.message);
1292
1562
  if (text)
1293
1563
  history.push(text);
1294
1564
  }
1295
- entry = entry.parentId ? this.byId.get(entry.parentId) : undefined;
1296
- }
1565
+ if (history.length >= maxEntries)
1566
+ return false;
1567
+ });
1297
1568
  return history.reverse();
1298
1569
  }
1299
1570
  getUserInputText(message) {
@@ -1366,6 +1637,7 @@ export class SessionManager {
1366
1637
  if (!this.byId.has(branchFromId)) {
1367
1638
  throw new Error(`Entry ${branchFromId} not found`);
1368
1639
  }
1640
+ this._invalidateSessionContextCache();
1369
1641
  this.leafId = branchFromId;
1370
1642
  }
1371
1643
  /**
@@ -1374,6 +1646,7 @@ export class SessionManager {
1374
1646
  * Use this when navigating to re-edit the first user message.
1375
1647
  */
1376
1648
  resetLeaf() {
1649
+ this._invalidateSessionContextCache();
1377
1650
  this.leafId = null;
1378
1651
  }
1379
1652
  /**
@@ -1385,6 +1658,7 @@ export class SessionManager {
1385
1658
  if (branchFromId !== null && !this.byId.has(branchFromId)) {
1386
1659
  throw new Error(`Entry ${branchFromId} not found`);
1387
1660
  }
1661
+ this._invalidateSessionContextCache();
1388
1662
  this.leafId = branchFromId;
1389
1663
  const entry = {
1390
1664
  type: "branch_summary",
@@ -1464,7 +1738,9 @@ export class SessionManager {
1464
1738
  if (this.persist && hasAssistant) {
1465
1739
  // Keep the source manager active while cold compacted getters serialize into the copy.
1466
1740
  branched._rewriteFile();
1467
- branched.flushed = true;
1741
+ // Reload through the branch owner so every disk-backed getter closes over the new
1742
+ // canonical file instead of retaining the source manager's payload arena.
1743
+ branched.setSessionFile(newSessionFile);
1468
1744
  }
1469
1745
  else {
1470
1746
  branched.flushed = false;
@@ -1479,6 +1755,7 @@ export class SessionManager {
1479
1755
  */
1480
1756
  createBranchedSession(leafId) {
1481
1757
  const branched = this.createBranchedSessionManager(leafId);
1758
+ this._invalidateSessionContextCache();
1482
1759
  this.sessionId = branched.sessionId;
1483
1760
  this.sessionFile = branched.sessionFile;
1484
1761
  this.flushed = branched.flushed;
@@ -1488,6 +1765,7 @@ export class SessionManager {
1488
1765
  this.labelsById = branched.labelsById;
1489
1766
  this.labelTimestampsById = branched.labelTimestampsById;
1490
1767
  this.leafId = branched.leafId;
1768
+ this.persistenceStateUncertain = branched.persistenceStateUncertain;
1491
1769
  this.coldPayloadEntryIds.clear();
1492
1770
  for (const id of branched.coldPayloadEntryIds)
1493
1771
  this.coldPayloadEntryIds.add(id);