pi-fold 4.2.4 → 4.2.6

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
@@ -27,7 +27,7 @@ Compaction replaces the transcript with a summary and discards the originals. Fo
27
27
  <img alt="Compaction turns the transcript into a summary and discards the originals, so the session starts over. pi-fold turns it into a brief and keeps the exact originals on disk, one peek or expand away, so the session keeps going." src="https://raw.githubusercontent.com/shaneconner/fold/main/media/fold-vs-compaction-light.svg">
28
28
  </picture>
29
29
 
30
- A fold takes a contiguous span of session entries and replaces it, in the window only, with a short brief. The entries go to a fold store byte for byte, addressed by their SHA-256 hash. The brief carries a handle, the handle resolves to the original, and expansion restores the exact bytes after verifying the hash.
30
+ A fold takes a contiguous span of session entries and replaces it, in the window only, with a short brief. The entries stay in Pi's session file byte for byte; the fold finds them by Pi's own entry ids and records each one's SHA-256 hash when it folds. The brief carries a handle, the handle resolves to the originals, and expansion restores them as Pi holds them. If an entry's bytes no longer match the recorded hash, the fold still stands and the change is logged as a `context.drift` event.
31
31
 
32
32
  The package registers one tool, `pi_fold_context`, with nine actions over the transcript: `status`, `peek`, `brief`, `expand`, `refold`, `pin`, `unpin`, `reboundary` and `unmark`. A session that never calls the tool still folds; it degrades into lossless hierarchical compaction.
33
33
 
@@ -52,6 +52,7 @@ import {
52
52
  latestProviderContextMeasurement,
53
53
  orderedRoots,
54
54
  parseNativeCompactionCompletion,
55
+ contentDrifted,
55
56
  exactMapped,
56
57
  foldInterval,
57
58
  parseNativeCompactionDecision,
@@ -1156,6 +1157,18 @@ export function registerActiveContext(pi: any, options: {
1156
1157
  }
1157
1158
  };
1158
1159
 
1160
+ // WHAT PI'S CONTEXT EVENT WOULD HAND OVER, BEFORE ONE HAS ARRIVED (2026-10-05). Pi's
1161
+ // ExtensionRunner.emitContext filters role "system" before any handler sees a message;
1162
+ // buildSessionContext does not. A `/fold` before the first context event cut folds from
1163
+ // the unfiltered view in session 01a10161: spans that crossed a system-prompt change
1164
+ // looked broken, and that commit erased 739 of 920 folds.
1165
+ const piContextMessages = (ctx: any): unknown[] | null => {
1166
+ const messages = ctx.sessionManager.buildSessionContext?.()?.messages;
1167
+ return Array.isArray(messages)
1168
+ ? messages.filter((message: any) => message?.role !== "system")
1169
+ : null;
1170
+ };
1171
+
1159
1172
  const snapshotForEvent = (ctx: any, messages: unknown[]): ActiveContextSnapshot =>
1160
1173
  timedDerivation(() => mapActiveContext({
1161
1174
  sessionId: ctx.sessionManager.getSessionId(),
@@ -1351,7 +1364,7 @@ export function registerActiveContext(pi: any, options: {
1351
1364
  persistence.deltasSinceCheckpoint = restoredPersistence?.deltasSinceCheckpoint ?? 0;
1352
1365
  persistence.persistedFoldRecords = restoredPersistence?.records ?? new Map<string, FoldRecordEntry>();
1353
1366
  persistence.persistedStateSha256 = restoredPersistence?.stateSha256 ?? semanticStateSha256(durableRestored);
1354
- const restoredMessages = ctx.sessionManager.buildSessionContext?.()?.messages;
1367
+ const restoredMessages = piContextMessages(ctx);
1355
1368
  measurements.lastProviderMeasurement = latestProviderContextMeasurement(
1356
1369
  Array.isArray(restoredMessages) ? restoredMessages : [],
1357
1370
  budgetWindowFor(ctx),
@@ -3396,6 +3409,24 @@ export function registerActiveContext(pi: any, options: {
3396
3409
  return changed;
3397
3410
  };
3398
3411
 
3412
+ // A FOLDED ENTRY WHOSE BYTES CHANGED (2026-10-05). The fold still stands over it, by Pi's
3413
+ // identity (lib/measurement exactMapped); what changed is recorded once per entry per
3414
+ // process, so the drift is on the record instead of silently unseating the fold.
3415
+ const reportedDrift = new Set<string>();
3416
+ const recordContentDrift = (snapshot: ActiveContextSnapshot): void => {
3417
+ const state = persistence.state;
3418
+ if (!state) return;
3419
+ const drifted: string[] = [];
3420
+ for (const fold of state.folds) {
3421
+ for (const part of fold.parts) {
3422
+ if (part.kind !== "raw" || reportedDrift.has(part.ref.entryId)) continue;
3423
+ if (!contentDrifted(snapshot, part.ref)) continue;
3424
+ reportedDrift.add(part.ref.entryId);
3425
+ drifted.push(part.ref.entryId);
3426
+ }
3427
+ }
3428
+ if (drifted.length) emit("context.drift", { entries: drifted.length, entry_ids: drifted.slice(0, 64) });
3429
+ };
3399
3430
  const handleContext = async (event: { messages: unknown[] }, ctx: any) => {
3400
3431
  if (lifecycle.shuttingDown) return { messages: event.messages };
3401
3432
  beginMutationPass();
@@ -3412,6 +3443,7 @@ export function registerActiveContext(pi: any, options: {
3412
3443
  const snapshot = snapshotForEvent(ctx, event.messages);
3413
3444
  lifecycle.latestSnapshot = snapshot;
3414
3445
  foldBar.startupSnapshot = null;
3446
+ recordContentDrift(snapshot);
3415
3447
  if (ladder.automaticFailure) {
3416
3448
  ladder.automaticFailure.suppressedCallbacks = Math.min(
3417
3449
  Number.MAX_SAFE_INTEGER,
@@ -3877,6 +3909,26 @@ export function registerActiveContext(pi: any, options: {
3877
3909
  try { updateStatus(ctx); } catch { }
3878
3910
  return undefined;
3879
3911
  }
3912
+ // PI CHECKS BEFORE THE FIRST PROMPT, BEFORE ANY CONTEXT EVENT (2026-10-05). After a
3913
+ // context_edit (Pi's own retry recovery writes one per failed attempt) Pi stops trusting
3914
+ // the last provider count and estimates the whole raw transcript, folds unseen: 134% on
3915
+ // session 01a10834, 260% on 01a10161. This used to throw for want of a snapshot, suspend
3916
+ // folding and let native compaction run, which ran Pi out of memory on a 272MB session.
3917
+ // The context event that follows projects and commits on the projection itself, so the
3918
+ // boundary is cancelled and folding stays on.
3919
+ const currentSnapshot = lifecycle.latestSnapshot;
3920
+ if (!currentSnapshot || currentSnapshot.sessionId !== ctx.sessionManager.getSessionId()) {
3921
+ nativeCompaction.lastThresholdDecision = {
3922
+ handled: true,
3923
+ retry: false,
3924
+ reason: `${contextBrand(brandNoun)} blocked stock automatic compaction before the first context event; ` +
3925
+ "the projection is measured when the request is built",
3926
+ compactionReason: reason,
3927
+ nativeCompactionCompleted: false,
3928
+ };
3929
+ try { updateStatus(ctx); } catch { }
3930
+ return { cancel: true };
3931
+ }
3880
3932
  let handoff: Record<string, unknown> | null = null;
3881
3933
  try {
3882
3934
  const snapshot = authoritativeSnapshotFor(ctx);
@@ -4774,7 +4826,7 @@ export function registerActiveContext(pi: any, options: {
4774
4826
  if (!persistence.state) persistence.state = emptyActiveContextState(ctx.sessionManager.getSessionId());
4775
4827
  const snapshot = lifecycle.latestSnapshot
4776
4828
  ? authoritativeSnapshotFor(ctx)
4777
- : snapshotForEvent(ctx, ctx.sessionManager.buildSessionContext().messages);
4829
+ : snapshotForEvent(ctx, piContextMessages(ctx) ?? []);
4778
4830
  if (!lifecycle.latestSnapshot) lifecycle.latestSnapshot = snapshot;
4779
4831
  const divider = args.indexOf(" -- ");
4780
4832
  const selector = (divider >= 0 ? args.slice(0, divider) : args).trim();
@@ -4884,7 +4936,7 @@ export function registerActiveContext(pi: any, options: {
4884
4936
  }
4885
4937
  const snapshot = lifecycle.latestSnapshot
4886
4938
  ? authoritativeSnapshotFor(ctx)
4887
- : snapshotForEvent(ctx, ctx.sessionManager.buildSessionContext().messages);
4939
+ : snapshotForEvent(ctx, piContextMessages(ctx) ?? []);
4888
4940
  if (!lifecycle.latestSnapshot) lifecycle.latestSnapshot = snapshot;
4889
4941
  if (!persistence.state) throw new Error("The context window is empty; there is nothing to show yet");
4890
4942
 
@@ -99,7 +99,8 @@ export type ContextEventKind =
99
99
  | "context.projection"
100
100
  | "context.usage"
101
101
  | "context.prefix"
102
- | "context.anchor";
102
+ | "context.anchor"
103
+ | "context.drift";
103
104
 
104
105
  export function prefixDivergence(
105
106
  previous: string | null,
@@ -50,14 +50,34 @@ export function mappedByKey(snapshot: ActiveContextSnapshot): Map<string, Mapped
50
50
  return indexed;
51
51
  }
52
52
 
53
+ // A FOLDED ENTRY IS FOUND BY PI'S IDENTITY, NOT BY ITS BYTES (2026-10-05). Pi names every
54
+ // entry (session, entry id, role) and the transcript it hands back is the truth for that
55
+ // entry. This also required the content digest recorded at fold time to match, and one
56
+ // assistant message in session 01a10834 hashed differently live than as persisted (1 of
57
+ // 3,686 refs, cause unidentified): on resume its root could not be seated, the commit
58
+ // that followed erased the root's 377 folds, and the request went out at ~1.49M tokens
59
+ // against a 1M window. A fold over an entry whose bytes changed now stands over Pi's
60
+ // current bytes; `contentDrifted` names the case so it is recorded rather than silent.
53
61
  export function exactMapped(snapshot: ActiveContextSnapshot, ref: EvidenceRef): MappedMessage | null {
54
62
  const item = mappedByKey(snapshot).get(objectRefKey(ref));
55
- return item && sameObjectIdentity(item.ref!, ref) && item.ref!.sha256 === ref.sha256 &&
56
- evidenceSha256(item.message) === ref.sha256 ? item : null;
63
+ return item && sameObjectIdentity(item.ref!, ref) ? item : null;
57
64
  }
58
65
 
66
+ /** The entry is where the ref says, but its bytes are not the ones it was folded with. */
67
+ export function contentDrifted(snapshot: ActiveContextSnapshot, ref: EvidenceRef): boolean {
68
+ const item = exactMapped(snapshot, ref);
69
+ return item !== null && item.ref!.sha256 !== ref.sha256;
70
+ }
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.
59
77
  export function refsInOrder(snapshot: ActiveContextSnapshot, refs: EvidenceRef[]): number[] | null {
60
- 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);
61
81
  if (indices.some((index) => index < 0) || new Set(indices).size !== indices.length) return null;
62
82
  for (let index = 1; index < indices.length; index += 1) if (indices[index] !== indices[index - 1] + 1) return null;
63
83
  return indices;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "4.2.4",
3
+ "version": "4.2.6",
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",