@caupulican/pi-agent-core 0.93.17 → 0.93.19

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.
Files changed (34) hide show
  1. package/dist/agent-loop.d.ts.map +1 -1
  2. package/dist/agent-loop.js +50 -17
  3. package/dist/agent-loop.js.map +1 -1
  4. package/dist/agent.d.ts +5 -1
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +4 -0
  7. package/dist/agent.js.map +1 -1
  8. package/dist/compaction/compaction.d.ts +22 -8
  9. package/dist/compaction/compaction.d.ts.map +1 -1
  10. package/dist/compaction/compaction.js +96 -27
  11. package/dist/compaction/compaction.js.map +1 -1
  12. package/dist/provider-request-estimator.d.ts.map +1 -1
  13. package/dist/provider-request-estimator.js +27 -17
  14. package/dist/provider-request-estimator.js.map +1 -1
  15. package/dist/provider-request-planner.d.ts +7 -1
  16. package/dist/provider-request-planner.d.ts.map +1 -1
  17. package/dist/provider-request-planner.js +20 -1
  18. package/dist/provider-request-planner.js.map +1 -1
  19. package/dist/session/lifecycle-ledger.d.ts +173 -0
  20. package/dist/session/lifecycle-ledger.d.ts.map +1 -0
  21. package/dist/session/lifecycle-ledger.js +648 -0
  22. package/dist/session/lifecycle-ledger.js.map +1 -0
  23. package/dist/session/session-manager.d.ts +61 -5
  24. package/dist/session/session-manager.d.ts.map +1 -1
  25. package/dist/session/session-manager.js +487 -66
  26. package/dist/session/session-manager.js.map +1 -1
  27. package/dist/session/session-tree.d.ts +10 -0
  28. package/dist/session/session-tree.d.ts.map +1 -0
  29. package/dist/session/session-tree.js +33 -0
  30. package/dist/session/session-tree.js.map +1 -0
  31. package/dist/types.d.ts +44 -0
  32. package/dist/types.d.ts.map +1 -1
  33. package/dist/types.js.map +1 -1
  34. package/package.json +2 -2
@@ -8,27 +8,16 @@ import { StreamingLineDecoder } from "@caupulican/pi-ai/streaming-lines";
8
8
  import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage, } from "../messages.js";
9
9
  import { normalizePath, resolvePath } from "../utils/paths.js";
10
10
  import { uuidv7 } from "../uuid.js";
11
+ import { encodeSessionEntry, indexSessionLifecycle, inspectSessionLifecycle, isSessionLifecycleEntry, planSessionLifecycleRepair, sessionLifecycleToolIdentityKey, validateLoadedLifecycleEntries, validateSessionLifecycleEntry, } from "./lifecycle-ledger.js";
11
12
  import { compactToolResultDetailsForRetention } from "./message-retention.js";
12
- export const CURRENT_SESSION_VERSION = 3;
13
+ import { invalidSessionParentCycle, visitSessionAncestry } from "./session-tree.js";
14
+ export * from "./lifecycle-ledger.js";
15
+ export const CURRENT_SESSION_VERSION = 4;
13
16
  /** Maximum borrowed entries one non-copying SessionManager visit may inspect. */
14
17
  export const MAX_SESSION_ENTRY_VISIT_COUNT = 1_024;
18
+ /** Maximum normal message/custom-message records in one atomic append batch. */
19
+ export const MAX_SESSION_MESSAGE_BATCH_ENTRIES = 256;
15
20
  const MAX_SESSION_LINEAGE_DEPTH = 64;
16
- function invalidSessionParentCycle(entryId) {
17
- return new Error(`Invalid session entry graph: parent cycle detected at entry "${entryId}".`);
18
- }
19
- /** Visit leaf-to-root ancestry through one bounded, cycle-rejecting implementation path. */
20
- function visitSessionAncestry(start, byId, visitor) {
21
- let current = start;
22
- // One external start plus every indexed node is the longest possible acyclic walk.
23
- let remainingEntries = byId.size + 1;
24
- while (current) {
25
- if (remainingEntries-- === 0)
26
- throw invalidSessionParentCycle(current.id);
27
- if (visitor(current) === false)
28
- return;
29
- current = current.parentId ? byId.get(current.parentId) : undefined;
30
- }
31
- }
32
21
  const synthesizedSessionContextMessages = new WeakSet();
33
22
  function retainSynthesizedSessionContextMessage(message) {
34
23
  synthesizedSessionContextMessages.add(message);
@@ -62,6 +51,9 @@ function generateId(byId) {
62
51
  // Fallback to full UUID if somehow we have collisions
63
52
  return randomUUID();
64
53
  }
54
+ function assistantToolIdentityKey(assistantMessageEntryId, callId) {
55
+ return JSON.stringify([assistantMessageEntryId, callId]);
56
+ }
65
57
  /** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
66
58
  function migrateV1ToV2(entries) {
67
59
  const ids = new Set();
@@ -104,6 +96,13 @@ function migrateV2ToV3(entries) {
104
96
  }
105
97
  }
106
98
  }
99
+ /** Migrate v3 → v4: reserve the version for the typed lifecycle ledger. */
100
+ function migrateV3ToV4(entries) {
101
+ for (const entry of entries) {
102
+ if (entry.type === "session")
103
+ entry.version = 4;
104
+ }
105
+ }
107
106
  /**
108
107
  * Run all necessary migrations to bring entries to current version.
109
108
  * Mutates entries in place. Returns true if any migration was applied.
@@ -117,6 +116,8 @@ function migrateToCurrentVersion(entries) {
117
116
  migrateV1ToV2(entries);
118
117
  if (version < 3)
119
118
  migrateV2ToV3(entries);
119
+ if (version < 4)
120
+ migrateV3ToV4(entries);
120
121
  return true;
121
122
  }
122
123
  /** Exported for testing */
@@ -201,11 +202,9 @@ export function buildSessionContext(entries, leafId, byId) {
201
202
  compaction = entry;
202
203
  }
203
204
  }
204
- // Build messages and collect corresponding entries
205
- // When there's a compaction, we need to:
206
- // 1. Emit summary first (entry = compaction)
207
- // 2. Emit kept messages (from firstKeptEntryId up to compaction)
208
- // 3. Emit messages after compaction
205
+ // Build messages and collect corresponding entries. Standard compaction emits the summary followed
206
+ // by a contiguous recent tail. Session replacement instead retains one sparse original-user anchor
207
+ // before the summary, matching providers whose compactor atomically replaces the live transcript.
209
208
  const messages = [];
210
209
  const appendMessage = (entry) => {
211
210
  if (entry.type === "message") {
@@ -219,19 +218,28 @@ export function buildSessionContext(entries, leafId, byId) {
219
218
  }
220
219
  };
221
220
  if (compaction) {
222
- // Emit summary first
223
- messages.push(retainSynthesizedSessionContextMessage(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)));
224
221
  // Find compaction index in path
225
222
  const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === compaction.id);
226
- // Emit kept messages (before compaction, starting from firstKeptEntryId)
227
- let foundFirstKept = false;
228
- for (let i = 0; i < compactionIdx; i++) {
229
- const entry = path[i];
230
- if (entry.id === compaction.firstKeptEntryId) {
231
- foundFirstKept = true;
232
- }
233
- if (foundFirstKept) {
234
- appendMessage(entry);
223
+ const originalUserEntry = compaction.retention?.mode === "original-user"
224
+ ? path
225
+ .slice(0, compactionIdx)
226
+ .find((entry) => entry.id === compaction.retention?.userEntryId &&
227
+ entry.type === "message" &&
228
+ entry.message.role === "user")
229
+ : undefined;
230
+ if (originalUserEntry)
231
+ appendMessage(originalUserEntry);
232
+ messages.push(retainSynthesizedSessionContextMessage(createCompactionSummaryMessage(compaction.summary, compaction.tokensBefore, compaction.timestamp)));
233
+ if (!compaction.retention) {
234
+ // Emit the standard contiguous tail before compaction, starting from firstKeptEntryId.
235
+ let foundFirstKept = false;
236
+ for (let i = 0; i < compactionIdx; i++) {
237
+ const entry = path[i];
238
+ if (entry.id === compaction.firstKeptEntryId) {
239
+ foundFirstKept = true;
240
+ }
241
+ if (foundFirstKept)
242
+ appendMessage(entry);
235
243
  }
236
244
  }
237
245
  // Emit messages after compaction
@@ -360,7 +368,9 @@ function loadEntriesFromFileInternal(filePath, options, onEntry) {
360
368
  }
361
369
  /** Exported for testing */
362
370
  export function loadEntriesFromFile(filePath, options) {
363
- return loadEntriesFromFileInternal(filePath, options).entries;
371
+ const entries = loadEntriesFromFileInternal(filePath, options).entries;
372
+ validateLoadedLifecycleEntries(entries);
373
+ return entries;
364
374
  }
365
375
  const ENTRY_ID_PREFIX_BYTES = 64 * 1024;
366
376
  const COMPACTED_PAYLOAD_RELEASE_MIN_CHARS = 16 * 1024;
@@ -847,6 +857,7 @@ export class SessionManager {
847
857
  this.inheritedSessionIds = collectParentSessionIds(header?.parentSession, dirname(this.sessionFile));
848
858
  this._ensureEntryFileLocations(this.coldPayloadEntryIds);
849
859
  const migrated = migrateToCurrentVersion(this.fileEntries);
860
+ validateLoadedLifecycleEntries(this.fileEntries);
850
861
  // Validate and index the complete parent graph before a migration rewrite can
851
862
  // mutate the source file. Malformed cycles must fail synchronously on cold open.
852
863
  this._buildIndex();
@@ -888,6 +899,7 @@ export class SessionManager {
888
899
  this.labelsById.clear();
889
900
  this.labelTimestampsById.clear();
890
901
  this.leafId = null;
902
+ this.lifecycleActiveCache = undefined;
891
903
  this.flushed = false;
892
904
  this.persistenceStateUncertain = false;
893
905
  this._invalidateSessionContextCache();
@@ -960,6 +972,7 @@ export class SessionManager {
960
972
  this.labelsById = labelsById;
961
973
  this.labelTimestampsById = labelTimestampsById;
962
974
  this.leafId = leafId;
975
+ this.lifecycleActiveCache = undefined;
963
976
  for (const id of this.coldPayloadEntryIds) {
964
977
  if (!byId.has(id))
965
978
  this.coldPayloadEntryIds.delete(id);
@@ -981,7 +994,7 @@ export class SessionManager {
981
994
  const fd = openSync(tempFile, "wx");
982
995
  try {
983
996
  for (const entry of this.fileEntries) {
984
- writeFileSync(fd, `${JSON.stringify(entry)}\n`);
997
+ writeFileSync(fd, `${entry.type === "session" ? JSON.stringify(entry) : encodeSessionEntry(entry)}\n`);
985
998
  }
986
999
  }
987
1000
  finally {
@@ -1120,24 +1133,42 @@ export class SessionManager {
1120
1133
  const leaf = this.leafId ? this.byId.get(this.leafId) : undefined;
1121
1134
  visitSessionAncestry(leaf, this.byId, (current) => {
1122
1135
  if (current.type === "compaction") {
1123
- this._releaseCompactedMessagePayloads(current.firstKeptEntryId, current.parentId);
1136
+ this._releaseCompactedMessagePayloads(current.firstKeptEntryId, current.parentId, current.retention, current.id);
1124
1137
  return false;
1125
1138
  }
1126
1139
  });
1127
1140
  }
1128
- _releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId) {
1141
+ _releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId, retention, compactionEntryId) {
1129
1142
  if (!this.persist || !this.flushed || !this.sessionFile)
1130
1143
  return;
1131
1144
  const keptEntryIds = new Set();
1132
1145
  let foundFirstKept = false;
1133
1146
  const compactionParent = compactionParentId ? this.byId.get(compactionParentId) : undefined;
1134
- visitSessionAncestry(compactionParent, this.byId, (current) => {
1135
- keptEntryIds.add(current.id);
1136
- if (current.id === firstKeptEntryId) {
1137
- foundFirstKept = true;
1138
- return false;
1139
- }
1140
- });
1147
+ if (retention?.mode === "original-user") {
1148
+ visitSessionAncestry(compactionParent, this.byId, (current) => {
1149
+ if (current.id === retention.userEntryId && current.type === "message" && current.message.role === "user") {
1150
+ keptEntryIds.add(current.id);
1151
+ foundFirstKept = true;
1152
+ return false;
1153
+ }
1154
+ });
1155
+ const leaf = this.leafId ? this.byId.get(this.leafId) : undefined;
1156
+ visitSessionAncestry(leaf, this.byId, (current) => {
1157
+ if (current.id === compactionEntryId)
1158
+ return false;
1159
+ if (current.type === "message")
1160
+ keptEntryIds.add(current.id);
1161
+ });
1162
+ }
1163
+ else {
1164
+ visitSessionAncestry(compactionParent, this.byId, (current) => {
1165
+ keptEntryIds.add(current.id);
1166
+ if (current.id === firstKeptEntryId) {
1167
+ foundFirstKept = true;
1168
+ return false;
1169
+ }
1170
+ });
1171
+ }
1141
1172
  if (!foundFirstKept)
1142
1173
  return;
1143
1174
  const retainedIds = new Set();
@@ -1160,56 +1191,159 @@ export class SessionManager {
1160
1191
  this._releaseMessageProperty(release.entry, property);
1161
1192
  }
1162
1193
  }
1163
- _persist(entry) {
1194
+ _persistEntries(entries, encodedEntries) {
1164
1195
  if (!this.persist || !this.sessionFile)
1165
1196
  return;
1197
+ if (entries.length !== encodedEntries.length) {
1198
+ throw new Error("Session persistence encoding count does not match the entry batch.");
1199
+ }
1166
1200
  if (this.persistenceStateUncertain) {
1167
1201
  throw new Error("Session persistence state is uncertain after a failed write; reopen the session file or start a new session before appending.");
1168
1202
  }
1169
1203
  try {
1170
- const hasAssistant = (entry.type === "message" && entry.message.role === "assistant") ||
1171
- this.fileEntries.some((candidate) => candidate.type === "message" && candidate.message.role === "assistant");
1172
- if (!hasAssistant) {
1173
- if (this.flushed) {
1204
+ const hasAssistant = this.fileEntries.some((candidate) => candidate.type === "message" && candidate.message.role === "assistant") || entries.some((entry) => entry.type === "message" && entry.message.role === "assistant");
1205
+ const forceFlush = entries.some((entry) => isSessionLifecycleEntry(entry));
1206
+ if (!hasAssistant && !forceFlush) {
1207
+ if (this.flushed && encodedEntries.length > 0) {
1174
1208
  this._ensureSessionFileParent(this.sessionFile);
1175
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1209
+ appendFileSync(this.sessionFile, `${encodedEntries.join("\n")}\n`);
1176
1210
  }
1177
1211
  return;
1178
1212
  }
1213
+ const encodedPayload = `${encodedEntries.join("\n")}\n`;
1179
1214
  if (!this.flushed) {
1180
1215
  this._ensureSessionFileParent(this.sessionFile);
1181
1216
  const fd = openSync(this.sessionFile, "wx");
1182
1217
  try {
1183
- for (const candidate of this.fileEntries) {
1184
- writeFileSync(fd, `${JSON.stringify(candidate)}\n`);
1185
- }
1186
- writeFileSync(fd, `${JSON.stringify(entry)}\n`);
1218
+ const prefix = this.fileEntries.map((candidate) => candidate.type === "session" ? JSON.stringify(candidate) : encodeSessionEntry(candidate));
1219
+ writeFileSync(fd, `${prefix.concat(encodedEntries).join("\n")}\n`);
1187
1220
  }
1188
1221
  finally {
1189
1222
  closeSync(fd);
1190
1223
  }
1191
1224
  this.flushed = true;
1192
1225
  }
1193
- else {
1226
+ else if (encodedEntries.length > 0) {
1194
1227
  this._ensureSessionFileParent(this.sessionFile);
1195
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1228
+ appendFileSync(this.sessionFile, encodedPayload);
1196
1229
  }
1197
1230
  }
1198
1231
  catch (error) {
1199
1232
  // append/write/close failures may leave a partial JSONL suffix. Do not publish the
1200
- // entry in memory, and fence every later append until an explicit reload owns the
1233
+ // entries in memory, and fence every later append until an explicit reload owns the
1201
1234
  // surviving canonical prefix.
1202
1235
  this.persistenceStateUncertain = true;
1203
1236
  throw error;
1204
1237
  }
1205
1238
  }
1239
+ _persist(entry) {
1240
+ const encodedEntry = encodeSessionEntry(entry);
1241
+ this._persistEntries([entry], [encodedEntry]);
1242
+ }
1243
+ _appendEntries(entries, preencodedEntries) {
1244
+ const encodedEntries = preencodedEntries ??
1245
+ (this.persist && this.sessionFile
1246
+ ? entries.map((entry) => {
1247
+ if (isSessionLifecycleEntry(entry))
1248
+ validateSessionLifecycleEntry(entry);
1249
+ return encodeSessionEntry(entry);
1250
+ })
1251
+ : []);
1252
+ if (preencodedEntries && preencodedEntries.length !== entries.length) {
1253
+ throw new Error("Session append encoding count does not match the entry batch.");
1254
+ }
1255
+ if (encodedEntries.length > 0) {
1256
+ this._persistEntries(entries, encodedEntries);
1257
+ }
1258
+ else {
1259
+ for (const entry of entries) {
1260
+ if (isSessionLifecycleEntry(entry))
1261
+ encodeSessionEntry(entry);
1262
+ }
1263
+ }
1264
+ for (const entry of entries) {
1265
+ this.fileEntries.push(entry);
1266
+ this.entries.push(entry);
1267
+ this.byId.set(entry.id, entry);
1268
+ this.leafId = entry.id;
1269
+ this._advanceLifecycleActiveCache(entry);
1270
+ this._advanceSessionContextCache(entry);
1271
+ }
1272
+ }
1206
1273
  _appendEntry(entry) {
1207
- this._persist(entry);
1208
- this.fileEntries.push(entry);
1209
- this.entries.push(entry);
1210
- this.byId.set(entry.id, entry);
1211
- this.leafId = entry.id;
1212
- this._advanceSessionContextCache(entry);
1274
+ this._appendEntries([entry]);
1275
+ }
1276
+ _getLifecycleActiveCache() {
1277
+ if (this.lifecycleActiveCache?.leafId === this.leafId)
1278
+ return this.lifecycleActiveCache;
1279
+ const branch = [];
1280
+ const leaf = this.leafId === null ? undefined : this.byId.get(this.leafId);
1281
+ visitSessionAncestry(leaf, this.byId, (entry) => {
1282
+ branch.push(entry);
1283
+ });
1284
+ branch.reverse();
1285
+ const cache = {
1286
+ leafId: this.leafId,
1287
+ positions: new Map(),
1288
+ entryTypes: new Map(),
1289
+ requestIds: new Set(),
1290
+ startsByIdentity: new Set(),
1291
+ terminalsByIdentity: new Set(),
1292
+ compactionStarts: new Map(),
1293
+ compactionEnds: new Set(),
1294
+ assistantToolsByIdentity: new Map(),
1295
+ };
1296
+ for (let position = 0; position < branch.length; position += 1) {
1297
+ this._applyLifecycleCacheEntry(cache, branch[position], position);
1298
+ }
1299
+ this.lifecycleActiveCache = cache;
1300
+ return cache;
1301
+ }
1302
+ /** Apply one canonical branch entry to both rebuilt and incrementally advanced lifecycle caches. */
1303
+ _applyLifecycleCacheEntry(cache, entry, position) {
1304
+ cache.positions.set(entry.id, position);
1305
+ cache.entryTypes.set(entry.id, entry.type);
1306
+ if (entry.type === "request_snapshot") {
1307
+ cache.currentRequestId = entry.requestId;
1308
+ cache.requestIds.add(entry.requestId);
1309
+ }
1310
+ else if (entry.type === "foreground_tool_start") {
1311
+ cache.startsByIdentity.add(sessionLifecycleToolIdentityKey(entry.requestId, entry.assistantMessageEntryId, entry.callId));
1312
+ }
1313
+ else if (entry.type === "foreground_tool_terminal") {
1314
+ cache.terminalsByIdentity.add(sessionLifecycleToolIdentityKey(entry.requestId, entry.assistantMessageEntryId, entry.callId));
1315
+ }
1316
+ else if (entry.type === "compaction_start" && !cache.compactionStarts.has(entry.compactionId)) {
1317
+ cache.compactionStarts.set(entry.compactionId, entry);
1318
+ }
1319
+ else if (entry.type === "compaction_end") {
1320
+ cache.compactionEnds.add(entry.compactionId);
1321
+ }
1322
+ else if (entry.type === "message" && entry.message.role === "assistant") {
1323
+ for (const block of entry.message.content) {
1324
+ if (block.type !== "toolCall")
1325
+ continue;
1326
+ const key = assistantToolIdentityKey(entry.id, block.id);
1327
+ if (cache.assistantToolsByIdentity.has(key)) {
1328
+ cache.assistantToolsByIdentity.set(key, "duplicate");
1329
+ }
1330
+ else {
1331
+ cache.assistantToolsByIdentity.set(key, {
1332
+ requestId: cache.currentRequestId,
1333
+ toolName: block.name,
1334
+ });
1335
+ }
1336
+ }
1337
+ }
1338
+ cache.leafId = entry.id;
1339
+ }
1340
+ _advanceLifecycleActiveCache(entry) {
1341
+ const cache = this.lifecycleActiveCache;
1342
+ if (!cache || cache.leafId !== entry.parentId) {
1343
+ this.lifecycleActiveCache = undefined;
1344
+ return;
1345
+ }
1346
+ this._applyLifecycleCacheEntry(cache, entry, cache.positions.size);
1213
1347
  }
1214
1348
  _invalidateSessionContextCache() {
1215
1349
  this.sessionContextCache = undefined;
@@ -1262,6 +1396,63 @@ export class SessionManager {
1262
1396
  this._appendEntry(entry);
1263
1397
  return entry.id;
1264
1398
  }
1399
+ /**
1400
+ * Append a bounded mixed message/custom-message batch as one durable publication.
1401
+ * Every item is encoded before the session tree or persistence state is mutated.
1402
+ */
1403
+ appendMessageBatch(batch) {
1404
+ if (batch.length === 0)
1405
+ return [];
1406
+ if (batch.length > MAX_SESSION_MESSAGE_BATCH_ENTRIES) {
1407
+ throw new RangeError(`Session message batch cannot contain more than ${MAX_SESSION_MESSAGE_BATCH_ENTRIES} entries.`);
1408
+ }
1409
+ const entries = [];
1410
+ const ids = new Set(this.byId.keys());
1411
+ let parentId = this.leafId;
1412
+ for (const item of batch) {
1413
+ if (!item || typeof item !== "object") {
1414
+ throw new TypeError("Session message batch items must be objects.");
1415
+ }
1416
+ const id = generateId(ids);
1417
+ let entry;
1418
+ if (item.kind === "message") {
1419
+ if (!item.message || typeof item.message !== "object") {
1420
+ throw new TypeError("Session message batch message items require a message object.");
1421
+ }
1422
+ entry = {
1423
+ type: "message",
1424
+ id,
1425
+ parentId,
1426
+ timestamp: new Date().toISOString(),
1427
+ message: item.message,
1428
+ };
1429
+ }
1430
+ else if (item.kind === "custom") {
1431
+ if (!item.message || typeof item.message !== "object") {
1432
+ throw new TypeError("Session message batch custom items require a message object.");
1433
+ }
1434
+ entry = {
1435
+ type: "custom_message",
1436
+ customType: item.message.customType,
1437
+ content: item.message.content,
1438
+ display: item.message.display,
1439
+ details: item.message.details,
1440
+ id,
1441
+ parentId,
1442
+ timestamp: new Date().toISOString(),
1443
+ };
1444
+ }
1445
+ else {
1446
+ throw new TypeError("Session message batch items must use kind message or custom.");
1447
+ }
1448
+ entries.push(entry);
1449
+ ids.add(id);
1450
+ parentId = id;
1451
+ }
1452
+ const encodedEntries = entries.map((entry) => encodeSessionEntry(entry));
1453
+ this._appendEntries(entries, encodedEntries);
1454
+ return entries.map((entry) => entry.id);
1455
+ }
1265
1456
  /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
1266
1457
  appendThinkingLevelChange(thinkingLevel) {
1267
1458
  const entry = {
@@ -1288,7 +1479,7 @@ export class SessionManager {
1288
1479
  return entry.id;
1289
1480
  }
1290
1481
  /** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
1291
- appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage) {
1482
+ appendCompaction(summary, firstKeptEntryId, tokensBefore, details, fromHook, usage, retention) {
1292
1483
  const compactionParentId = this.leafId;
1293
1484
  const entry = {
1294
1485
  type: "compaction",
@@ -1298,15 +1489,229 @@ export class SessionManager {
1298
1489
  summary,
1299
1490
  firstKeptEntryId,
1300
1491
  tokensBefore,
1492
+ retention,
1301
1493
  details,
1302
1494
  usage,
1303
1495
  fromHook,
1304
1496
  };
1305
1497
  this._appendEntry(entry);
1306
- this._releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId);
1498
+ this._releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId, retention, entry.id);
1307
1499
  return entry.id;
1308
1500
  }
1309
- /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
1501
+ _findAssistantToolCall(assistantMessageEntryId, callId) {
1502
+ const cache = this._getLifecycleActiveCache();
1503
+ if (!cache.positions.has(assistantMessageEntryId)) {
1504
+ throw new Error(`Assistant message is outside the active branch: ${assistantMessageEntryId}.`);
1505
+ }
1506
+ const assistant = this.byId.get(assistantMessageEntryId);
1507
+ if (!assistant || assistant.type !== "message") {
1508
+ throw new Error(`Foreground tool lifecycle identity requires an assistant message: ${assistantMessageEntryId}.`);
1509
+ }
1510
+ const reference = cache.assistantToolsByIdentity.get(assistantToolIdentityKey(assistantMessageEntryId, callId));
1511
+ if (reference === "duplicate") {
1512
+ throw new Error(`Foreground tool lifecycle identity must resolve to exactly one assistant tool call: ${assistantMessageEntryId}/${callId}.`);
1513
+ }
1514
+ if (!reference) {
1515
+ throw new Error(`Foreground tool lifecycle identity must resolve to exactly one assistant tool call: ${assistantMessageEntryId}/${callId}.`);
1516
+ }
1517
+ return reference;
1518
+ }
1519
+ _validateRequestSnapshot(entry) {
1520
+ const cache = this._getLifecycleActiveCache();
1521
+ if (cache.requestIds.has(entry.requestId)) {
1522
+ throw new Error(`Duplicate request snapshot identity: ${entry.requestId}.`);
1523
+ }
1524
+ for (const messageEntryId of entry.messageEntryIds) {
1525
+ const messageEntry = this.byId.get(messageEntryId);
1526
+ if (!messageEntry || messageEntry.type !== "message") {
1527
+ throw new Error(`Request snapshot references a non-message entry: ${messageEntryId}.`);
1528
+ }
1529
+ if (!cache.positions.has(messageEntryId)) {
1530
+ throw new Error(`Request snapshot message is outside the active branch: ${messageEntryId}.`);
1531
+ }
1532
+ }
1533
+ }
1534
+ _validateForegroundToolStartBatch(entries) {
1535
+ const cache = this._getLifecycleActiveCache();
1536
+ const seen = new Set();
1537
+ for (const entry of entries) {
1538
+ validateSessionLifecycleEntry(entry);
1539
+ const call = this._findAssistantToolCall(entry.assistantMessageEntryId, entry.callId);
1540
+ if (call.requestId !== undefined && call.requestId !== entry.requestId) {
1541
+ throw new Error(`Foreground tool start request does not match its assistant request: ${entry.id}.`);
1542
+ }
1543
+ const key = sessionLifecycleToolIdentityKey(entry.requestId, entry.assistantMessageEntryId, entry.callId);
1544
+ if (seen.has(key) || cache.startsByIdentity.has(key)) {
1545
+ throw new Error(`Duplicate foreground tool start identity: ${entry.requestId}/${entry.assistantMessageEntryId}/${entry.callId}.`);
1546
+ }
1547
+ seen.add(key);
1548
+ if (call.toolName !== entry.toolName) {
1549
+ throw new Error(`Foreground tool start name does not match its assistant call: ${entry.id}.`);
1550
+ }
1551
+ }
1552
+ }
1553
+ _validateForegroundToolTerminal(entry) {
1554
+ validateSessionLifecycleEntry(entry);
1555
+ const call = this._findAssistantToolCall(entry.assistantMessageEntryId, entry.callId);
1556
+ if (call.requestId !== undefined && call.requestId !== entry.requestId) {
1557
+ throw new Error(`Foreground tool terminal request does not match its assistant request: ${entry.id}.`);
1558
+ }
1559
+ if (call.toolName !== entry.toolName) {
1560
+ throw new Error(`Foreground tool terminal name does not match its assistant call: ${entry.id}.`);
1561
+ }
1562
+ const cache = this._getLifecycleActiveCache();
1563
+ const identityKey = sessionLifecycleToolIdentityKey(entry.requestId, entry.assistantMessageEntryId, entry.callId);
1564
+ if (cache.terminalsByIdentity.has(identityKey)) {
1565
+ throw new Error(`Duplicate foreground tool terminal identity: ${entry.id}.`);
1566
+ }
1567
+ if (!cache.startsByIdentity.has(identityKey)) {
1568
+ throw new Error(`Foreground tool terminal has no matching durable start: ${entry.id}.`);
1569
+ }
1570
+ const resultEntry = this.byId.get(entry.resultMessageEntryId);
1571
+ if (!resultEntry ||
1572
+ !cache.positions.has(entry.resultMessageEntryId) ||
1573
+ resultEntry.type !== "message" ||
1574
+ resultEntry.message.role !== "toolResult") {
1575
+ throw new Error(`Foreground tool terminal references a non-result entry: ${entry.resultMessageEntryId}.`);
1576
+ }
1577
+ const resultErrorKind = resultEntry.message.isError
1578
+ ? (resultEntry.message.errorKind ?? "tool_failure")
1579
+ : undefined;
1580
+ if (resultEntry.message.toolCallId !== entry.callId ||
1581
+ resultEntry.message.toolName !== entry.toolName ||
1582
+ entry.outcome !== (resultEntry.message.isError ? "error" : "success") ||
1583
+ (resultEntry.message.errorKind !== undefined && !resultEntry.message.isError) ||
1584
+ entry.errorKind !== resultErrorKind) {
1585
+ throw new Error(`Foreground tool terminal metadata contradicts its canonical result: ${entry.id}.`);
1586
+ }
1587
+ }
1588
+ _validateCompactionStart(entry) {
1589
+ validateSessionLifecycleEntry(entry);
1590
+ const cache = this._getLifecycleActiveCache();
1591
+ if (cache.compactionStarts.has(entry.compactionId)) {
1592
+ throw new Error(`Duplicate compaction start identity: ${entry.compactionId}.`);
1593
+ }
1594
+ const firstKeptPosition = cache.positions.get(entry.firstKeptEntryId);
1595
+ const leafPosition = this.leafId === null ? undefined : cache.positions.get(this.leafId);
1596
+ if (firstKeptPosition === undefined || leafPosition === undefined || firstKeptPosition > leafPosition) {
1597
+ throw new Error(`Compaction start firstKeptEntryId is not an earlier active-branch entry: ${entry.firstKeptEntryId}.`);
1598
+ }
1599
+ }
1600
+ _validateCompactionEnd(entry) {
1601
+ validateSessionLifecycleEntry(entry);
1602
+ const cache = this._getLifecycleActiveCache();
1603
+ const start = cache.compactionStarts.get(entry.compactionId);
1604
+ if (!start)
1605
+ throw new Error(`Compaction end has no matching start: ${entry.compactionId}.`);
1606
+ if (cache.compactionEnds.has(entry.compactionId)) {
1607
+ throw new Error(`Duplicate compaction end identity: ${entry.compactionId}.`);
1608
+ }
1609
+ if (entry.outcome !== "success")
1610
+ return;
1611
+ const compactionPosition = entry.compactionEntryId ? cache.positions.get(entry.compactionEntryId) : undefined;
1612
+ const compactionEntry = entry.compactionEntryId ? this.byId.get(entry.compactionEntryId) : undefined;
1613
+ const finalFirstKeptPosition = compactionEntry?.type === "compaction" ? cache.positions.get(compactionEntry.firstKeptEntryId) : undefined;
1614
+ if (!entry.compactionEntryId ||
1615
+ cache.entryTypes.get(entry.compactionEntryId) !== "compaction" ||
1616
+ compactionPosition === undefined ||
1617
+ compactionPosition <= (cache.positions.get(start.id) ?? -1) ||
1618
+ compactionEntry?.type !== "compaction" ||
1619
+ finalFirstKeptPosition === undefined ||
1620
+ finalFirstKeptPosition >= compactionPosition) {
1621
+ throw new Error(`Compaction end references an invalid summary entry: ${entry.compactionEntryId ?? "missing"}.`);
1622
+ }
1623
+ }
1624
+ /** Append a bounded provider request snapshot before its transport starts. */
1625
+ appendRequestSnapshot(snapshot) {
1626
+ const entry = {
1627
+ type: "request_snapshot",
1628
+ id: generateId(this.byId),
1629
+ parentId: this.leafId,
1630
+ timestamp: new Date().toISOString(),
1631
+ ...snapshot,
1632
+ messageEntryIds: [...snapshot.messageEntryIds],
1633
+ };
1634
+ validateSessionLifecycleEntry(entry);
1635
+ this._validateRequestSnapshot(entry);
1636
+ this._appendEntry(entry);
1637
+ return entry.id;
1638
+ }
1639
+ /** Append a bounded batch of foreground tool execution start markers in one durable publication. */
1640
+ appendForegroundToolStarts(starts) {
1641
+ if (starts.length === 0)
1642
+ return [];
1643
+ const entries = [];
1644
+ let parentId = this.leafId;
1645
+ const ids = new Set(this.byId.keys());
1646
+ for (const start of starts) {
1647
+ const entry = {
1648
+ type: "foreground_tool_start",
1649
+ id: generateId(ids),
1650
+ parentId,
1651
+ timestamp: new Date().toISOString(),
1652
+ ...start,
1653
+ };
1654
+ entries.push(entry);
1655
+ ids.add(entry.id);
1656
+ parentId = entry.id;
1657
+ }
1658
+ this._validateForegroundToolStartBatch(entries);
1659
+ this._appendEntries(entries);
1660
+ return entries.map((entry) => entry.id);
1661
+ }
1662
+ /** Append one bounded foreground tool execution start marker. */
1663
+ appendForegroundToolStart(requestId, assistantMessageEntryId, callId, toolName) {
1664
+ return this.appendForegroundToolStarts([{ requestId, assistantMessageEntryId, callId, toolName }])[0];
1665
+ }
1666
+ /** Append a bounded foreground tool terminal marker. */
1667
+ appendForegroundToolTerminal(requestId, assistantMessageEntryId, callId, toolName, outcome, metadata) {
1668
+ const entry = {
1669
+ type: "foreground_tool_terminal",
1670
+ id: generateId(this.byId),
1671
+ parentId: this.leafId,
1672
+ timestamp: new Date().toISOString(),
1673
+ requestId,
1674
+ assistantMessageEntryId,
1675
+ callId,
1676
+ toolName,
1677
+ outcome,
1678
+ ...metadata,
1679
+ };
1680
+ this._validateForegroundToolTerminal(entry);
1681
+ this._appendEntry(entry);
1682
+ return entry.id;
1683
+ }
1684
+ /** Append a bounded compaction transaction start marker. */
1685
+ appendCompactionStart(compactionId, firstKeptEntryId, tokensBefore) {
1686
+ const entry = {
1687
+ type: "compaction_start",
1688
+ id: generateId(this.byId),
1689
+ parentId: this.leafId,
1690
+ timestamp: new Date().toISOString(),
1691
+ compactionId,
1692
+ firstKeptEntryId,
1693
+ tokensBefore,
1694
+ };
1695
+ this._validateCompactionStart(entry);
1696
+ this._appendEntry(entry);
1697
+ return entry.id;
1698
+ }
1699
+ /** Append a bounded compaction transaction end marker. */
1700
+ appendCompactionEnd(compactionId, outcome, metadata = {}) {
1701
+ const entry = {
1702
+ type: "compaction_end",
1703
+ id: generateId(this.byId),
1704
+ parentId: this.leafId,
1705
+ timestamp: new Date().toISOString(),
1706
+ compactionId,
1707
+ outcome,
1708
+ ...metadata,
1709
+ };
1710
+ this._validateCompactionEnd(entry);
1711
+ this._appendEntry(entry);
1712
+ return entry.id;
1713
+ }
1714
+ /** Append a custom entry (for extensions) as child of current leaf, then advance leaf. */
1310
1715
  appendCustomEntry(customType, data) {
1311
1716
  const entry = {
1312
1717
  type: "custom",
@@ -1344,6 +1749,18 @@ export class SessionManager {
1344
1749
  }
1345
1750
  return undefined;
1346
1751
  }
1752
+ /** Return lifecycle records on one branch without exposing lifecycle data to model context. */
1753
+ getSessionLifecycleIndex(fromId) {
1754
+ return indexSessionLifecycle(this.entries, fromId === undefined ? this.leafId : fromId);
1755
+ }
1756
+ /** Inspect lifecycle balance on one branch without mutating or appending repair records. */
1757
+ inspectSessionLifecycle(fromId) {
1758
+ return inspectSessionLifecycle(this.entries, fromId === undefined ? this.leafId : fromId);
1759
+ }
1760
+ /** Plan deterministic lifecycle repair for one branch without mutating the session. */
1761
+ planSessionLifecycleRepair(fromId) {
1762
+ return planSessionLifecycleRepair(this.entries, fromId === undefined ? this.leafId : fromId);
1763
+ }
1347
1764
  /**
1348
1765
  * Append a custom message entry (for extensions) that participates in LLM context.
1349
1766
  * @param customType Extension identifier for filtering on reload
@@ -1716,6 +2133,7 @@ export class SessionManager {
1716
2133
  }
1717
2134
  this._invalidateSessionContextCache();
1718
2135
  this.leafId = branchFromId;
2136
+ this.lifecycleActiveCache = undefined;
1719
2137
  }
1720
2138
  /**
1721
2139
  * Reset the leaf pointer to null (before any entries).
@@ -1725,6 +2143,7 @@ export class SessionManager {
1725
2143
  resetLeaf() {
1726
2144
  this._invalidateSessionContextCache();
1727
2145
  this.leafId = null;
2146
+ this.lifecycleActiveCache = undefined;
1728
2147
  }
1729
2148
  /**
1730
2149
  * Start a new branch with a summary of the abandoned path.
@@ -1737,6 +2156,7 @@ export class SessionManager {
1737
2156
  }
1738
2157
  this._invalidateSessionContextCache();
1739
2158
  this.leafId = branchFromId;
2159
+ this.lifecycleActiveCache = undefined;
1740
2160
  const entry = {
1741
2161
  type: "branch_summary",
1742
2162
  id: generateId(this.byId),
@@ -1843,6 +2263,7 @@ export class SessionManager {
1843
2263
  this.labelsById = branched.labelsById;
1844
2264
  this.labelTimestampsById = branched.labelTimestampsById;
1845
2265
  this.leafId = branched.leafId;
2266
+ this.lifecycleActiveCache = undefined;
1846
2267
  this.persistenceStateUncertain = branched.persistenceStateUncertain;
1847
2268
  this.inheritedSessionIds = new Set(branched.inheritedSessionIds);
1848
2269
  this.coldPayloadEntryIds.clear();