@caupulican/pi-agent-core 0.81.27 → 0.81.30

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.
@@ -6,6 +6,7 @@ import { createInterface } from "node:readline";
6
6
  import { StringDecoder } from "node:string_decoder";
7
7
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "../messages.js";
8
8
  import { normalizePath, resolvePath } from "../utils/paths.js";
9
+ import { StreamingLineDecoder } from "../utils/streaming-lines.js";
9
10
  import { uuidv7 } from "../uuid.js";
10
11
  import { compactToolResultDetailsForRetention } from "./message-retention.js";
11
12
  export const CURRENT_SESSION_VERSION = 3;
@@ -145,13 +146,14 @@ export function buildSessionContext(entries, leafId, byId) {
145
146
  if (!leaf) {
146
147
  return { messages: [], thinkingLevel: "off", model: null };
147
148
  }
148
- // Walk from leaf to root, collecting path
149
+ // Walk from leaf to root, then reverse once. Repeated front insertion makes long branches quadratic.
149
150
  const path = [];
150
151
  let current = leaf;
151
152
  while (current) {
152
- path.unshift(current);
153
+ path.push(current);
153
154
  current = current.parentId ? byId.get(current.parentId) : undefined;
154
155
  }
156
+ path.reverse();
155
157
  // Extract settings and find compaction
156
158
  let thinkingLevel = "off";
157
159
  let model = null;
@@ -273,45 +275,28 @@ export function loadEntriesFromFile(filePath, options) {
273
275
  const fd = openSync(resolvedFilePath, "r");
274
276
  try {
275
277
  const decoder = new StringDecoder("utf8");
278
+ const lines = new StreamingLineDecoder(maxLineChars, { overflow: "skip", lineEndings: "lf" });
276
279
  const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
277
- let pending = "";
278
- let skippingOversizedLine = false;
279
280
  while (true) {
280
281
  const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
281
282
  if (bytesRead === 0)
282
283
  break;
283
- pending += decoder.write(buffer.subarray(0, bytesRead));
284
- if (skippingOversizedLine) {
285
- const resumeIndex = pending.indexOf("\n");
286
- if (resumeIndex === -1) {
287
- pending = "";
288
- continue;
289
- }
290
- pending = pending.slice(resumeIndex + 1);
291
- skippingOversizedLine = false;
292
- }
293
- let lineStart = 0;
294
- let newlineIndex = pending.indexOf("\n", lineStart);
295
- while (newlineIndex !== -1) {
296
- if (newlineIndex - lineStart <= maxLineChars) {
297
- const entry = parseSessionEntryLine(pending.slice(lineStart, newlineIndex));
298
- if (entry)
299
- entries.push(entry);
300
- }
301
- lineStart = newlineIndex + 1;
302
- newlineIndex = pending.indexOf("\n", lineStart);
303
- }
304
- pending = pending.slice(lineStart);
305
- if (pending.length > maxLineChars) {
306
- skippingOversizedLine = true;
307
- pending = "";
284
+ for (const line of lines.push(decoder.write(buffer.subarray(0, bytesRead)))) {
285
+ const entry = parseSessionEntryLine(line);
286
+ if (entry)
287
+ entries.push(entry);
308
288
  }
309
289
  }
310
- pending += decoder.end();
311
- if (!skippingOversizedLine && pending.length <= maxLineChars) {
312
- const finalEntry = parseSessionEntryLine(pending);
313
- if (finalEntry)
314
- entries.push(finalEntry);
290
+ for (const line of lines.push(decoder.end())) {
291
+ const entry = parseSessionEntryLine(line);
292
+ if (entry)
293
+ entries.push(entry);
294
+ }
295
+ const finalLine = lines.finish();
296
+ if (finalLine !== undefined) {
297
+ const entry = parseSessionEntryLine(finalLine);
298
+ if (entry)
299
+ entries.push(entry);
315
300
  }
316
301
  }
317
302
  finally {
@@ -326,6 +311,87 @@ export function loadEntriesFromFile(filePath, options) {
326
311
  }
327
312
  return entries;
328
313
  }
314
+ const ENTRY_ID_PREFIX_BYTES = 64 * 1024;
315
+ const COMPACTED_PAYLOAD_RELEASE_MIN_CHARS = 16 * 1024;
316
+ function indexSessionEntryFileLocations(filePath, startOffset, locations, retainedIds) {
317
+ const fd = openSync(filePath, "r");
318
+ const buffer = Buffer.allocUnsafe(SESSION_READ_BUFFER_SIZE);
319
+ let position = startOffset;
320
+ let lineOffset = startOffset;
321
+ let lineLength = 0;
322
+ let prefixLength = 0;
323
+ let prefixParts = [];
324
+ const finishLine = () => {
325
+ if (prefixLength > 0) {
326
+ const prefix = prefixParts.length === 1
327
+ ? prefixParts[0].toString("utf8")
328
+ : Buffer.concat(prefixParts, prefixLength).toString("utf8");
329
+ const id = /"id"\s*:\s*"([A-Za-z0-9_-]+)"/.exec(prefix)?.[1];
330
+ if (id && retainedIds.has(id))
331
+ locations.set(id, { offset: lineOffset, length: lineLength });
332
+ }
333
+ lineLength = 0;
334
+ prefixLength = 0;
335
+ prefixParts = [];
336
+ };
337
+ try {
338
+ while (true) {
339
+ const bytesRead = readSync(fd, buffer, 0, buffer.length, position);
340
+ if (bytesRead === 0)
341
+ break;
342
+ const chunkOffset = position;
343
+ position += bytesRead;
344
+ let segmentStart = 0;
345
+ while (segmentStart < bytesRead) {
346
+ const newlineIndex = buffer.indexOf(0x0a, segmentStart);
347
+ const segmentEnd = newlineIndex === -1 || newlineIndex >= bytesRead ? bytesRead : newlineIndex;
348
+ const segmentLength = segmentEnd - segmentStart;
349
+ lineLength += segmentLength;
350
+ const prefixRemaining = ENTRY_ID_PREFIX_BYTES - prefixLength;
351
+ if (prefixRemaining > 0 && segmentLength > 0) {
352
+ const retainedLength = Math.min(prefixRemaining, segmentLength);
353
+ prefixParts.push(Buffer.from(buffer.subarray(segmentStart, segmentStart + retainedLength)));
354
+ prefixLength += retainedLength;
355
+ }
356
+ if (newlineIndex === -1 || newlineIndex >= bytesRead)
357
+ break;
358
+ finishLine();
359
+ lineOffset = chunkOffset + newlineIndex + 1;
360
+ segmentStart = newlineIndex + 1;
361
+ }
362
+ }
363
+ if (lineLength > 0)
364
+ finishLine();
365
+ return position;
366
+ }
367
+ finally {
368
+ closeSync(fd);
369
+ }
370
+ }
371
+ function retainedStringChars(value, stopAfter) {
372
+ const pending = [value];
373
+ const seen = new WeakSet();
374
+ let chars = 0;
375
+ while (pending.length > 0) {
376
+ const item = pending.pop();
377
+ if (typeof item === "string") {
378
+ chars += item.length;
379
+ if (chars >= stopAfter)
380
+ return chars;
381
+ continue;
382
+ }
383
+ if (!item || typeof item !== "object" || seen.has(item))
384
+ continue;
385
+ seen.add(item);
386
+ if (Array.isArray(item)) {
387
+ pending.push(...item);
388
+ }
389
+ else {
390
+ pending.push(...Object.values(item));
391
+ }
392
+ }
393
+ return chars;
394
+ }
329
395
  function readSessionHeader(filePath) {
330
396
  try {
331
397
  const fd = openSync(filePath, "r");
@@ -603,10 +669,14 @@ export class SessionManager {
603
669
  persist;
604
670
  flushed = false;
605
671
  fileEntries = [];
672
+ entries = [];
606
673
  byId = new Map();
607
674
  labelsById = new Map();
608
675
  labelTimestampsById = new Map();
609
676
  leafId = null;
677
+ entryFileLocations = new Map();
678
+ coldPayloadEntryIds = new Set();
679
+ indexedSessionFileBytes = 0;
610
680
  constructor(cwd, sessionDir, sessionFile, persist, agentDir, newSessionOptions) {
611
681
  this.cwd = resolvePath(cwd);
612
682
  this.sessionDir = normalizePath(sessionDir);
@@ -624,6 +694,7 @@ export class SessionManager {
624
694
  }
625
695
  /** Switch to a different session file (used for resume and branching) */
626
696
  setSessionFile(sessionFile) {
697
+ this._resetEntryFileIndex(true);
627
698
  this.sessionFile = resolvePath(sessionFile);
628
699
  if (existsSync(this.sessionFile)) {
629
700
  this.fileEntries = loadEntriesFromFile(this.sessionFile);
@@ -650,6 +721,7 @@ export class SessionManager {
650
721
  }
651
722
  this._buildIndex();
652
723
  this.flushed = true;
724
+ this._releaseExistingCompactedMessagePayloads();
653
725
  }
654
726
  else {
655
727
  const explicitPath = this.sessionFile;
@@ -672,17 +744,27 @@ export class SessionManager {
672
744
  parentSession: options?.parentSession,
673
745
  };
674
746
  this.fileEntries = [header];
747
+ this.entries = [];
675
748
  this.byId.clear();
676
749
  this.labelsById.clear();
750
+ this.labelTimestampsById.clear();
677
751
  this.leafId = null;
678
752
  this.flushed = false;
753
+ this._resetEntryFileIndex(true);
679
754
  if (this.persist) {
680
755
  const fileTimestamp = timestamp.replace(/[:.]/g, "-");
681
756
  this.sessionFile = join(this.getSessionDir(), `${fileTimestamp}_${this.sessionId}.jsonl`);
682
757
  }
683
758
  return this.sessionFile;
684
759
  }
760
+ _resetEntryFileIndex(clearColdPayloads = false) {
761
+ this.entryFileLocations.clear();
762
+ this.indexedSessionFileBytes = 0;
763
+ if (clearColdPayloads)
764
+ this.coldPayloadEntryIds.clear();
765
+ }
685
766
  _buildIndex() {
767
+ this.entries = [];
686
768
  this.byId.clear();
687
769
  this.labelsById.clear();
688
770
  this.labelTimestampsById.clear();
@@ -690,6 +772,7 @@ export class SessionManager {
690
772
  for (const entry of this.fileEntries) {
691
773
  if (entry.type === "session")
692
774
  continue;
775
+ this.entries.push(entry);
693
776
  this.byId.set(entry.id, entry);
694
777
  this.leafId = entry.id;
695
778
  if (entry.type === "label") {
@@ -703,14 +786,18 @@ export class SessionManager {
703
786
  }
704
787
  }
705
788
  }
789
+ for (const id of this.coldPayloadEntryIds) {
790
+ if (!this.byId.has(id))
791
+ this.coldPayloadEntryIds.delete(id);
792
+ }
706
793
  }
707
- _rewriteFile() {
708
- if (!this.persist || !this.sessionFile)
794
+ _rewriteFile(targetFile = this.sessionFile) {
795
+ if (!this.persist || !targetFile)
709
796
  return;
710
797
  // Write-then-rename keeps the session file valid at every instant: truncating
711
798
  // in place leaves a torn-file window for crashes or a concurrent process
712
799
  // appending to the same session.
713
- const tempFile = `${this.sessionFile}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
800
+ const tempFile = `${targetFile}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
714
801
  const fd = openSync(tempFile, "wx");
715
802
  try {
716
803
  for (const entry of this.fileEntries) {
@@ -720,7 +807,9 @@ export class SessionManager {
720
807
  finally {
721
808
  closeSync(fd);
722
809
  }
723
- renameSync(tempFile, this.sessionFile);
810
+ renameSync(tempFile, targetFile);
811
+ if (targetFile === this.sessionFile)
812
+ this._resetEntryFileIndex();
724
813
  }
725
814
  isPersisted() {
726
815
  return this.persist;
@@ -740,6 +829,132 @@ export class SessionManager {
740
829
  getSessionFile() {
741
830
  return this.sessionFile;
742
831
  }
832
+ _ensureEntryFileLocations(retainedIds) {
833
+ if (!this.sessionFile || !existsSync(this.sessionFile))
834
+ return;
835
+ const fileBytes = statSync(this.sessionFile).size;
836
+ if (fileBytes < this.indexedSessionFileBytes)
837
+ this._resetEntryFileIndex();
838
+ if (fileBytes === this.indexedSessionFileBytes)
839
+ return;
840
+ const idsToIndex = new Set(this.coldPayloadEntryIds);
841
+ for (const id of retainedIds)
842
+ idsToIndex.add(id);
843
+ this.indexedSessionFileBytes = indexSessionEntryFileLocations(this.sessionFile, this.indexedSessionFileBytes, this.entryFileLocations, idsToIndex);
844
+ }
845
+ _readCompactedMessageProperty(entryId, property) {
846
+ if (!this.sessionFile)
847
+ throw new Error(`Compacted session payload ${entryId} is unavailable`);
848
+ let location = this.entryFileLocations.get(entryId);
849
+ if (!location) {
850
+ this._resetEntryFileIndex();
851
+ this._ensureEntryFileLocations(new Set([entryId]));
852
+ location = this.entryFileLocations.get(entryId);
853
+ }
854
+ if (!location)
855
+ throw new Error(`Compacted session payload ${entryId} is unavailable`);
856
+ const fd = openSync(this.sessionFile, "r");
857
+ const buffer = Buffer.allocUnsafe(location.length);
858
+ let bytesRead = 0;
859
+ try {
860
+ while (bytesRead < buffer.length) {
861
+ const count = readSync(fd, buffer, bytesRead, buffer.length - bytesRead, location.offset + bytesRead);
862
+ if (count === 0)
863
+ break;
864
+ bytesRead += count;
865
+ }
866
+ }
867
+ finally {
868
+ closeSync(fd);
869
+ }
870
+ const entry = parseSessionEntryLine(buffer.subarray(0, bytesRead).toString("utf8"));
871
+ if (!entry || entry.type !== "message" || entry.id !== entryId) {
872
+ throw new Error(`Compacted session payload ${entryId} could not be restored`);
873
+ }
874
+ return entry.message[property];
875
+ }
876
+ _releaseMessageProperty(entry, property) {
877
+ const message = entry.message;
878
+ const descriptor = Object.getOwnPropertyDescriptor(message, property);
879
+ if (!descriptor || descriptor.get || !("value" in descriptor))
880
+ return;
881
+ if (retainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) <
882
+ COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) {
883
+ return;
884
+ }
885
+ const entryId = entry.id;
886
+ const enumerable = descriptor.enumerable;
887
+ this.coldPayloadEntryIds.add(entryId);
888
+ Object.defineProperty(message, property, {
889
+ configurable: true,
890
+ enumerable,
891
+ get: () => this._readCompactedMessageProperty(entryId, property),
892
+ set: (value) => {
893
+ Object.defineProperty(message, property, {
894
+ configurable: true,
895
+ enumerable,
896
+ writable: true,
897
+ value,
898
+ });
899
+ },
900
+ });
901
+ }
902
+ _releaseExistingCompactedMessagePayloads() {
903
+ let current = this.leafId ? this.byId.get(this.leafId) : undefined;
904
+ while (current) {
905
+ if (current.type === "compaction") {
906
+ this._releaseCompactedMessagePayloads(current.firstKeptEntryId, current.parentId);
907
+ return;
908
+ }
909
+ current = current.parentId ? this.byId.get(current.parentId) : undefined;
910
+ }
911
+ }
912
+ _releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId) {
913
+ if (!this.persist || !this.flushed || !this.sessionFile)
914
+ return;
915
+ const keptEntryIds = new Set();
916
+ let currentId = compactionParentId;
917
+ let foundFirstKept = false;
918
+ while (currentId) {
919
+ keptEntryIds.add(currentId);
920
+ if (currentId === firstKeptEntryId) {
921
+ foundFirstKept = true;
922
+ break;
923
+ }
924
+ currentId = this.byId.get(currentId)?.parentId ?? null;
925
+ }
926
+ if (!foundFirstKept)
927
+ return;
928
+ const retainedIds = new Set();
929
+ const releases = [];
930
+ for (const entry of this.entries) {
931
+ if (entry.type !== "message")
932
+ continue;
933
+ const message = entry.message;
934
+ const properties = [];
935
+ for (const property of ["content", "output"]) {
936
+ const descriptor = Object.getOwnPropertyDescriptor(message, property);
937
+ if (descriptor &&
938
+ !descriptor.get &&
939
+ "value" in descriptor &&
940
+ retainedStringChars(descriptor.value, COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) >=
941
+ COMPACTED_PAYLOAD_RELEASE_MIN_CHARS) {
942
+ retainedIds.add(entry.id);
943
+ if (!keptEntryIds.has(entry.id))
944
+ properties.push(property);
945
+ }
946
+ }
947
+ if (properties.length > 0)
948
+ releases.push({ entry, properties });
949
+ }
950
+ this._ensureEntryFileLocations(retainedIds);
951
+ for (const release of releases) {
952
+ if (!this.entryFileLocations.has(release.entry.id))
953
+ continue;
954
+ for (const property of release.properties)
955
+ this._releaseMessageProperty(release.entry, property);
956
+ }
957
+ }
743
958
  _persist(entry) {
744
959
  if (!this.persist || !this.sessionFile)
745
960
  return;
@@ -772,6 +987,7 @@ export class SessionManager {
772
987
  }
773
988
  _appendEntry(entry) {
774
989
  this.fileEntries.push(entry);
990
+ this.entries.push(entry);
775
991
  this.byId.set(entry.id, entry);
776
992
  this.leafId = entry.id;
777
993
  this._persist(entry);
@@ -820,10 +1036,11 @@ export class SessionManager {
820
1036
  }
821
1037
  /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
822
1038
  appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook) {
1039
+ const compactionParentId = this.leafId;
823
1040
  const entry = {
824
1041
  type: "compaction",
825
1042
  id: generateId(this.byId),
826
- parentId: this.leafId,
1043
+ parentId: compactionParentId,
827
1044
  timestamp: new Date().toISOString(),
828
1045
  summary,
829
1046
  firstKeptEntryId,
@@ -832,6 +1049,7 @@ export class SessionManager {
832
1049
  fromHook,
833
1050
  };
834
1051
  this._appendEntry(entry);
1052
+ this._releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId);
835
1053
  return entry.id;
836
1054
  }
837
1055
  /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
@@ -988,11 +1206,16 @@ export class SessionManager {
988
1206
  * change the leaf pointer. Entries cannot be modified or deleted.
989
1207
  */
990
1208
  getEntries() {
991
- return this.fileEntries.filter((e) => e.type !== "session");
1209
+ return this.entries.slice();
1210
+ }
1211
+ /** Entries appended at or after `startIndex`, returned as a defensive shallow slice. */
1212
+ getEntriesSince(startIndex) {
1213
+ const normalizedStart = Number.isFinite(startIndex) ? Math.max(0, Math.floor(startIndex)) : 0;
1214
+ return this.entries.slice(normalizedStart);
992
1215
  }
993
1216
  /** Return current session entry count without allocating a defensive entries array. */
994
1217
  getEntryCount() {
995
- return this.byId.size;
1218
+ return this.entries.length;
996
1219
  }
997
1220
  /**
998
1221
  * Return recent user-entered prompt text for input recall, oldest entry first.
@@ -1170,7 +1393,6 @@ export class SessionManager {
1170
1393
  }
1171
1394
  this.fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
1172
1395
  this.sessionId = newSessionId;
1173
- this.sessionFile = newSessionFile;
1174
1396
  this._buildIndex();
1175
1397
  // Only write the file now if it contains an assistant message.
1176
1398
  // Otherwise defer to _persist(), which creates the file on the
@@ -1179,12 +1401,15 @@ export class SessionManager {
1179
1401
  // no-assistant guard later resets flushed to false.
1180
1402
  const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
1181
1403
  if (hasAssistant) {
1182
- this._rewriteFile();
1404
+ // Keep the source file active while cold compacted getters are serialized into the branch.
1405
+ this._rewriteFile(newSessionFile);
1183
1406
  this.flushed = true;
1184
1407
  }
1185
1408
  else {
1186
1409
  this.flushed = false;
1187
1410
  }
1411
+ this.sessionFile = newSessionFile;
1412
+ this._resetEntryFileIndex();
1188
1413
  return newSessionFile;
1189
1414
  }
1190
1415
  // In-memory mode: replace current session with the path + labels
@@ -1226,9 +1451,8 @@ export class SessionManager {
1226
1451
  */
1227
1452
  static open(path, agentDir, sessionDir, cwdOverride) {
1228
1453
  const resolvedPath = resolvePath(path);
1229
- // Extract cwd from session header if possible, otherwise use process.cwd()
1230
- const entries = loadEntriesFromFile(resolvedPath);
1231
- const header = entries.find((e) => e.type === "session");
1454
+ // Read only the header here; the constructor performs the one full session load.
1455
+ const header = readSessionHeader(resolvedPath);
1232
1456
  const cwd = cwdOverride ?? header?.cwd ?? process.cwd();
1233
1457
  // If no sessionDir provided, derive from file's parent directory
1234
1458
  const dir = sessionDir ? normalizePath(sessionDir) : resolve(resolvedPath, "..");