@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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 &&
|
|
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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.
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
813
|
-
|
|
896
|
+
labelsById.set(entry.targetId, entry.label);
|
|
897
|
+
labelTimestampsById.set(entry.targetId, entry.timestamp);
|
|
814
898
|
}
|
|
815
899
|
else {
|
|
816
|
-
|
|
817
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
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
|
-
|
|
942
|
-
|
|
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
|
-
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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
|
-
|
|
1099
|
+
return false;
|
|
961
1100
|
}
|
|
962
|
-
|
|
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
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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.
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
path.push(
|
|
1226
|
-
|
|
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
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|