pi-fold 4.2.6 → 4.2.8

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.
package/README.md CHANGED
@@ -110,7 +110,7 @@ Four commands ship for the human: `/fold-status`, `/fold` (commit every staged m
110
110
 
111
111
  <picture>
112
112
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/shaneconner/fold/main/media/marks-and-commits-dark.svg">
113
- <img alt="A completed unit becomes a pending mark that leaves the window and its cached prefix untouched. Marks repeat for free below maxTarget; at maxTarget one commit applies every standing mark in a single rewrite. From the brief, peek appends the exact bytes and expand restores them in place, both after verifying the SHA-256." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/marks-and-commits-light.svg">
113
+ <img alt="A completed unit becomes a pending mark that leaves the window and its cached prefix untouched. Marks repeat for free below maxTarget; at maxTarget one commit applies every standing mark in a single rewrite. From the brief, peek appends verified history without thinking blocks and expand restores the original messages in place." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/marks-and-commits-light.svg">
114
114
  </picture>
115
115
 
116
116
  A mark moves nothing. The moment a tool batch or chapter closes, the runtime cuts it into a pending mark, up to eight cuts per pass, stalest first. The mark lives in durable state outside the window, so the projection stays byte-identical and the cached prefix survives. Marks accumulate for free until one commit applies all of them in a single rewrite.
@@ -136,10 +136,12 @@ Every commit re-reads roughly `minTarget` of the budget uncached, so a shallower
136
136
 
137
137
  ### Peek and expand
138
138
 
139
- `peek` returns a fold's exact SHA-256-verified source as a tool result at any depth, with the ancestors still collapsed. It appends and never edits the prefix, so checking what a fold holds costs nothing a commit has to pay for. It takes `offset` and `bytes` for a bounded slice and reads any child fold by id, so a large fold has a narrow read.
139
+ `peek` verifies a fold's original source against its SHA-256 references, then returns a history view as a tool result at any depth, with the ancestors still collapsed. Assistant thinking blocks and their opaque signatures are omitted before slicing; visible text, tool calls and tool results stay verbatim. It appends and never edits the prefix, so checking what a fold holds costs nothing a commit has to pay for. It takes `offset` and `bytes` within that view for a bounded slice and reads any child fold by id, so a large fold has a narrow read. The result reports `sourceFormat: "messages-without-thinking"` and `omittedThinkingBlocks`. Pi's stored transcript and exact `expand` restoration remain unchanged.
140
140
 
141
141
  A peek is ephemeral by default: the bytes ride the window until the model's next message, then their place holds a one-line placeholder and the reply that used them is the surviving trace. Peeking again is lossless and costs one append. The default follows what the agent chose: on the run measured for it, six of seven peeks asked for ephemeral at first exposure. `expand` is the commitment, restoring the source in place until the fold is refolded. Looking is not the same as taking.
142
142
 
143
+ Anthropic documents [reasoning-extraction refusals](https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#keep-reasoning-in-thinking-blocks). Omitting native thinking from history reads avoids replaying it as ordinary text, but does not guarantee that a provider will accept a request. Existing tool results can still contain previously printed transcript excerpts. A durable peek stays in later requests, and reloading makes an ephemeral peek durable. Fold does not automatically retry content-filter refusals or remove arbitrary transcript text.
144
+
143
145
  ### Clipping, pins and the compaction path
144
146
 
145
147
  Tool results get a second, lighter treatment at the same commit. Results in the oldest `toolFoldThreshold` share of the projected window, half of it by default, are clipped in view: the identifying head stays, the rest becomes a marker naming the entry id that peeks it back, and the stored bytes are untouched, so a later fold of that span still encodes the full result.
@@ -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
  };
@@ -1184,6 +1192,146 @@ export function registerActiveContext(pi: any, options: {
1184
1192
  thresholds,
1185
1193
  }));
1186
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
+
1187
1335
  const authoritativeSnapshotFor = (ctx: any): ActiveContextSnapshot => {
1188
1336
  const sessionId = ctx.sessionManager.getSessionId();
1189
1337
  if (!lifecycle.latestSnapshot || lifecycle.latestSnapshot.sessionId !== sessionId) {
@@ -1251,7 +1399,7 @@ export function registerActiveContext(pi: any, options: {
1251
1399
  model: measurement.model,
1252
1400
  });
1253
1401
 
1254
- const load = (ctx: any, preserveThresholdDecision = false): void => {
1402
+ const load = (ctx: any, preserveThresholdDecision = false, forkedSessionHint = false): void => {
1255
1403
  lifecycle.generation += 1;
1256
1404
  lifecycle.shuttingDown = false;
1257
1405
  cancelPreparation();
@@ -1291,6 +1439,7 @@ export function registerActiveContext(pi: any, options: {
1291
1439
  curation.contextCalls = 0;
1292
1440
  curation.recoveryAttempts = 0;
1293
1441
  curation.pendingRejection = null;
1442
+ curation.restoreRevisionGap = false;
1294
1443
  curation.lastRecovery = null;
1295
1444
  ladder.lastAutomaticAction = null;
1296
1445
  ladder.automaticFailure = null;
@@ -1299,14 +1448,19 @@ export function registerActiveContext(pi: any, options: {
1299
1448
  measurements.providerMeasurementByMessageSha.clear();
1300
1449
  measurements.providerMeasurementAnchorByMessageSha.clear();
1301
1450
  const sessionId = ctx.sessionManager.getSessionId();
1451
+ const forkedSession = forkedSessionHint || Boolean(ctx.sessionManager.getHeader?.()?.parentSession);
1302
1452
  let restored: ActiveContextState | null = null;
1303
1453
  let restoreError: unknown = null;
1304
1454
  let restoredPersistence: MaterializedStatePersistence | null = null;
1305
1455
  let measurementRestoreError: unknown = null;
1456
+ let forkBaseline = false;
1457
+ let startupSnapshot: ActiveContextSnapshot | null = null;
1458
+ let aheadReceiptRevision: number | null = null;
1306
1459
  const branchEntries = [...ctx.sessionManager.getBranch()];
1460
+ const restoredMessages = piContextMessages(ctx);
1307
1461
  try {
1308
1462
  restoredPersistence = materializeStatePersistence(
1309
- branchEntries,
1463
+ forkedSession ? forkEntriesForSession(branchEntries, sessionId) : branchEntries,
1310
1464
  sessionId,
1311
1465
  stateEntryType,
1312
1466
  foldRecordEntryType,
@@ -1314,10 +1468,28 @@ export function registerActiveContext(pi: any, options: {
1314
1468
  restored = restoredPersistence.state;
1315
1469
  } catch (error) {
1316
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
+ }
1317
1483
  }
1318
1484
  for (const entry of branchEntries) {
1319
1485
  if (entry?.type !== "custom") continue;
1320
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
+ }
1321
1493
  try {
1322
1494
  const receipt = parseProviderContextMeasurementReceipt(entry.data, sessionId);
1323
1495
  const boundRevision = measurements.providerMeasurementRevisionByMessageSha.get(receipt.messageSha256);
@@ -1330,6 +1502,12 @@ export function registerActiveContext(pi: any, options: {
1330
1502
  throw new Error("One provider response has conflicting durable measurement receipts");
1331
1503
  }
1332
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
+ }
1333
1511
  const fingerprint = restoredPersistence?.projectionFingerprints.get(receipt.projectionRevision);
1334
1512
  if (fingerprint) {
1335
1513
  measurements.providerMeasurementAnchorByMessageSha.set(receipt.messageSha256, {
@@ -1348,6 +1526,12 @@ export function registerActiveContext(pi: any, options: {
1348
1526
  }
1349
1527
  }
1350
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
+ }
1351
1535
  persistence.state = durableRestored.prepared ? clearPrepared(durableRestored) : clone(durableRestored);
1352
1536
  // AN UNREADABLE LINEAGE IS NOT AN EMPTY ONE (2026-09-07). `persistedWireVersion` reads
1353
1537
  // 0 for both, and `persist` writes a CHECKPOINT whenever it is not 2, so a restore that
@@ -1360,23 +1544,25 @@ export function registerActiveContext(pi: any, options: {
1360
1544
  // the same ids with drifted bytes, 48 of 85 ids left conflicting. The two states are
1361
1545
  // told apart here, once, rather than at each of `persist`'s callers.
1362
1546
  persistence.lineageUnreadable = restoreError !== null;
1363
- persistence.persistedWireVersion = restoredPersistence?.wireVersion ?? 0;
1364
- persistence.deltasSinceCheckpoint = restoredPersistence?.deltasSinceCheckpoint ?? 0;
1365
- persistence.persistedFoldRecords = restoredPersistence?.records ?? new Map<string, FoldRecordEntry>();
1366
- persistence.persistedStateSha256 = restoredPersistence?.stateSha256 ?? semanticStateSha256(durableRestored);
1367
- const restoredMessages = piContextMessages(ctx);
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;
1368
1555
  measurements.lastProviderMeasurement = latestProviderContextMeasurement(
1369
1556
  Array.isArray(restoredMessages) ? restoredMessages : [],
1370
1557
  budgetWindowFor(ctx),
1371
1558
  ctx.model,
1372
1559
  );
1373
1560
  measurements.latestRatio = contextUsageRatio(measurements.lastProviderMeasurement);
1374
- persistence.persisted = clone(durableRestored);
1375
1561
  // Reuse Pi's own reconstructed messages, including custom messages and branch
1376
1562
  // selection. This is ONLY a view for the bar: do not populate latestSnapshot,
1377
1563
  // advance the frontier, persist a projection, or pretend a provider event occurred.
1378
1564
  if (typeof ctx.ui?.setWidget === "function" && Array.isArray(restoredMessages)) {
1379
- try { foldBar.startupSnapshot = snapshotForEvent(ctx, restoredMessages); }
1565
+ try { foldBar.startupSnapshot = startupSnapshot ?? snapshotForEvent(ctx, restoredMessages); }
1380
1566
  catch { /* Keep the honest mapping fallback if the host cannot supply a view. */ }
1381
1567
  }
1382
1568
  // The warning names the CONSEQUENCE and not only the cause: "state was ignored" read as
@@ -1424,7 +1610,7 @@ export function registerActiveContext(pi: any, options: {
1424
1610
  next = persistenceProjection(next, projectionSnapshot);
1425
1611
  }
1426
1612
  next.folds = normalizeFoldsForPersistedRecords(next.folds, persistence.persistedFoldRecords);
1427
- if (sameStateProjection(next, persistence.persisted)) {
1613
+ if (sameStateProjection(next, persistence.persisted) && !persistence.forkCheckpointPending) {
1428
1614
  if (arrivingFoldIds.length) {
1429
1615
  const lost = clone(persistence.state).folds.find((fold) => fold.id === arrivingFoldIds[0]);
1430
1616
  const lostRefs = lost ? flattenFoldRefs(lost, persistence.state) : [];
@@ -1503,6 +1689,7 @@ export function registerActiveContext(pi: any, options: {
1503
1689
  await pi.appendEntry(stateEntryType, wire);
1504
1690
  persistence.persisted = clone(next);
1505
1691
  persistence.persistedWireVersion = 2;
1692
+ persistence.forkCheckpointPending = false;
1506
1693
  persistence.deltasSinceCheckpoint = wire.kind === "checkpoint" ? 0 : persistence.deltasSinceCheckpoint + 1;
1507
1694
  persistence.persistedStateSha256 = wire.stateSha256;
1508
1695
  persistence.state = lifecycle.shuttingDown ? null : clone(next);
@@ -2466,7 +2653,8 @@ export function registerActiveContext(pi: any, options: {
2466
2653
  recovered,
2467
2654
  note: measured.over
2468
2655
  ? `The rebuilt request is still ${measured.tokens} tokens against a ${measured.budgetTokens}-token ` +
2469
- "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."
2470
2658
  : recovered
2471
2659
  ? "A rollback was required: the provider rejected the last request, which overfilled the serving " +
2472
2660
  `budget at ${rejectedTokens} estimated tokens against ${measured.budgetTokens}. This pass ` +
@@ -2843,7 +3031,7 @@ export function registerActiveContext(pi: any, options: {
2843
3031
  if (addition.added) { state = addition.state; consolidationAdded += 1; }
2844
3032
  }
2845
3033
  const capacity = servingCapacity(snapshot.contextWindow);
2846
- const usedTokens = capacity.usedTokens;
3034
+ const usedTokens = curation.restoreRevisionGap ? null : capacity.usedTokens;
2847
3035
  const budgetTokens = capacity.budgetTokens;
2848
3036
  const overflowExempt = userRequested || curation.recoveryAttempts > 0 ||
2849
3037
  (usedTokens !== null && budgetTokens > 0 && usedTokens > budgetTokens);
@@ -3231,8 +3419,10 @@ export function registerActiveContext(pi: any, options: {
3231
3419
  phase: string,
3232
3420
  operation: () => Promise<Record<string, unknown> | null>,
3233
3421
  ): Promise<Record<string, unknown> | null> => {
3234
- if (!persistence.state || !measurements.lastProviderMeasurement ||
3235
- !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;
3236
3426
  if (ladder.automaticFailure) {
3237
3427
  ladder.automaticFailure.suppressedCallbacks = Math.min(
3238
3428
  Number.MAX_SAFE_INTEGER,
@@ -3292,10 +3482,11 @@ export function registerActiveContext(pi: any, options: {
3292
3482
  return [];
3293
3483
  }
3294
3484
  };
3295
- const enqueueLifecycleLoad = async (ctx: any): Promise<void> => {
3485
+ const enqueueLifecycleLoad = async (ctx: any, forkedSessionHint = false): Promise<void> => {
3296
3486
  const operation = lifecycle.contextQueue.then(() => {
3297
3487
  const loadOperation = ladder.actionQueue.then(async () => {
3298
- load(ctx);
3488
+ load(ctx, false, forkedSessionHint);
3489
+ if (persistence.forkCheckpointPending) await persist(ctx);
3299
3490
  await recoverNativeReceipts(ctx);
3300
3491
  });
3301
3492
  ladder.actionQueue = loadOperation.catch(() => undefined);
@@ -3305,8 +3496,12 @@ export function registerActiveContext(pi: any, options: {
3305
3496
  await operation;
3306
3497
  };
3307
3498
 
3308
- const safeLifecycleLoad = async (ctx: any, phase: "session-start" | "session-tree"): Promise<void> => {
3309
- 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); }
3310
3505
  catch (error) {
3311
3506
  const sessionId = ctx.sessionManager.getSessionId();
3312
3507
  if (!persistence.state || persistence.state.sessionId !== sessionId) persistence.state = emptyActiveContextState(sessionId);
@@ -3328,12 +3523,14 @@ export function registerActiveContext(pi: any, options: {
3328
3523
  });
3329
3524
  };
3330
3525
 
3331
- pi.on("session_start", async (_event: unknown, ctx: any) => {
3332
- 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");
3333
3528
  recordResolvedCapacity(ctx);
3334
3529
  await armRollbackLane(ctx);
3335
3530
  });
3336
- 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
+ });
3337
3534
  pi.on("session_compact", async (event: Record<string, unknown>, ctx: any) => {
3338
3535
  const reason = boundReceiptText(ownValue(event, "reason"), 64, "unknown");
3339
3536
  const decision = makeNativeDecision(
@@ -3508,6 +3705,15 @@ export function registerActiveContext(pi: any, options: {
3508
3705
  // neither of their inputs. What it changes is WHEN the agent learns a fold exists,
3509
3706
  // which is now while the material it covers is still in front of it.
3510
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
+ }
3511
3717
  // BEFORE the fence, so an ordinary crossing is an ordinary commit and the fence is
3512
3718
  // left holding only the requests that genuinely will not fit.
3513
3719
  if (await enforceBandTop(snapshot, ctx)) {
@@ -3760,7 +3966,8 @@ export function registerActiveContext(pi: any, options: {
3760
3966
  rollback.attempts += 1;
3761
3967
  refuseRollback(
3762
3968
  "the overflow arrived without a compaction event, so pi never stripped the failed message from agent " +
3763
- "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`,
3764
3971
  "message_end",
3765
3972
  ctx,
3766
3973
  );
@@ -3828,7 +4035,7 @@ export function registerActiveContext(pi: any, options: {
3828
4035
  "turn after an unsatisfied tool call is a malformed transcript for every provider";
3829
4036
  }
3830
4037
  const occupancyBefore = measurements.lastProviderMeasurement?.tokens ?? null;
3831
- const notice = rollbackNoticeText({
4038
+ let notice = rollbackNoticeText({
3832
4039
  brandNoun,
3833
4040
  toolName,
3834
4041
  tokensRolledBack: estimatedTokens(rolledBackBytes),
@@ -3852,7 +4059,13 @@ export function registerActiveContext(pi: any, options: {
3852
4059
  }
3853
4060
  }
3854
4061
  }
3855
- 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
+ }
3856
4069
  const record = emit("context.rollback", {
3857
4070
  trigger: "session_before_compact",
3858
4071
  armed: true,
@@ -4839,14 +5052,19 @@ export function registerActiveContext(pi: any, options: {
4839
5052
  const occupancy = capacity.usedTokens !== null && capacity.budgetTokens > 0
4840
5053
  ? capacity.usedTokens / capacity.budgetTokens
4841
5054
  : null;
4842
- 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;
4843
5059
  const committed = await runCommitEpoch(snapshot, "user-command", topUp, measurements.latestRatio, true);
4844
5060
  // Until this lands the epoch's folds are in the event stream and nowhere else.
4845
5061
  // Swallowing the failure here announced a commit that does not exist and left the
4846
5062
  // session computing folds it would keep throwing away, which is the dead session
4847
5063
  // gate 122 was built for, reached through the user's own command instead.
4848
- try { await persist(ctx); }
4849
- catch (error) {
5064
+ try {
5065
+ await persist(ctx);
5066
+ if (committed) curation.restoreRevisionGap = false;
5067
+ } catch (error) {
4850
5068
  suspendAutomatic(error, "user-command", ctx);
4851
5069
  updateStatus(ctx);
4852
5070
  throw error;
@@ -1427,13 +1427,31 @@ export function peekFoldSource(input: {
1427
1427
  }): Record<string, unknown> {
1428
1428
  const fold = input.state.folds.find((item) => item.id === input.foldId);
1429
1429
  if (!fold) throw new Error(`Unknown active-context fold ${input.foldId}`);
1430
- const messages = foldStoredSpan({
1430
+ const storedMessages = foldStoredSpan({
1431
1431
  foldId: input.foldId,
1432
1432
  state: input.state,
1433
1433
  entries: input.entries,
1434
1434
  sessionId: input.sessionId,
1435
1435
  projectEntry: input.projectEntry,
1436
1436
  });
1437
+ // Verify the ORIGINAL messages first, then build the history read. Native
1438
+ // reasoning and opaque signatures belong to the provider's assistant protocol,
1439
+ // not to a JSON transcript replayed as ordinary tool-result text (issue #10).
1440
+ // Only structural assistant blocks are removed: prose and tool output containing
1441
+ // words such as "thinking" stay verbatim. Stored refs, hashes and expand are intact.
1442
+ let omittedThinkingBlocks = 0;
1443
+ const messages = storedMessages.map((message) => {
1444
+ const content = ownValue(message, "content");
1445
+ if (!isPlainRecord(message) || ownValue(message, "role") !== "assistant" ||
1446
+ !Array.isArray(content)) return message;
1447
+ const visible = content.filter((block) => {
1448
+ const type = ownValue(block, "type");
1449
+ if (type !== "thinking" && type !== "redacted_thinking") return true;
1450
+ omittedThinkingBlocks += 1;
1451
+ return false;
1452
+ });
1453
+ return visible.length === content.length ? message : { ...message, content: visible };
1454
+ });
1437
1455
  const source = stableStringify(messages);
1438
1456
  const sourceBytes = bytes(source);
1439
1457
  const offset = Math.min(Math.max(0, input.offset ?? 0), sourceBytes);
@@ -1475,6 +1493,8 @@ export function peekFoldSource(input: {
1475
1493
  brief: foldBrief(fold, input.state),
1476
1494
  sourceCount: messages.length,
1477
1495
  sourceSha256: fold.sourceSha256,
1496
+ sourceFormat: "messages-without-thinking",
1497
+ omittedThinkingBlocks,
1478
1498
  sourceBytes,
1479
1499
  offset,
1480
1500
  returnedBytes,
@@ -1493,16 +1513,20 @@ export function peekFoldSource(input: {
1493
1513
  ? { wider: { action: "peek", id: fold.id, bytes: Math.min(sourceBytes, ACTIVE_CONTEXT_POLICY.maxSourceChars) } }
1494
1514
  : {}),
1495
1515
  lifetime: "these bytes stay in the window exactly as returned, like any other tool result, and " +
1496
- `nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s stored source, so the ` +
1516
+ `nothing rewrites this result in place. It is a COPY of fold ${fold.id}'s source view, so the ` +
1497
1517
  `next commit reclaims it behind a placeholder naming ${fold.id}; peek ${fold.id} again for the ` +
1498
1518
  "same verbatim bytes, or pin this result to keep the copy raw.",
1499
1519
  note: (truncated
1500
- ? `Bounded read: ${returnedBytes} of ${sourceBytes} exact source bytes, ${view.omittedBytes} omitted ` +
1520
+ ? `Bounded read: ${returnedBytes} of ${sourceBytes} ${omittedThinkingBlocks ? "source view" : "exact source"} bytes, ${view.omittedBytes} omitted ` +
1501
1521
  `from the middle. Widen with bytes, page with offset, or expand ${fold.id} to restore it in place.`
1502
1522
  : children.length
1503
- ? `Complete stored span, one level: raw entries exactly, and ${children.length} child fold(s) still ` +
1523
+ ? `Complete stored span, one level: raw entries ${omittedThinkingBlocks ? "with thinking blocks omitted" : "exactly"}, and ${children.length} child fold(s) still ` +
1504
1524
  "placeheld. Peek a child id to read its own span; the fold stayed collapsed and no projection changed."
1505
- : "Complete exact source; the fold stayed collapsed and no projection changed.") +
1525
+ : `Complete ${omittedThinkingBlocks ? "source view" : "exact source"}; the fold stayed collapsed and no projection changed.`) +
1526
+ (omittedThinkingBlocks
1527
+ ? ` ${omittedThinkingBlocks} thinking blocks omitted from this read, including their opaque ` +
1528
+ "signatures. The original messages remain unchanged for exact in-place restoration."
1529
+ : "") +
1506
1530
  (catalog.omitted
1507
1531
  ? ` The nested index seats the shallowest ${index.length} of ${index.length + catalog.omitted} ` +
1508
1532
  "descendant briefs inside the byte budget; the deeper rows are one hop away by peeking a child id, " +
@@ -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);
@@ -29,7 +29,7 @@ export function buildActiveContextTool(input: {
29
29
  handler: ToolHandler;
30
30
  }) {
31
31
  const peekGuidance = input.allowedActions.includes("peek")
32
- ? ` Peek is the point read: it returns one fold's index view -- nested folds' briefs seated shallowest first inside the byte budget with the omitted rows counted, plus the head and tail of that fold's own span with the omitted middle stated -- for any fold id, ancestors still collapsed, changing nothing. It serves ONE level: raw entries come back exactly, folds nested inside stay placeheld, and peeking a child id you see there returns that child's own bytes, so anything folded is one hop away at any depth. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it is EPHEMERAL by default: the bytes ride only until your next message, your reply then the surviving trace, so extract what your current task needs from them in that reply. Peeking again is lossless and costs one append, so a read you turn out to need twice is cheap; pass ephemeral false when you need the bytes standing across several turns. Expand is the durable in-place restoration.`
32
+ ? ` Peek is the point read: it returns one fold's index view -- nested folds' briefs seated shallowest first inside the byte budget with the omitted rows counted, plus the head and tail of that fold's own span with the omitted middle stated -- for any fold id, ancestors still collapsed, changing nothing. It serves ONE level: raw entries come back with thinking blocks and their signatures omitted, all other content unchanged, folds nested inside stay placeheld, and peeking a child id you see there returns that child's own bytes, so anything folded is one hop away at any depth. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it is EPHEMERAL by default: the bytes ride only until your next message, your reply then the surviving trace, so extract what your current task needs from them in that reply. Peeking again is lossless and costs one append, so a read you turn out to need twice is cheap; pass ephemeral false when you need the bytes standing across several turns. Expand is the durable in-place restoration.`
33
33
  : "";
34
34
  const correctionGuidance = input.allowedActions.includes("reboundary")
35
35
  ? " Curation is correctable: reboundary with ids re-cuts a span into exactly one fold, which merges adjacent folds when the span covers several and splits one when the span sits inside it. Reboundary with a single id returns that fold's span to raw. A brief is written when you mark the span; a standing fold's brief is not rewritten, because rewriting it rewrites the projection at that fold's placeholder and costs the whole prefix cache from there on."
@@ -80,7 +80,7 @@ export function buildActiveContextTool(input: {
80
80
  bytes: {
81
81
  type: "integer",
82
82
  minimum: input.minPeekSliceBytes,
83
- description: `Peek only: bytes of exact source to return, widening or narrowing the ${input.defaultPeekBytes}-byte default.`,
83
+ description: `Peek only: bytes of the source view to return, widening or narrowing the ${input.defaultPeekBytes}-byte default. Thinking blocks and their signatures are omitted before slicing.`,
84
84
  },
85
85
  limit: { type: "integer", minimum: 1, maximum: 100 },
86
86
  detail: { type: "string", enum: [...input.statusDetails] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.2.6",
3
+ "version": "4.2.8",
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",