pi-fold 4.2.5 → 4.2.7

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.
@@ -4,6 +4,7 @@ import {
4
4
  objectRefKey,
5
5
  sha256Value,
6
6
  stableStringify,
7
+ type EvidenceRef,
7
8
  } from "./json.ts";
8
9
  import {
9
10
  contentText,
@@ -77,6 +78,7 @@ import type {
77
78
  ProviderMeasurementAnchor,
78
79
  } from "./lib/measurement.ts";
79
80
  import {
81
+ assignFoldParents,
80
82
  childFoldIds,
81
83
  clearPrepared,
82
84
  deriveFoldParents,
@@ -86,6 +88,8 @@ import {
86
88
  makeFoldRecordEntry,
87
89
  makeStateCheckpoint,
88
90
  makeStateDelta,
91
+ parseActiveContextState,
92
+ validateFoldForest,
89
93
  MAX_ACTIVE_FOLD_RECORDS,
90
94
  MAX_DELTAS_BETWEEN_CHECKPOINTS,
91
95
  materializeStatePersistence,
@@ -136,10 +140,12 @@ import type {
136
140
  ActiveContextSnapshot,
137
141
  ActiveContextState,
138
142
  ActiveContextThresholds,
143
+ ActiveFold,
139
144
  ActiveContextToolAction,
140
145
  BriefProvenance,
141
146
  FoldCandidate,
142
147
  FoldKind,
148
+ FoldPart,
143
149
  FoldRecordEntry,
144
150
  PreparedFold,
145
151
  } from "./lib/policy.ts";
@@ -445,6 +451,7 @@ export function registerActiveContext(pi: any, options: {
445
451
  * session that never had any, which is what `persistedWireVersion` 0 also means and
446
452
  * which legitimately writes the first checkpoint. See `persist`. */
447
453
  lineageUnreadable: false,
454
+ forkCheckpointPending: false,
448
455
  /** Deltas standing after the newest checkpoint in the branch. See
449
456
  * `MAX_DELTAS_BETWEEN_CHECKPOINTS`: at the bound, `persist` writes a checkpoint instead
450
457
  * of a delta, which is what keeps a load's replay bounded. */
@@ -574,6 +581,7 @@ export function registerActiveContext(pi: any, options: {
574
581
  wallEpisodeOpen: false,
575
582
  recoveryAttempts: 0,
576
583
  pendingRejection: null as { status: number; ordinal: number } | null,
584
+ restoreRevisionGap: false,
577
585
  lastRecovery: null as Record<string, unknown> | null,
578
586
  instrumentationQueue: Promise.resolve<void>(undefined),
579
587
  };
@@ -1157,6 +1165,18 @@ export function registerActiveContext(pi: any, options: {
1157
1165
  }
1158
1166
  };
1159
1167
 
1168
+ // WHAT PI'S CONTEXT EVENT WOULD HAND OVER, BEFORE ONE HAS ARRIVED (2026-10-05). Pi's
1169
+ // ExtensionRunner.emitContext filters role "system" before any handler sees a message;
1170
+ // buildSessionContext does not. A `/fold` before the first context event cut folds from
1171
+ // the unfiltered view in session 01a10161: spans that crossed a system-prompt change
1172
+ // looked broken, and that commit erased 739 of 920 folds.
1173
+ const piContextMessages = (ctx: any): unknown[] | null => {
1174
+ const messages = ctx.sessionManager.buildSessionContext?.()?.messages;
1175
+ return Array.isArray(messages)
1176
+ ? messages.filter((message: any) => message?.role !== "system")
1177
+ : null;
1178
+ };
1179
+
1160
1180
  const snapshotForEvent = (ctx: any, messages: unknown[]): ActiveContextSnapshot =>
1161
1181
  timedDerivation(() => mapActiveContext({
1162
1182
  sessionId: ctx.sessionManager.getSessionId(),
@@ -1172,6 +1192,146 @@ export function registerActiveContext(pi: any, options: {
1172
1192
  thresholds,
1173
1193
  }));
1174
1194
 
1195
+ const forkEntriesForSession = (entries: any[], sessionId: string): any[] => {
1196
+ // A child checkpoint supersedes inherited records; keep child records and validate anything newer strictly.
1197
+ const checkpointIndex = entries.findLastIndex((entry) => entry?.type === "custom" &&
1198
+ entry.customType === stateEntryType && ownValue(entry.data, "sessionId") === sessionId &&
1199
+ ownValue(entry.data, "version") === 2 && ownValue(entry.data, "kind") === "checkpoint");
1200
+ if (checkpointIndex < 0) return entries;
1201
+ const inheritedOwners = new Set(entries.slice(0, checkpointIndex).flatMap((entry) => {
1202
+ if (entry?.type !== "custom" || entry.customType !== stateEntryType) return [];
1203
+ const owner = ownValue(entry.data, "sessionId");
1204
+ return typeof owner === "string" && owner !== sessionId ? [owner] : [];
1205
+ }));
1206
+ return entries.filter((entry, index) => {
1207
+ if (entry?.type !== "custom" || entry.customType !== foldRecordEntryType || index >= checkpointIndex) return true;
1208
+ const owner = ownValue(entry.data, "sessionId");
1209
+ return owner === sessionId || typeof owner !== "string" || !inheritedOwners.has(owner);
1210
+ });
1211
+ };
1212
+
1213
+ const forkEntriesForOwner = (entries: any[], sessionId: string): any[] => entries.filter((entry) =>
1214
+ entry?.type !== "custom" || entry.customType !== foldRecordEntryType ||
1215
+ ownValue(entry.data, "sessionId") === sessionId,
1216
+ );
1217
+
1218
+ const rebaseForkState = (
1219
+ state: ActiveContextState,
1220
+ snapshot: ActiveContextSnapshot,
1221
+ sessionId: string,
1222
+ ): ActiveContextState => {
1223
+ const refsByEntryId = new Map<string, ActiveContextSnapshot["branchObjects"]>();
1224
+ for (const item of snapshot.branchObjects) {
1225
+ const refs = refsByEntryId.get(item.ref.entryId) ?? [];
1226
+ refs.push(item);
1227
+ refsByEntryId.set(item.ref.entryId, refs);
1228
+ }
1229
+ const rebaseRef = (ref: ActiveContextState["protected"][number]) => {
1230
+ const matches = refsByEntryId.get(ref.entryId)?.filter((item) =>
1231
+ item.ref.role === ref.role,
1232
+ ) ?? [];
1233
+ if (matches.length !== 1) {
1234
+ throw new Error(`Inherited active-context evidence ${ref.entryId} is missing or ambiguous in the fork branch`);
1235
+ }
1236
+ // Pi's entry identity locates the source; preserve its recorded digest so the
1237
+ // runtime can report content drift instead of silently erasing its provenance.
1238
+ return { ...clone(ref), sessionId };
1239
+ };
1240
+ const rebaseParts = (parts: FoldPart[], resolveFoldId: (id: string) => string): FoldPart[] =>
1241
+ parts.map((part) => {
1242
+ if (part.kind === "raw") return { kind: "raw", ref: rebaseRef(part.ref) };
1243
+ return { kind: "fold", foldId: resolveFoldId(part.foldId) };
1244
+ });
1245
+ const sourceFolds = new Map(state.folds.map((fold) => [fold.id, fold]));
1246
+ const rebasedFolds = new Map<string, { fold: ActiveFold; refs: EvidenceRef[] }>();
1247
+ const visiting = new Set<string>();
1248
+ const rebaseFold = (id: string): { fold: ActiveFold; refs: EvidenceRef[] } => {
1249
+ const existing = rebasedFolds.get(id);
1250
+ if (existing) return existing;
1251
+ const source = sourceFolds.get(id);
1252
+ if (!source) throw new Error(`Inherited active-context fold ${id} is missing`);
1253
+ if (visiting.has(id)) throw new Error(`Inherited active-context fold cycle at ${id}`);
1254
+ visiting.add(id);
1255
+ const parts = rebaseParts(source.parts, (foldId) => rebaseFold(foldId).fold.id);
1256
+ const refs = source.parts.flatMap((part) => {
1257
+ if (part.kind === "raw") return [rebaseRef(part.ref)];
1258
+ return rebaseFold(part.foldId).refs;
1259
+ });
1260
+ const fold: ActiveFold = {
1261
+ ...source,
1262
+ id: foldIdFor(source.kind, parts),
1263
+ parentId: null,
1264
+ parts,
1265
+ sourceSha256: sha256Value(refs),
1266
+ };
1267
+ const rebased = { fold, refs };
1268
+ visiting.delete(id);
1269
+ rebasedFolds.set(id, rebased);
1270
+ return rebased;
1271
+ };
1272
+ const folds = assignFoldParents(state.folds.map((fold) => rebaseFold(fold.id).fold));
1273
+ const rebasedFoldId = (id: string): string => {
1274
+ const rebased = rebasedFolds.get(id);
1275
+ if (!rebased) throw new Error(`Inherited active-context state references missing fold ${id}`);
1276
+ return rebased.fold.id;
1277
+ };
1278
+ let rebased: ActiveContextState;
1279
+ try {
1280
+ const expanded = state.expanded.map(rebasedFoldId);
1281
+ const leases = Object.fromEntries(Object.entries(state.leases).map(([id, value]) => [rebasedFoldId(id), value]));
1282
+ const briefs = Object.fromEntries(Object.entries(state.briefs ?? {}).map(([id, brief]) => [rebasedFoldId(id), brief]));
1283
+ const pendingMarks = (state.pendingMarks ?? []).map((mark) => {
1284
+ if (mark.mark === "refold") return { ...mark, id: rebasedFoldId(mark.id) };
1285
+ const parts = rebaseParts(mark.parts, rebasedFoldId);
1286
+ return { ...mark, id: foldIdFor(mark.kind, parts), parts };
1287
+ });
1288
+ rebased = clearPrepared({
1289
+ ...state,
1290
+ sessionId,
1291
+ folds,
1292
+ expanded,
1293
+ protected: state.protected.map(rebaseRef),
1294
+ leases,
1295
+ ...(state.pendingMarks ? { pendingMarks } : {}),
1296
+ ...(state.briefs ? { briefs } : {}),
1297
+ });
1298
+ } catch (error) {
1299
+ // Keep forest errors ahead of curation errors without validating successful rebases twice.
1300
+ validateFoldForest(folds);
1301
+ throw error;
1302
+ }
1303
+ return parseActiveContextState(rebased, sessionId);
1304
+ };
1305
+
1306
+ const prepareForkRecovery = (
1307
+ ctx: any,
1308
+ branchEntries: any[],
1309
+ sessionId: string,
1310
+ restoredMessages: unknown,
1311
+ restoreError: unknown,
1312
+ ): { inherited: MaterializedStatePersistence; snapshot: ActiveContextSnapshot } => {
1313
+ const inheritedStateEntry = branchEntries.findLast((entry) =>
1314
+ entry?.type === "custom" && entry.customType === stateEntryType,
1315
+ );
1316
+ const inheritedSessionId = ownValue(inheritedStateEntry?.data, "sessionId");
1317
+ if (typeof inheritedSessionId !== "string" || !inheritedSessionId || inheritedSessionId === sessionId) {
1318
+ throw new Error("No distinct inherited session ID is available for fork recovery", { cause: restoreError });
1319
+ }
1320
+ const inherited = materializeStatePersistence(
1321
+ forkEntriesForOwner(branchEntries, inheritedSessionId),
1322
+ inheritedSessionId,
1323
+ stateEntryType,
1324
+ foldRecordEntryType,
1325
+ );
1326
+ if (!Array.isArray(restoredMessages)) {
1327
+ throw new Error("Pi did not provide the fork branch messages needed to rebase active-context state", {
1328
+ cause: restoreError,
1329
+ });
1330
+ }
1331
+ const snapshot = snapshotForEvent(ctx, restoredMessages);
1332
+ return { inherited, snapshot };
1333
+ };
1334
+
1175
1335
  const authoritativeSnapshotFor = (ctx: any): ActiveContextSnapshot => {
1176
1336
  const sessionId = ctx.sessionManager.getSessionId();
1177
1337
  if (!lifecycle.latestSnapshot || lifecycle.latestSnapshot.sessionId !== sessionId) {
@@ -1239,7 +1399,7 @@ export function registerActiveContext(pi: any, options: {
1239
1399
  model: measurement.model,
1240
1400
  });
1241
1401
 
1242
- const load = (ctx: any, preserveThresholdDecision = false): void => {
1402
+ const load = (ctx: any, preserveThresholdDecision = false, forkedSessionHint = false): void => {
1243
1403
  lifecycle.generation += 1;
1244
1404
  lifecycle.shuttingDown = false;
1245
1405
  cancelPreparation();
@@ -1279,6 +1439,7 @@ export function registerActiveContext(pi: any, options: {
1279
1439
  curation.contextCalls = 0;
1280
1440
  curation.recoveryAttempts = 0;
1281
1441
  curation.pendingRejection = null;
1442
+ curation.restoreRevisionGap = false;
1282
1443
  curation.lastRecovery = null;
1283
1444
  ladder.lastAutomaticAction = null;
1284
1445
  ladder.automaticFailure = null;
@@ -1287,14 +1448,19 @@ export function registerActiveContext(pi: any, options: {
1287
1448
  measurements.providerMeasurementByMessageSha.clear();
1288
1449
  measurements.providerMeasurementAnchorByMessageSha.clear();
1289
1450
  const sessionId = ctx.sessionManager.getSessionId();
1451
+ const forkedSession = forkedSessionHint || Boolean(ctx.sessionManager.getHeader?.()?.parentSession);
1290
1452
  let restored: ActiveContextState | null = null;
1291
1453
  let restoreError: unknown = null;
1292
1454
  let restoredPersistence: MaterializedStatePersistence | null = null;
1293
1455
  let measurementRestoreError: unknown = null;
1456
+ let forkBaseline = false;
1457
+ let startupSnapshot: ActiveContextSnapshot | null = null;
1458
+ let aheadReceiptRevision: number | null = null;
1294
1459
  const branchEntries = [...ctx.sessionManager.getBranch()];
1460
+ const restoredMessages = piContextMessages(ctx);
1295
1461
  try {
1296
1462
  restoredPersistence = materializeStatePersistence(
1297
- branchEntries,
1463
+ forkedSession ? forkEntriesForSession(branchEntries, sessionId) : branchEntries,
1298
1464
  sessionId,
1299
1465
  stateEntryType,
1300
1466
  foldRecordEntryType,
@@ -1302,10 +1468,28 @@ export function registerActiveContext(pi: any, options: {
1302
1468
  restored = restoredPersistence.state;
1303
1469
  } catch (error) {
1304
1470
  restoreError = error;
1471
+ if (forkedSession) {
1472
+ try {
1473
+ const recovery = prepareForkRecovery(ctx, branchEntries, sessionId, restoredMessages, error);
1474
+ startupSnapshot = recovery.snapshot;
1475
+ restored = rebaseForkState(recovery.inherited.state, startupSnapshot, sessionId);
1476
+ restoredPersistence = recovery.inherited;
1477
+ forkBaseline = true;
1478
+ restoreError = null;
1479
+ } catch (forkError) {
1480
+ restoreError = forkError;
1481
+ }
1482
+ }
1305
1483
  }
1306
1484
  for (const entry of branchEntries) {
1307
1485
  if (entry?.type !== "custom") continue;
1308
1486
  if (entry.customType !== providerMeasurementEntryType) continue;
1487
+ const receiptSessionId = ownValue(entry.data, "sessionId");
1488
+ if (forkedSession && typeof receiptSessionId === "string" && receiptSessionId !== sessionId) {
1489
+ try { parseProviderContextMeasurementReceipt(entry.data, receiptSessionId); }
1490
+ catch (error) { measurementRestoreError = error; }
1491
+ continue;
1492
+ }
1309
1493
  try {
1310
1494
  const receipt = parseProviderContextMeasurementReceipt(entry.data, sessionId);
1311
1495
  const boundRevision = measurements.providerMeasurementRevisionByMessageSha.get(receipt.messageSha256);
@@ -1318,6 +1502,12 @@ export function registerActiveContext(pi: any, options: {
1318
1502
  throw new Error("One provider response has conflicting durable measurement receipts");
1319
1503
  }
1320
1504
  measurements.providerMeasurementByMessageSha.set(receipt.messageSha256, receipt);
1505
+ if (restoreError === null && receipt.projectionRevision > (restored?.revision ?? 0)) {
1506
+ aheadReceiptRevision = Math.max(aheadReceiptRevision ?? 0, receipt.projectionRevision);
1507
+ // This receipt describes a projection whose state is absent from this branch.
1508
+ // Keep the original receipt, but never anchor the restored window to it.
1509
+ continue;
1510
+ }
1321
1511
  const fingerprint = restoredPersistence?.projectionFingerprints.get(receipt.projectionRevision);
1322
1512
  if (fingerprint) {
1323
1513
  measurements.providerMeasurementAnchorByMessageSha.set(receipt.messageSha256, {
@@ -1336,6 +1526,12 @@ export function registerActiveContext(pi: any, options: {
1336
1526
  }
1337
1527
  }
1338
1528
  const durableRestored = restored ?? emptyActiveContextState(sessionId);
1529
+ if (restoreError === null && aheadReceiptRevision !== null) {
1530
+ curation.restoreRevisionGap = true;
1531
+ safeNotify(ctx, `A provider receipt names revision ${aheadReceiptRevision}, ahead of the restored ` +
1532
+ `${brandNoun} ledger at revision ${durableRestored.revision}. Existing folds and raw history are retained; ` +
1533
+ "the next context pass will rebuild eligible folds from that history. Missing ledger changes cannot be replayed.", "warning");
1534
+ }
1339
1535
  persistence.state = durableRestored.prepared ? clearPrepared(durableRestored) : clone(durableRestored);
1340
1536
  // AN UNREADABLE LINEAGE IS NOT AN EMPTY ONE (2026-09-07). `persistedWireVersion` reads
1341
1537
  // 0 for both, and `persist` writes a CHECKPOINT whenever it is not 2, so a restore that
@@ -1348,23 +1544,25 @@ export function registerActiveContext(pi: any, options: {
1348
1544
  // the same ids with drifted bytes, 48 of 85 ids left conflicting. The two states are
1349
1545
  // told apart here, once, rather than at each of `persist`'s callers.
1350
1546
  persistence.lineageUnreadable = restoreError !== null;
1351
- persistence.persistedWireVersion = restoredPersistence?.wireVersion ?? 0;
1352
- persistence.deltasSinceCheckpoint = restoredPersistence?.deltasSinceCheckpoint ?? 0;
1353
- persistence.persistedFoldRecords = restoredPersistence?.records ?? new Map<string, FoldRecordEntry>();
1354
- persistence.persistedStateSha256 = restoredPersistence?.stateSha256 ?? semanticStateSha256(durableRestored);
1355
- const restoredMessages = ctx.sessionManager.buildSessionContext?.()?.messages;
1547
+ const persistedRestore = forkBaseline ? null : restoredPersistence;
1548
+ const persistedState = forkBaseline ? emptyActiveContextState(sessionId) : durableRestored;
1549
+ persistence.persistedWireVersion = persistedRestore?.wireVersion ?? 0;
1550
+ persistence.deltasSinceCheckpoint = persistedRestore?.deltasSinceCheckpoint ?? 0;
1551
+ persistence.persistedFoldRecords = persistedRestore?.records ?? new Map<string, FoldRecordEntry>();
1552
+ persistence.persistedStateSha256 = persistedRestore?.stateSha256 ?? semanticStateSha256(persistedState);
1553
+ persistence.persisted = clone(persistedState);
1554
+ persistence.forkCheckpointPending = forkBaseline;
1356
1555
  measurements.lastProviderMeasurement = latestProviderContextMeasurement(
1357
1556
  Array.isArray(restoredMessages) ? restoredMessages : [],
1358
1557
  budgetWindowFor(ctx),
1359
1558
  ctx.model,
1360
1559
  );
1361
1560
  measurements.latestRatio = contextUsageRatio(measurements.lastProviderMeasurement);
1362
- persistence.persisted = clone(durableRestored);
1363
1561
  // Reuse Pi's own reconstructed messages, including custom messages and branch
1364
1562
  // selection. This is ONLY a view for the bar: do not populate latestSnapshot,
1365
1563
  // advance the frontier, persist a projection, or pretend a provider event occurred.
1366
1564
  if (typeof ctx.ui?.setWidget === "function" && Array.isArray(restoredMessages)) {
1367
- try { foldBar.startupSnapshot = snapshotForEvent(ctx, restoredMessages); }
1565
+ try { foldBar.startupSnapshot = startupSnapshot ?? snapshotForEvent(ctx, restoredMessages); }
1368
1566
  catch { /* Keep the honest mapping fallback if the host cannot supply a view. */ }
1369
1567
  }
1370
1568
  // The warning names the CONSEQUENCE and not only the cause: "state was ignored" read as
@@ -1412,7 +1610,7 @@ export function registerActiveContext(pi: any, options: {
1412
1610
  next = persistenceProjection(next, projectionSnapshot);
1413
1611
  }
1414
1612
  next.folds = normalizeFoldsForPersistedRecords(next.folds, persistence.persistedFoldRecords);
1415
- if (sameStateProjection(next, persistence.persisted)) {
1613
+ if (sameStateProjection(next, persistence.persisted) && !persistence.forkCheckpointPending) {
1416
1614
  if (arrivingFoldIds.length) {
1417
1615
  const lost = clone(persistence.state).folds.find((fold) => fold.id === arrivingFoldIds[0]);
1418
1616
  const lostRefs = lost ? flattenFoldRefs(lost, persistence.state) : [];
@@ -1491,6 +1689,7 @@ export function registerActiveContext(pi: any, options: {
1491
1689
  await pi.appendEntry(stateEntryType, wire);
1492
1690
  persistence.persisted = clone(next);
1493
1691
  persistence.persistedWireVersion = 2;
1692
+ persistence.forkCheckpointPending = false;
1494
1693
  persistence.deltasSinceCheckpoint = wire.kind === "checkpoint" ? 0 : persistence.deltasSinceCheckpoint + 1;
1495
1694
  persistence.persistedStateSha256 = wire.stateSha256;
1496
1695
  persistence.state = lifecycle.shuttingDown ? null : clone(next);
@@ -2454,7 +2653,8 @@ export function registerActiveContext(pi: any, options: {
2454
2653
  recovered,
2455
2654
  note: measured.over
2456
2655
  ? `The rebuilt request is still ${measured.tokens} tokens against a ${measured.budgetTokens}-token ` +
2457
- "serving budget, so the run stops here rather than sending a request the provider will reject again."
2656
+ "serving budget by our estimate. Available folds have been attempted; the retry is being sent, " +
2657
+ "and the provider will determine whether it fits. Recovery is not yet established."
2458
2658
  : recovered
2459
2659
  ? "A rollback was required: the provider rejected the last request, which overfilled the serving " +
2460
2660
  `budget at ${rejectedTokens} estimated tokens against ${measured.budgetTokens}. This pass ` +
@@ -2831,7 +3031,7 @@ export function registerActiveContext(pi: any, options: {
2831
3031
  if (addition.added) { state = addition.state; consolidationAdded += 1; }
2832
3032
  }
2833
3033
  const capacity = servingCapacity(snapshot.contextWindow);
2834
- const usedTokens = capacity.usedTokens;
3034
+ const usedTokens = curation.restoreRevisionGap ? null : capacity.usedTokens;
2835
3035
  const budgetTokens = capacity.budgetTokens;
2836
3036
  const overflowExempt = userRequested || curation.recoveryAttempts > 0 ||
2837
3037
  (usedTokens !== null && budgetTokens > 0 && usedTokens > budgetTokens);
@@ -3219,8 +3419,10 @@ export function registerActiveContext(pi: any, options: {
3219
3419
  phase: string,
3220
3420
  operation: () => Promise<Record<string, unknown> | null>,
3221
3421
  ): Promise<Record<string, unknown> | null> => {
3222
- if (!persistence.state || !measurements.lastProviderMeasurement ||
3223
- !durableProviderMeasurementMatches(measurements.lastProviderMeasurement)) return null;
3422
+ const unmeasuredRecovery = phase === "restore-revision-gap" ||
3423
+ (phase === "projection-budget" && curation.pendingRejection !== null && !measurements.lastProviderMeasurement);
3424
+ if (!persistence.state || (!unmeasuredRecovery && (!measurements.lastProviderMeasurement ||
3425
+ !durableProviderMeasurementMatches(measurements.lastProviderMeasurement)))) return null;
3224
3426
  if (ladder.automaticFailure) {
3225
3427
  ladder.automaticFailure.suppressedCallbacks = Math.min(
3226
3428
  Number.MAX_SAFE_INTEGER,
@@ -3280,10 +3482,11 @@ export function registerActiveContext(pi: any, options: {
3280
3482
  return [];
3281
3483
  }
3282
3484
  };
3283
- const enqueueLifecycleLoad = async (ctx: any): Promise<void> => {
3485
+ const enqueueLifecycleLoad = async (ctx: any, forkedSessionHint = false): Promise<void> => {
3284
3486
  const operation = lifecycle.contextQueue.then(() => {
3285
3487
  const loadOperation = ladder.actionQueue.then(async () => {
3286
- load(ctx);
3488
+ load(ctx, false, forkedSessionHint);
3489
+ if (persistence.forkCheckpointPending) await persist(ctx);
3287
3490
  await recoverNativeReceipts(ctx);
3288
3491
  });
3289
3492
  ladder.actionQueue = loadOperation.catch(() => undefined);
@@ -3293,8 +3496,12 @@ export function registerActiveContext(pi: any, options: {
3293
3496
  await operation;
3294
3497
  };
3295
3498
 
3296
- const safeLifecycleLoad = async (ctx: any, phase: "session-start" | "session-tree"): Promise<void> => {
3297
- try { await enqueueLifecycleLoad(ctx); }
3499
+ const safeLifecycleLoad = async (
3500
+ ctx: any,
3501
+ phase: "session-start" | "session-tree",
3502
+ forkedSessionHint = false,
3503
+ ): Promise<void> => {
3504
+ try { await enqueueLifecycleLoad(ctx, forkedSessionHint); }
3298
3505
  catch (error) {
3299
3506
  const sessionId = ctx.sessionManager.getSessionId();
3300
3507
  if (!persistence.state || persistence.state.sessionId !== sessionId) persistence.state = emptyActiveContextState(sessionId);
@@ -3316,12 +3523,14 @@ export function registerActiveContext(pi: any, options: {
3316
3523
  });
3317
3524
  };
3318
3525
 
3319
- pi.on("session_start", async (_event: unknown, ctx: any) => {
3320
- await safeLifecycleLoad(ctx, "session-start");
3526
+ pi.on("session_start", async (event: unknown, ctx: any) => {
3527
+ await safeLifecycleLoad(ctx, "session-start", ownValue(event, "reason") === "fork");
3321
3528
  recordResolvedCapacity(ctx);
3322
3529
  await armRollbackLane(ctx);
3323
3530
  });
3324
- pi.on("session_tree", async (_event: unknown, ctx: any) => { await safeLifecycleLoad(ctx, "session-tree"); });
3531
+ pi.on("session_tree", async (_event: unknown, ctx: any) => {
3532
+ await safeLifecycleLoad(ctx, "session-tree", Boolean(ctx.sessionManager.getHeader?.()?.parentSession));
3533
+ });
3325
3534
  pi.on("session_compact", async (event: Record<string, unknown>, ctx: any) => {
3326
3535
  const reason = boundReceiptText(ownValue(event, "reason"), 64, "unknown");
3327
3536
  const decision = makeNativeDecision(
@@ -3496,6 +3705,15 @@ export function registerActiveContext(pi: any, options: {
3496
3705
  // neither of their inputs. What it changes is WHEN the agent learns a fold exists,
3497
3706
  // which is now while the material it covers is still in front of it.
3498
3707
  advanceFoldFrontier(snapshot);
3708
+ if (curation.restoreRevisionGap && !ladder.automaticFailure) {
3709
+ mutationAttempted = true;
3710
+ const action = await attemptAutomaticCommit(snapshot, ctx, "restore-revision-gap", 1);
3711
+ if (action) {
3712
+ persistedSucceeded = true;
3713
+ projected = projectWithAdvisory(snapshot);
3714
+ }
3715
+ curation.restoreRevisionGap = false;
3716
+ }
3499
3717
  // BEFORE the fence, so an ordinary crossing is an ordinary commit and the fence is
3500
3718
  // left holding only the requests that genuinely will not fit.
3501
3719
  if (await enforceBandTop(snapshot, ctx)) {
@@ -3748,7 +3966,8 @@ export function registerActiveContext(pi: any, options: {
3748
3966
  rollback.attempts += 1;
3749
3967
  refuseRollback(
3750
3968
  "the overflow arrived without a compaction event, so pi never stripped the failed message from agent " +
3751
- "state and a tree rollback would leave the tree and agent state disagreeing (auto-compaction is off)",
3969
+ "state and a tree rollback would leave the tree and agent state disagreeing (auto-compaction is off); " +
3970
+ `use /${commandNames.fold} to commit eligible stale context, then retry explicitly`,
3752
3971
  "message_end",
3753
3972
  ctx,
3754
3973
  );
@@ -3816,7 +4035,7 @@ export function registerActiveContext(pi: any, options: {
3816
4035
  "turn after an unsatisfied tool call is a malformed transcript for every provider";
3817
4036
  }
3818
4037
  const occupancyBefore = measurements.lastProviderMeasurement?.tokens ?? null;
3819
- const notice = rollbackNoticeText({
4038
+ let notice = rollbackNoticeText({
3820
4039
  brandNoun,
3821
4040
  toolName,
3822
4041
  tokensRolledBack: estimatedTokens(rolledBackBytes),
@@ -3840,7 +4059,13 @@ export function registerActiveContext(pi: any, options: {
3840
4059
  }
3841
4060
  }
3842
4061
  }
3843
- if (!replayed) safeNotify(ctx, notice, "warning");
4062
+ if (!replayed) {
4063
+ notice = rollbackNoticeText({
4064
+ brandNoun, toolName, tokensRolledBack: estimatedTokens(rolledBackBytes),
4065
+ entriesAbandoned: abandoned.length, replayed: false, replaySkipReason,
4066
+ });
4067
+ safeNotify(ctx, notice, "warning");
4068
+ }
3844
4069
  const record = emit("context.rollback", {
3845
4070
  trigger: "session_before_compact",
3846
4071
  armed: true,
@@ -3897,6 +4122,26 @@ export function registerActiveContext(pi: any, options: {
3897
4122
  try { updateStatus(ctx); } catch { }
3898
4123
  return undefined;
3899
4124
  }
4125
+ // PI CHECKS BEFORE THE FIRST PROMPT, BEFORE ANY CONTEXT EVENT (2026-10-05). After a
4126
+ // context_edit (Pi's own retry recovery writes one per failed attempt) Pi stops trusting
4127
+ // the last provider count and estimates the whole raw transcript, folds unseen: 134% on
4128
+ // session 01a10834, 260% on 01a10161. This used to throw for want of a snapshot, suspend
4129
+ // folding and let native compaction run, which ran Pi out of memory on a 272MB session.
4130
+ // The context event that follows projects and commits on the projection itself, so the
4131
+ // boundary is cancelled and folding stays on.
4132
+ const currentSnapshot = lifecycle.latestSnapshot;
4133
+ if (!currentSnapshot || currentSnapshot.sessionId !== ctx.sessionManager.getSessionId()) {
4134
+ nativeCompaction.lastThresholdDecision = {
4135
+ handled: true,
4136
+ retry: false,
4137
+ reason: `${contextBrand(brandNoun)} blocked stock automatic compaction before the first context event; ` +
4138
+ "the projection is measured when the request is built",
4139
+ compactionReason: reason,
4140
+ nativeCompactionCompleted: false,
4141
+ };
4142
+ try { updateStatus(ctx); } catch { }
4143
+ return { cancel: true };
4144
+ }
3900
4145
  let handoff: Record<string, unknown> | null = null;
3901
4146
  try {
3902
4147
  const snapshot = authoritativeSnapshotFor(ctx);
@@ -4794,7 +5039,7 @@ export function registerActiveContext(pi: any, options: {
4794
5039
  if (!persistence.state) persistence.state = emptyActiveContextState(ctx.sessionManager.getSessionId());
4795
5040
  const snapshot = lifecycle.latestSnapshot
4796
5041
  ? authoritativeSnapshotFor(ctx)
4797
- : snapshotForEvent(ctx, ctx.sessionManager.buildSessionContext().messages);
5042
+ : snapshotForEvent(ctx, piContextMessages(ctx) ?? []);
4798
5043
  if (!lifecycle.latestSnapshot) lifecycle.latestSnapshot = snapshot;
4799
5044
  const divider = args.indexOf(" -- ");
4800
5045
  const selector = (divider >= 0 ? args.slice(0, divider) : args).trim();
@@ -4807,14 +5052,19 @@ export function registerActiveContext(pi: any, options: {
4807
5052
  const occupancy = capacity.usedTokens !== null && capacity.budgetTokens > 0
4808
5053
  ? capacity.usedTokens / capacity.budgetTokens
4809
5054
  : null;
4810
- const topUp = occupancy !== null && occupancy >= thresholds.maxTarget;
5055
+ const estimatedOccupancy = occupancy ?? (capacity.budgetTokens > 0
5056
+ ? estimatedTokens(pricedBytes(projectActiveContext(snapshot, persistence.state!))) / capacity.budgetTokens
5057
+ : 0);
5058
+ const topUp = Boolean(curation.restoreRevisionGap) || estimatedOccupancy >= thresholds.maxTarget;
4811
5059
  const committed = await runCommitEpoch(snapshot, "user-command", topUp, measurements.latestRatio, true);
4812
5060
  // Until this lands the epoch's folds are in the event stream and nowhere else.
4813
5061
  // Swallowing the failure here announced a commit that does not exist and left the
4814
5062
  // session computing folds it would keep throwing away, which is the dead session
4815
5063
  // gate 122 was built for, reached through the user's own command instead.
4816
- try { await persist(ctx); }
4817
- catch (error) {
5064
+ try {
5065
+ await persist(ctx);
5066
+ if (committed) curation.restoreRevisionGap = false;
5067
+ } catch (error) {
4818
5068
  suspendAutomatic(error, "user-command", ctx);
4819
5069
  updateStatus(ctx);
4820
5070
  throw error;
@@ -4904,7 +5154,7 @@ export function registerActiveContext(pi: any, options: {
4904
5154
  }
4905
5155
  const snapshot = lifecycle.latestSnapshot
4906
5156
  ? authoritativeSnapshotFor(ctx)
4907
- : snapshotForEvent(ctx, ctx.sessionManager.buildSessionContext().messages);
5157
+ : snapshotForEvent(ctx, piContextMessages(ctx) ?? []);
4908
5158
  if (!lifecycle.latestSnapshot) lifecycle.latestSnapshot = snapshot;
4909
5159
  if (!persistence.state) throw new Error("The context window is empty; there is nothing to show yet");
4910
5160
 
@@ -69,8 +69,15 @@ export function contentDrifted(snapshot: ActiveContextSnapshot, ref: EvidenceRef
69
69
  return item !== null && item.ref!.sha256 !== ref.sha256;
70
70
  }
71
71
 
72
+ // PI NEVER SHOWS AN EXTENSION ITS SYSTEM MESSAGES (2026-10-05). The context event filters
73
+ // role "system" (ExtensionRunner.emitContext), so a system-prompt-change entry has no
74
+ // place in the window and cannot break a span. Folds cut from a view that did include them
75
+ // (a `/fold` before the first context event, session 01a10161) carry such refs; they are
76
+ // skipped here rather than unseating the fold and, at the next commit, its whole subtree.
72
77
  export function refsInOrder(snapshot: ActiveContextSnapshot, refs: EvidenceRef[]): number[] | null {
73
- const indices = refs.map((ref) => exactMapped(snapshot, ref)?.index ?? -1);
78
+ const placed = refs.filter((ref) => ref.role !== "system");
79
+ if (!placed.length) return null;
80
+ const indices = placed.map((ref) => exactMapped(snapshot, ref)?.index ?? -1);
74
81
  if (indices.some((index) => index < 0) || new Set(indices).size !== indices.length) return null;
75
82
  for (let index = 1; index < indices.length; index += 1) if (indices[index] !== indices[index - 1] + 1) return null;
76
83
  return indices;
@@ -152,11 +152,11 @@ export function rollbackNoticeText(input: {
152
152
  }): string {
153
153
  const head = "The provider rejected the last request because the context exceeded its input limit. " +
154
154
  `${input.brandNoun} rolled the session back past that request (${input.entriesAbandoned} entry(s), ` +
155
- `about ${input.tokensRolledBack} tokens, kept in the tree as an abandoned branch) and folded the ` +
156
- "stale window to make room.";
155
+ `about ${input.tokensRolledBack} tokens, kept in the tree as an abandoned branch).`;
157
156
  if (input.replayed) {
158
- return `${head} The request is being reissued now, so continue the work you were doing; nothing was lost, ` +
159
- `and the folded material is still readable through ${input.toolName}.`;
157
+ return `${head} The request is being reissued now. Its context pass will attempt to fold eligible stale ` +
158
+ "material; the recovery receipt will report the result. Original entries remain in the session tree, " +
159
+ `and folded material is still readable through ${input.toolName}.`;
160
160
  }
161
161
  return `${head} The request was NOT reissued: ${input.replaySkipReason ?? "the rolled-back tail is not replayable"}. ` +
162
162
  "Reissue the work yourself from the state you can see.";
@@ -699,9 +699,12 @@ export function commitCoverage(input: {
699
699
  budgetTokens: number;
700
700
  }): CommitCoverage {
701
701
  const { snapshot, state, usedTokens, budgetTokens } = input;
702
- const targetShare = usedTokens === null || !(budgetTokens > 0)
702
+ // Before the first response, or after a restore, an estimate can size a commit.
703
+ // It never decides whether the resulting request is allowed to reach the provider.
704
+ const occupancyTokens = usedTokens ?? estimatedTokens(pricedBytes(projectActiveContext(snapshot, state)));
705
+ const targetShare = !(budgetTokens > 0)
703
706
  ? 0
704
- : Math.max(0, (usedTokens - snapshot.thresholds.minTarget * budgetTokens) / budgetTokens);
707
+ : Math.max(0, (occupancyTokens - snapshot.thresholds.minTarget * budgetTokens) / budgetTokens);
705
708
  const accounting = markAccounting(snapshot, state);
706
709
  const markedShare = accounting.eligibleFreedBudgetShare;
707
710
  const targetTokens = Math.round(targetShare * budgetTokens);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.2.5",
3
+ "version": "4.2.7",
4
4
  "description": "Lossless context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",