@caupulican/pi-agent-core 0.93.18 → 0.94.0

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.
@@ -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 */
@@ -367,7 +368,9 @@ function loadEntriesFromFileInternal(filePath, options, onEntry) {
367
368
  }
368
369
  /** Exported for testing */
369
370
  export function loadEntriesFromFile(filePath, options) {
370
- return loadEntriesFromFileInternal(filePath, options).entries;
371
+ const entries = loadEntriesFromFileInternal(filePath, options).entries;
372
+ validateLoadedLifecycleEntries(entries);
373
+ return entries;
371
374
  }
372
375
  const ENTRY_ID_PREFIX_BYTES = 64 * 1024;
373
376
  const COMPACTED_PAYLOAD_RELEASE_MIN_CHARS = 16 * 1024;
@@ -854,6 +857,7 @@ export class SessionManager {
854
857
  this.inheritedSessionIds = collectParentSessionIds(header?.parentSession, dirname(this.sessionFile));
855
858
  this._ensureEntryFileLocations(this.coldPayloadEntryIds);
856
859
  const migrated = migrateToCurrentVersion(this.fileEntries);
860
+ validateLoadedLifecycleEntries(this.fileEntries);
857
861
  // Validate and index the complete parent graph before a migration rewrite can
858
862
  // mutate the source file. Malformed cycles must fail synchronously on cold open.
859
863
  this._buildIndex();
@@ -895,6 +899,7 @@ export class SessionManager {
895
899
  this.labelsById.clear();
896
900
  this.labelTimestampsById.clear();
897
901
  this.leafId = null;
902
+ this.lifecycleActiveCache = undefined;
898
903
  this.flushed = false;
899
904
  this.persistenceStateUncertain = false;
900
905
  this._invalidateSessionContextCache();
@@ -967,6 +972,7 @@ export class SessionManager {
967
972
  this.labelsById = labelsById;
968
973
  this.labelTimestampsById = labelTimestampsById;
969
974
  this.leafId = leafId;
975
+ this.lifecycleActiveCache = undefined;
970
976
  for (const id of this.coldPayloadEntryIds) {
971
977
  if (!byId.has(id))
972
978
  this.coldPayloadEntryIds.delete(id);
@@ -988,7 +994,7 @@ export class SessionManager {
988
994
  const fd = openSync(tempFile, "wx");
989
995
  try {
990
996
  for (const entry of this.fileEntries) {
991
- writeFileSync(fd, `${JSON.stringify(entry)}\n`);
997
+ writeFileSync(fd, `${entry.type === "session" ? JSON.stringify(entry) : encodeSessionEntry(entry)}\n`);
992
998
  }
993
999
  }
994
1000
  finally {
@@ -1185,56 +1191,159 @@ export class SessionManager {
1185
1191
  this._releaseMessageProperty(release.entry, property);
1186
1192
  }
1187
1193
  }
1188
- _persist(entry) {
1194
+ _persistEntries(entries, encodedEntries) {
1189
1195
  if (!this.persist || !this.sessionFile)
1190
1196
  return;
1197
+ if (entries.length !== encodedEntries.length) {
1198
+ throw new Error("Session persistence encoding count does not match the entry batch.");
1199
+ }
1191
1200
  if (this.persistenceStateUncertain) {
1192
1201
  throw new Error("Session persistence state is uncertain after a failed write; reopen the session file or start a new session before appending.");
1193
1202
  }
1194
1203
  try {
1195
- const hasAssistant = (entry.type === "message" && entry.message.role === "assistant") ||
1196
- this.fileEntries.some((candidate) => candidate.type === "message" && candidate.message.role === "assistant");
1197
- if (!hasAssistant) {
1198
- 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) {
1199
1208
  this._ensureSessionFileParent(this.sessionFile);
1200
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1209
+ appendFileSync(this.sessionFile, `${encodedEntries.join("\n")}\n`);
1201
1210
  }
1202
1211
  return;
1203
1212
  }
1213
+ const encodedPayload = `${encodedEntries.join("\n")}\n`;
1204
1214
  if (!this.flushed) {
1205
1215
  this._ensureSessionFileParent(this.sessionFile);
1206
1216
  const fd = openSync(this.sessionFile, "wx");
1207
1217
  try {
1208
- for (const candidate of this.fileEntries) {
1209
- writeFileSync(fd, `${JSON.stringify(candidate)}\n`);
1210
- }
1211
- 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`);
1212
1220
  }
1213
1221
  finally {
1214
1222
  closeSync(fd);
1215
1223
  }
1216
1224
  this.flushed = true;
1217
1225
  }
1218
- else {
1226
+ else if (encodedEntries.length > 0) {
1219
1227
  this._ensureSessionFileParent(this.sessionFile);
1220
- appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
1228
+ appendFileSync(this.sessionFile, encodedPayload);
1221
1229
  }
1222
1230
  }
1223
1231
  catch (error) {
1224
1232
  // append/write/close failures may leave a partial JSONL suffix. Do not publish the
1225
- // 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
1226
1234
  // surviving canonical prefix.
1227
1235
  this.persistenceStateUncertain = true;
1228
1236
  throw error;
1229
1237
  }
1230
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
+ }
1231
1273
  _appendEntry(entry) {
1232
- this._persist(entry);
1233
- this.fileEntries.push(entry);
1234
- this.entries.push(entry);
1235
- this.byId.set(entry.id, entry);
1236
- this.leafId = entry.id;
1237
- 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);
1238
1347
  }
1239
1348
  _invalidateSessionContextCache() {
1240
1349
  this.sessionContextCache = undefined;
@@ -1287,6 +1396,63 @@ export class SessionManager {
1287
1396
  this._appendEntry(entry);
1288
1397
  return entry.id;
1289
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
+ }
1290
1456
  /** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
1291
1457
  appendThinkingLevelChange(thinkingLevel) {
1292
1458
  const entry = {
@@ -1332,7 +1498,220 @@ export class SessionManager {
1332
1498
  this._releaseCompactedMessagePayloads(firstKeptEntryId, compactionParentId, retention, entry.id);
1333
1499
  return entry.id;
1334
1500
  }
1335
- /** 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. */
1336
1715
  appendCustomEntry(customType, data) {
1337
1716
  const entry = {
1338
1717
  type: "custom",
@@ -1370,6 +1749,18 @@ export class SessionManager {
1370
1749
  }
1371
1750
  return undefined;
1372
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
+ }
1373
1764
  /**
1374
1765
  * Append a custom message entry (for extensions) that participates in LLM context.
1375
1766
  * @param customType Extension identifier for filtering on reload
@@ -1742,6 +2133,7 @@ export class SessionManager {
1742
2133
  }
1743
2134
  this._invalidateSessionContextCache();
1744
2135
  this.leafId = branchFromId;
2136
+ this.lifecycleActiveCache = undefined;
1745
2137
  }
1746
2138
  /**
1747
2139
  * Reset the leaf pointer to null (before any entries).
@@ -1751,6 +2143,7 @@ export class SessionManager {
1751
2143
  resetLeaf() {
1752
2144
  this._invalidateSessionContextCache();
1753
2145
  this.leafId = null;
2146
+ this.lifecycleActiveCache = undefined;
1754
2147
  }
1755
2148
  /**
1756
2149
  * Start a new branch with a summary of the abandoned path.
@@ -1763,6 +2156,7 @@ export class SessionManager {
1763
2156
  }
1764
2157
  this._invalidateSessionContextCache();
1765
2158
  this.leafId = branchFromId;
2159
+ this.lifecycleActiveCache = undefined;
1766
2160
  const entry = {
1767
2161
  type: "branch_summary",
1768
2162
  id: generateId(this.byId),
@@ -1869,6 +2263,7 @@ export class SessionManager {
1869
2263
  this.labelsById = branched.labelsById;
1870
2264
  this.labelTimestampsById = branched.labelTimestampsById;
1871
2265
  this.leafId = branched.leafId;
2266
+ this.lifecycleActiveCache = undefined;
1872
2267
  this.persistenceStateUncertain = branched.persistenceStateUncertain;
1873
2268
  this.inheritedSessionIds = new Set(branched.inheritedSessionIds);
1874
2269
  this.coldPayloadEntryIds.clear();