pi-fold 4.1.6 → 4.2.1

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
@@ -14,7 +14,7 @@
14
14
  pi install npm:pi-fold
15
15
  ```
16
16
 
17
- Node 22 or later, Pi 0.83 or later. MIT licensed.
17
+ Node 22 or later and Pi 0.83 or later. Pi 1.0 is supported and requires Node 22.19 or later. MIT licensed. Pi supplies the extension's runtime packages; they are declared as host-provided peers, not private dependencies.
18
18
 
19
19
  Folding does not require a memory store, but every best result measured came from pairing it with one. The campaigns used [pi-canon](https://github.com/shaneconner/canon), installed separately. The two began as a single working setup and were split into separate extensions for modularity, and the experiments are what established that they work better as complements than either does alone. Only that pairing has been measured. Any durable long-term memory store is expected to compose the same way, though that is a suspicion rather than a result.
20
20
 
@@ -85,6 +85,7 @@ import {
85
85
  makeStateCheckpoint,
86
86
  makeStateDelta,
87
87
  MAX_ACTIVE_FOLD_RECORDS,
88
+ MAX_DELTAS_BETWEEN_CHECKPOINTS,
88
89
  materializeStatePersistence,
89
90
  foldProvenance,
90
91
  normalizeFoldsForPersistedRecords,
@@ -118,7 +119,7 @@ import {
118
119
  ESTIMATED_BYTES_PER_TOKEN,
119
120
  IMAGE_ESTIMATED_TOKENS,
120
121
  entryTypeNamespace,
121
- MAX_FOLD_SPAN_CHARS,
122
+ chapterSpanCap,
122
123
  DEFAULT_TOOL_FOLD_THRESHOLD,
123
124
  MAX_PINNED_SHARE,
124
125
  PEEK_DEFAULT_MAX_BYTES,
@@ -432,6 +433,14 @@ export function registerActiveContext(pi: any, options: {
432
433
  state: null as ActiveContextState | null,
433
434
  persisted: null as ActiveContextState | null,
434
435
  persistedWireVersion: 0 as 0 | 1 | 2,
436
+ /** Set when the branch HELD durable state this build could not read. Distinct from a
437
+ * session that never had any, which is what `persistedWireVersion` 0 also means and
438
+ * which legitimately writes the first checkpoint. See `persist`. */
439
+ lineageUnreadable: false,
440
+ /** Deltas standing after the newest checkpoint in the branch. See
441
+ * `MAX_DELTAS_BETWEEN_CHECKPOINTS`: at the bound, `persist` writes a checkpoint instead
442
+ * of a delta, which is what keeps a load's replay bounded. */
443
+ deltasSinceCheckpoint: 0,
435
444
  persistedStateSha256: "",
436
445
  persistedFoldRecords: new Map<string, FoldRecordEntry>(),
437
446
  persistenceQueue: Promise.resolve<void>(undefined),
@@ -1187,7 +1196,19 @@ export function registerActiveContext(pi: any, options: {
1187
1196
  }
1188
1197
  const durableRestored = restored ?? emptyActiveContextState(sessionId);
1189
1198
  persistence.state = durableRestored.prepared ? clearPrepared(durableRestored) : clone(durableRestored);
1199
+ // AN UNREADABLE LINEAGE IS NOT AN EMPTY ONE (2026-09-07). `persistedWireVersion` reads
1200
+ // 0 for both, and `persist` writes a CHECKPOINT whenever it is not 2, so a restore that
1201
+ // threw put a second checkpoint into a branch that already had one. The reader refuses
1202
+ // exactly that ("Duplicate active-context v2 checkpoint"), so the next load threw too,
1203
+ // and wrote a third. ONE TRANSIENT FAILURE OF ANY CAUSE LATCHED THE SESSION DEAD AND
1204
+ // EVERY RELOAD MADE IT STRICTLY WORSE: a real session reached five checkpoints, four of
1205
+ // them from four /reload presses inside eleven minutes, and grew from 859 entries to
1206
+ // 4,393 because each restart also re-cut the same spans and wrote fold records under
1207
+ // the same ids with drifted bytes, 48 of 85 ids left conflicting. The two states are
1208
+ // told apart here, once, rather than at each of `persist`'s callers.
1209
+ persistence.lineageUnreadable = restoreError !== null;
1190
1210
  persistence.persistedWireVersion = restoredPersistence?.wireVersion ?? 0;
1211
+ persistence.deltasSinceCheckpoint = restoredPersistence?.deltasSinceCheckpoint ?? 0;
1191
1212
  persistence.persistedFoldRecords = restoredPersistence?.records ?? new Map<string, FoldRecordEntry>();
1192
1213
  persistence.persistedStateSha256 = restoredPersistence?.stateSha256 ?? semanticStateSha256(durableRestored);
1193
1214
  const restoredMessages = ctx.sessionManager.buildSessionContext?.()?.messages;
@@ -1205,11 +1226,19 @@ export function registerActiveContext(pi: any, options: {
1205
1226
  try { foldBar.startupSnapshot = snapshotForEvent(ctx, restoredMessages); }
1206
1227
  catch { /* Keep the honest mapping fallback if the host cannot supply a view. */ }
1207
1228
  }
1208
- if (restoreError) safeNotify(
1209
- ctx,
1210
- `Active-context state was ignored; Pi native context remains authoritative: ${String(restoreError)}`,
1211
- "warning",
1212
- );
1229
+ // The warning names the CONSEQUENCE and not only the cause: "state was ignored" read as
1230
+ // graceful degradation, and the session it described could no longer fold at all. The
1231
+ // suspension carries no ctx of its own so the canonical `context.suspend` record is
1232
+ // emitted for a headless host without a second UI line saying the same thing twice.
1233
+ if (restoreError) {
1234
+ safeNotify(
1235
+ ctx,
1236
+ "Active-context state could not be read, so folding is suspended for this session and " +
1237
+ `Pi native context remains authoritative: ${String(restoreError)}`,
1238
+ "warning",
1239
+ );
1240
+ suspendAutomatic(restoreError, "restore", undefined);
1241
+ }
1213
1242
  if (measurementRestoreError) safeNotify(
1214
1243
  ctx,
1215
1244
  `Malformed provider measurement receipt was ignored; automatic context remains unmeasured: ${String(measurementRestoreError)}`,
@@ -1220,6 +1249,18 @@ export function registerActiveContext(pi: any, options: {
1220
1249
  const persist = (ctx?: any): Promise<void> => {
1221
1250
  const operation = persistence.persistenceQueue.then(async () => {
1222
1251
  if (!persistence.state || !persistence.persisted) return;
1252
+ // THE ONE WRITE THAT MUST NOT HAPPEN. Folding is suspended the moment a restore fails,
1253
+ // so nothing automatic reaches this; a manual action still can, and one appended state
1254
+ // entry is all it takes to latch the session. Raising rather than returning keeps the
1255
+ // law of the discarded commit: a write that cannot land says so instead of vanishing,
1256
+ // and the manual path already restores the state it entered with and reports.
1257
+ if (persistence.lineageUnreadable) {
1258
+ throw new Error(
1259
+ "Active-context durable state is unreadable for this session, so no state may be " +
1260
+ "written: a checkpoint here would be the second in this branch and would make every " +
1261
+ "later load fail",
1262
+ );
1263
+ }
1223
1264
  let next = clone(persistence.state);
1224
1265
  const persistedFoldIds = new Set(persistence.persisted.folds.map((fold) => fold.id));
1225
1266
  const arrivingFoldIds = next.folds.filter((fold) => !persistedFoldIds.has(fold.id)).map((fold) => fold.id);
@@ -1287,7 +1328,15 @@ export function registerActiveContext(pi: any, options: {
1287
1328
  throw new Error("Active-context session changed after fold-record persistence");
1288
1329
  }
1289
1330
  }
1290
- const wire = persistence.persistedWireVersion === 2 ? makeStateDelta(persistence.persisted, next) : makeStateCheckpoint(next);
1331
+ // A CHECKPOINT ENDS THE RUN THE READER WOULD OTHERWISE WALK (2026-09-20). A delta is
1332
+ // the cheap write and stays the common one; at the bound the run is closed instead, so
1333
+ // the next load replays at most `MAX_DELTAS_BETWEEN_CHECKPOINTS` deltas however long the
1334
+ // session runs. A session that is already past the bound when this build first reads it
1335
+ // closes its run on this write, so it pays the long load once and never again.
1336
+ const runIsLong = persistence.deltasSinceCheckpoint >= MAX_DELTAS_BETWEEN_CHECKPOINTS;
1337
+ const wire = persistence.persistedWireVersion === 2 && !runIsLong
1338
+ ? makeStateDelta(persistence.persisted, next)
1339
+ : makeStateCheckpoint(next);
1291
1340
  // THE WIRE CARRIES BOTH DIGESTS IT JUST DERIVED (2026-08-28). `makeStateDelta` writes
1292
1341
  // `baseStateSha256` from `persistence.persisted` and `stateSha256` from `next`, and
1293
1342
  // nothing mutates either state between that call and here, so re-hashing two whole
@@ -1301,6 +1350,7 @@ export function registerActiveContext(pi: any, options: {
1301
1350
  await pi.appendEntry(stateEntryType, wire);
1302
1351
  persistence.persisted = clone(next);
1303
1352
  persistence.persistedWireVersion = 2;
1353
+ persistence.deltasSinceCheckpoint = wire.kind === "checkpoint" ? 0 : persistence.deltasSinceCheckpoint + 1;
1304
1354
  persistence.persistedStateSha256 = wire.stateSha256;
1305
1355
  persistence.state = lifecycle.shuttingDown ? null : clone(next);
1306
1356
  if (!sessionIdentityStillValid(ctx, sessionId, generationAtStart)) {
@@ -4147,7 +4197,7 @@ export function registerActiveContext(pi: any, options: {
4147
4197
  last: rollback.last ? clone(rollback.last) : null,
4148
4198
  },
4149
4199
  },
4150
- foldSpanCap: MAX_FOLD_SPAN_CHARS,
4200
+ foldSpanCap: chapterSpanCap(thresholds.minFoldChars),
4151
4201
  projectionBudgetTokens: currentCapacity(ctx).budgetTokens,
4152
4202
  projectionCharsPerToken: projectionCharsPerToken(),
4153
4203
  projectionEstimatorErrorShare: estimatorErrorShare(),
@@ -51,7 +51,7 @@ import {
51
51
  CONTEXT_STATUS_RESPONSE_BYTES,
52
52
  EXPAND_LEASE_GENERATIONS,
53
53
  MAX_EXPAND_LEASES,
54
- MAX_FOLD_SPAN_CHARS,
54
+ chapterSpanCap,
55
55
  PEEK_DEFAULT_MAX_BYTES,
56
56
  PEEK_HEAD_SHARE,
57
57
  STATUS_DIET_INDEX_ROWS,
@@ -95,6 +95,10 @@ export function selectAutomaticChapter(
95
95
  // The maturity floor: a stale span may end only where the batch lane has already had
96
96
  // its chance, so a chapter can never claim a tool batch one turn before it qualifies.
97
97
  const matureEnd = staleSpanMatureEnd(snapshot.messages);
98
+ // THE CAP FOLLOWS THE FLOOR: a multi-unit span may not pass it, and it sits at least
99
+ // one floor above `minFoldChars` so the accumulate-until-over walk below can always
100
+ // land between the two (policy.ts, chapterSpanCap).
101
+ const spanCap = chapterSpanCap(snapshot.thresholds.minFoldChars);
98
102
  for (let unitIndex = 0; unitIndex < units.length; unitIndex += 1) {
99
103
  const first = units[unitIndex];
100
104
  if (first.end > matureEnd) break;
@@ -131,12 +135,12 @@ export function selectAutomaticChapter(
131
135
  if (claimed.size && refs.some((ref) => claimed.has(objectRefKey(ref)))) break;
132
136
  if (refsProtected(refs, state, snapshot)) break;
133
137
  const size = bytes(encodedFoldSource(snapshot, state, parts, "chapter"));
134
- const biteSized = size <= MAX_FOLD_SPAN_CHARS || endIndex === unitIndex;
138
+ const biteSized = size <= spanCap || endIndex === unitIndex;
135
139
  // ACCUMULATE UNTIL OVER, never cut to hit the number. The walk grows the span one
136
140
  // whole unit at a time and records the first one that carries it past the floor,
137
141
  // so a fold is always at least minFoldChars and always ends on a message boundary.
138
142
  if (size >= snapshot.thresholds.minFoldChars && biteSized) best = { kind: "chapter", parts, sourceRefs: refs };
139
- if (size > MAX_FOLD_SPAN_CHARS) break;
143
+ if (size > spanCap) break;
140
144
  }
141
145
  if (best) return best;
142
146
  }
@@ -68,6 +68,53 @@ export const STATE_DELTA_V2_KEYS = [
68
68
  export const MAX_ACTIVE_FOLD_RECORD_BYTES = 256 * 1024;
69
69
  export const MAX_ACTIVE_STATE_EVENT_BYTES = 512 * 1024;
70
70
  export const MAX_ACTIVE_FOLD_RECORDS = 4_096;
71
+
72
+ /**
73
+ * HOW MANY DELTAS MAY STAND BETWEEN CHECKPOINTS (2026-09-20).
74
+ *
75
+ * The v2 ledger was one checkpoint and then deltas for the life of the session, and
76
+ * `materializeStatePersistence` replays every one of them on every load. Each delta rebuilds
77
+ * the WHOLE state, so a load costs the delta count times the state size: quadratic in
78
+ * session length. Shane's noloss session 01a0ad40 reached 2,207 deltas and took 129.6s to
79
+ * restore, against 0.6s for pi to parse the same 93MB of entries; 01a0ba4e took 56.1s on
80
+ * 1,147 deltas. Doubling the entries quadrupled the time (3,987 entries 9.3s, 7,974 entries
81
+ * 38.8s) and the per-delta cost rose with it (7.5ms to 57.0ms), because what each delta
82
+ * re-walks is the state, not the delta. The load is also not paid once: `session_start`,
83
+ * `session_tree`, `session_compact` and a branch switch each pay it again, and
84
+ * `doubleEscapeAction: "tree"` puts it behind a keypress.
85
+ *
86
+ * A CHECKPOINT STATES THE WHOLE STATE, so a replay may start at the LAST one rather than the
87
+ * only one and never walk the run before it. That bounds a load at this many deltas instead
88
+ * of at the session's lifetime. Nothing is weakened by starting later: a checkpoint carries
89
+ * its own digest and the deltas after it still chain, so the final state is proven by the
90
+ * same equalities as before. What a later start gives up is re-proving revisions that were
91
+ * already proven when they were written, and the fingerprint map loses the revisions before
92
+ * the checkpoint, which its one reader already treats as absent (see `projectionFingerprints`).
93
+ *
94
+ * THE BOUND IS A TRADE, AND 50 IS WHERE IT WAS MEASURED. A checkpoint names every standing
95
+ * fold by id and digest, so it costs far more bytes than a delta: 237.9KB against a 2.55KB
96
+ * delta at 375 folds, 248.9KB against 3.34KB at 213. The load after a checkpoint is 0.34s and
97
+ * 0.20s for those two sessions, and each replayed delta adds about 85ms at 375 folds, so the
98
+ * bound sets both the worst load and the write overhead:
99
+ *
100
+ * K=100 8.8s worst load +93% state-ledger bytes (+5.6% of the session file)
101
+ * K= 50 4.4s worst load +187% state-ledger bytes (+11% of the session file)
102
+ * K= 25 2.2s worst load +374% state-ledger bytes (+22% of the session file)
103
+ *
104
+ * The state ledger is a small part of a session (5.9MB of 01a0ad40's 93MB), which is why the
105
+ * percentages look worse against the ledger than against the file. 50 takes the worst load
106
+ * under five seconds and the average near two, for a tenth of the file, and a session already
107
+ * past the bound heals itself on its next durable write.
108
+ *
109
+ * WHAT WOULD MAKE THE BOUND STOP MATTERING is the per-delta cost itself, which is still the
110
+ * whole state: 23.8% of a profiled load is `structuredClone` and 34% is `stableStringify`
111
+ * under `semanticStateSha256`, both walking every standing fold on every delta. Neither is
112
+ * reachable without giving the reader one canonical fold object per id for the life of a
113
+ * materialization, so that the clone stops being per-delta and a serializer memo can hit at
114
+ * all. That is a change to the aliasing barrier gate 158 guards and is deliberately NOT made
115
+ * here; until it is, this constant is what bounds the load.
116
+ */
117
+ export const MAX_DELTAS_BETWEEN_CHECKPOINTS = 50;
71
118
  export const MAX_ACTIVE_FOLD_PARTS = 1_024;
72
119
  export const MAX_ACTIVE_EXPANDED = 1_024;
73
120
  export const MAX_ACTIVE_PROTECTED = 1_024;
@@ -269,15 +316,54 @@ export function directFoldOwners(folds: readonly ActiveFold[]): Map<string, stri
269
316
  return owner;
270
317
  }
271
318
 
319
+ /**
320
+ * A FOLD THIS MATERIALIZATION HAS ALREADY PROVEN, AND WILL NOT PROVE AGAIN (2026-09-20).
321
+ *
322
+ * The replay's cost was never the deltas, it was re-proving the whole forest at each one.
323
+ * `stateFromFoldRefs` runs per delta and ends in `parseActiveContextState`, so a session with
324
+ * 2,207 deltas and 400 standing folds recomputed 400 content addresses and flattened and
325
+ * hashed 400 fold subtrees 2,207 times over. Profiling a 1,147-delta load put 48.2% of all
326
+ * time inside `validateFoldForest` and 14.8% on `foldIdFor` alone.
327
+ *
328
+ * WHAT THE MARK ASSERTS, and why each half of it is already true:
329
+ *
330
+ * The content address. `parseFoldRecordEntry` refuses a record unless
331
+ * `fold.id === foldIdFor(fold.kind, fold.parts)`, and every fold reaching a marked forest
332
+ * came out of `records` past that check, matched to its ref on `recordSha256`. The only
333
+ * field `assignFoldParents` writes is `parentId`, which `foldIdFor` does not read. So the
334
+ * recomputation could not reach a different verdict. This is the argument the 2026-08-28
335
+ * `foldRecordRef` change already made in `persist`, applied on the reading side.
336
+ *
337
+ * The source digest. This one is NOT implied by the record, so it is not skipped: it is
338
+ * paid ONCE PER DISTINCT FOLD PER MATERIALIZATION instead of once per delta. A fold's
339
+ * flattened refs are a pure function of its id, because the id pins kind and parts and each
340
+ * child id pins its own subtree in turn, so a second computation over the same id is
341
+ * arithmetic already done. `stateFromFoldRefs` marks only ids already proven against THIS
342
+ * records map and leaves a newly arriving fold unmarked, so its digest is still checked, and
343
+ * still inside `validateFoldForest` after the ownership loop that proves its children are
344
+ * present. Error ordering is unchanged.
345
+ *
346
+ * KEYED ON OBJECT IDENTITY, like gate 160's VALIDATED_FORESTS, so nothing outside this module
347
+ * can present itself as proven. It carries none of gate 160's stated cost: a fold mutated in
348
+ * place is never marked, because the mark is applied to the objects `assignFoldParents` has
349
+ * just built and is read before `clone` hands the forest on.
350
+ */
351
+ const RECORD_PROVEN_FOLDS = new WeakSet<ActiveFold>();
352
+ const PROVEN_SOURCE_DIGESTS = new WeakMap<Map<string, FoldRecordEntry>, Set<string>>();
353
+
272
354
  export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
273
355
  const rawValues = denseOwnArrayValues(folds);
274
356
  if (!rawValues || rawValues.some((fold) => !validFoldShape(fold))) {
275
357
  throw new Error("Invalid active-context fold forest shape");
276
358
  }
277
359
  const values = clone(rawValues) as ActiveFold[];
360
+ // READ OFF THE CALLER'S OBJECTS, BEFORE THE CLONE ABOVE COSTS US THE IDENTITY. `clone`
361
+ // preserves order, so the verdict for `values[i]` is the mark on `rawValues[i]`.
362
+ const proven = rawValues.map((fold) => RECORD_PROVEN_FOLDS.has(fold as ActiveFold));
278
363
  const byId = new Map<string, ActiveFold>();
279
- for (const fold of values) {
280
- if (fold.id !== foldIdFor(fold.kind, fold.parts) || byId.has(fold.id)) {
364
+ for (let index = 0; index < values.length; index += 1) {
365
+ const fold = values[index];
366
+ if ((!proven[index] && fold.id !== foldIdFor(fold.kind, fold.parts)) || byId.has(fold.id)) {
281
367
  throw new Error("Invalid active-context fold");
282
368
  }
283
369
  if (fold.kind === "tool-result" &&
@@ -304,7 +390,9 @@ export function validateFoldForest(folds: ActiveFold[]): ActiveFold[] {
304
390
  for (const fold of values) {
305
391
  if ((parent.get(fold.id) ?? null) !== fold.parentId) throw new Error(`Fold ${fold.id} parent drift`);
306
392
  }
307
- for (const fold of values) {
393
+ for (let index = 0; index < values.length; index += 1) {
394
+ if (proven[index]) continue;
395
+ const fold = values[index];
308
396
  const refs = flattenFoldRefs(fold, { folds: values });
309
397
  if (fold.sourceSha256 !== sha256Value(refs)) throw new Error(`Fold ${fold.id} source digest drift`);
310
398
  }
@@ -890,13 +978,28 @@ export function stateFromFoldRefs(
890
978
  if (!record || record.recordSha256 !== ref.sha256) throw new Error(`Missing active-context fold record ${ref.id}`);
891
979
  return record.fold;
892
980
  });
981
+ const parented = assignFoldParents(folds);
982
+ // WHAT THIS MATERIALIZATION HAS ALREADY PROVEN IS MARKED; THE REST IS STILL PROVEN BELOW.
983
+ // See RECORD_PROVEN_FOLDS. A fold standing across deltas is marked from its second
984
+ // appearance on, so its content address and source digest are computed once for the whole
985
+ // replay rather than once per delta. A fold arriving now is deliberately left unmarked: its
986
+ // digest is checked by `validateFoldForest`, after the loop that proves its children are
987
+ // present, exactly where it was checked before.
988
+ let provenIds = PROVEN_SOURCE_DIGESTS.get(records);
989
+ if (!provenIds) {
990
+ provenIds = new Set<string>();
991
+ PROVEN_SOURCE_DIGESTS.set(records, provenIds);
992
+ }
993
+ for (const fold of parented) {
994
+ if (provenIds.has(fold.id)) RECORD_PROVEN_FOLDS.add(fold);
995
+ }
893
996
  const state: ActiveContextState = {
894
997
  version: 1,
895
998
  sessionId: wire.sessionId,
896
999
  revision: wire.revision,
897
1000
  // `parseActiveContextState` at the end of this function validates the forest, so
898
1001
  // validating it here as well was the same walk twice on one persist.
899
- folds: assignFoldParents(folds),
1002
+ folds: parented,
900
1003
  expanded: clone(wire.expanded),
901
1004
  protected: clone(wire.protected),
902
1005
  tokensSinceToolFold: wire.tokensSinceToolFold ?? 0,
@@ -907,7 +1010,10 @@ export function stateFromFoldRefs(
907
1010
  ...(wire.advisory === undefined ? {} : { advisory: clone(wire.advisory) }),
908
1011
  ...(wire.prepared === null || wire.prepared === undefined ? {} : { prepared: clone(wire.prepared) }),
909
1012
  };
910
- return parseActiveContextState(state, wire.sessionId, false);
1013
+ const parsed = parseActiveContextState(state, wire.sessionId, false);
1014
+ // RECORDED ONLY ONCE THE FOREST HAS PASSED, so a throw leaves nothing marked as proven.
1015
+ for (const fold of parented) provenIds.add(fold.id);
1016
+ return parsed;
911
1017
  }
912
1018
 
913
1019
  export interface ProjectionFingerprint {
@@ -933,6 +1039,12 @@ export interface MaterializedStatePersistence {
933
1039
  records: Map<string, FoldRecordEntry>;
934
1040
  stateSha256: string;
935
1041
  projectionFingerprints: ProjectionFingerprints;
1042
+ /**
1043
+ * HOW MANY DELTAS STAND AFTER THE LAST CHECKPOINT in the branch just read. `persist` writes
1044
+ * a checkpoint instead of a delta once this reaches `MAX_DELTAS_BETWEEN_CHECKPOINTS`, which
1045
+ * is what stops the run, and therefore the load, from growing without bound.
1046
+ */
1047
+ deltasSinceCheckpoint: number;
936
1048
  }
937
1049
 
938
1050
  export function materializeStatePersistence(
@@ -985,6 +1097,7 @@ export function materializeStatePersistence(
985
1097
  let stateStart = -1;
986
1098
  let checkpointIndex = -1;
987
1099
  let v2Seen = false;
1100
+ let deltasSinceCheckpoint = 0;
988
1101
  const rememberProjection = (): void => {
989
1102
  fingerprintSources.set(state.revision, state);
990
1103
  };
@@ -1001,11 +1114,25 @@ export function materializeStatePersistence(
1001
1114
  if (version !== 2) throw new Error("Invalid active-context persisted state version");
1002
1115
  const kind = ownValue(entry.data, "kind");
1003
1116
  if (kind === "checkpoint") {
1004
- if (v2Seen || checkpointIndex >= 0) throw new Error("Duplicate active-context v2 checkpoint");
1117
+ // THE LAST CHECKPOINT WINS, AND A SECOND ONE IS NO LONGER FATAL (2026-09-20). This
1118
+ // raised "Duplicate active-context v2 checkpoint" on sight of a second, which made the
1119
+ // ledger append-only in the strongest sense: one checkpoint, then deltas forever, and a
1120
+ // replay that walked all of them on every load. Restarting the replay at the newest
1121
+ // checkpoint is what makes `MAX_DELTAS_BETWEEN_CHECKPOINTS` possible, and it reaches
1122
+ // the same final state by the same digest equalities.
1123
+ //
1124
+ // It also un-latches the failure gate 173 describes. A branch that took a second
1125
+ // checkpoint from a failed restore used to be unreadable forever, and every reload made
1126
+ // it worse; such a branch now reads from its newest checkpoint. That is a second line of
1127
+ // defence, not the first: the `lineageUnreadable` guard in `persist` still refuses to
1128
+ // write the state entry that starts the damage, and gate 173 still pins it.
1005
1129
  checkpointIndex = index;
1006
1130
  stateStart = index;
1131
+ deltasSinceCheckpoint = 0;
1007
1132
  } else if (checkpointIndex < 0) {
1008
1133
  throw new Error("Active-context v2 delta precedes migration checkpoint");
1134
+ } else {
1135
+ deltasSinceCheckpoint += 1;
1009
1136
  }
1010
1137
  v2Seen = true;
1011
1138
  }
@@ -1142,7 +1269,7 @@ export function materializeStatePersistence(
1142
1269
  writtenSha256 = wire.stateSha256 === calculated ? null : wire.stateSha256;
1143
1270
  rememberProjection();
1144
1271
  }
1145
- return { state, wireVersion, records, stateSha256, projectionFingerprints };
1272
+ return { state, wireVersion, records, stateSha256, projectionFingerprints, deltasSinceCheckpoint };
1146
1273
  }
1147
1274
 
1148
1275
  export function materializeActiveContextState(
@@ -343,6 +343,26 @@ export const CONTEXT_STATUS_RESPONSE_BYTES = 24_000;
343
343
  export const MAX_UNMARKED_CANDIDATES = 3;
344
344
 
345
345
  export const MAX_FOLD_SPAN_CHARS = 16_000;
346
+
347
+ /**
348
+ * THE LADDER'S CHAPTER CAP FOLLOWS THE FLOOR (2026-09-07).
349
+ *
350
+ * `selectAutomaticChapter` accumulates whole units until a span reaches `minFoldChars` and
351
+ * refuses a multi-unit span past this cap, so the cap has to sit at least one unit above
352
+ * the floor or the walk can never satisfy both. At the shipped 8,000 the 16,000 constant
353
+ * does. /fold-settings offers floors up to 32,000, and at any floor past the constant every
354
+ * multi-unit chapter was refused by arithmetic: only a single oversized unit or a tool batch
355
+ * could fold, and a prose session with the floor at 32,000 held its raw material forever
356
+ * while the status row announced a commit (session 01a07498, 2026-09-07: fourteen sizable
357
+ * units in view, none over 32,000, the frontier staging nothing). Twice the floor is the
358
+ * smallest cap under which a walk that crosses the floor by one unit shorter than the floor
359
+ * always lands inside it; the constant stays as the cap's own floor so every default reads
360
+ * exactly as before, and a manual span is never bounded by either (Shane, 2026-08-22).
361
+ */
362
+ export function chapterSpanCap(minFoldChars: number): number {
363
+ return Math.max(MAX_FOLD_SPAN_CHARS, 2 * minFoldChars);
364
+ }
365
+
346
366
  export const PEEK_DEFAULT_MAX_BYTES = 16_000;
347
367
  export const PEEK_HEAD_SHARE = 0.6;
348
368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.1.6",
3
+ "version": "4.2.1",
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",
@@ -37,7 +37,8 @@
37
37
  "test": "node tests/verify.mjs"
38
38
  },
39
39
  "peerDependencies": {
40
- "@earendil-works/pi-coding-agent": ">=0.83.0 <1"
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@eslint/js": "^10.0.1",
@@ -52,8 +53,5 @@
52
53
  "extensions": [
53
54
  "./extensions"
54
55
  ]
55
- },
56
- "dependencies": {
57
- "@earendil-works/pi-tui": "^0.84.2"
58
56
  }
59
57
  }