pi-fold 1.0.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,35 +34,22 @@ export function buildActiveContextTool(input: {
34
34
  const correctionGuidance = input.allowedActions.includes("rebrief")
35
35
  ? " Curation is correctable: rebrief replaces a fold's brief, and 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."
36
36
  : "";
37
- // The pin. Protection predates this sentence; the sentence exists because a verb
38
- // nobody can discover is a verb nobody uses.
39
37
  const pinGuidance = input.allowedActions.includes("protect")
40
38
  ? " Protect is the pin: protect with ids holds folds or entries against automatic folding, consolidation and refolding until unprotect releases them, so an expanded span you still need stays expanded. Protect reveals nothing on its own: a folded span stays folded, just held. Pinning moves no context bytes, and fold receipts report how much mass you have pinned."
41
39
  : "";
42
- // Marking is yours, folding is the runtime's. The epoch guidance says how marks
43
- // behave and stops there: there is no agent-callable commit verb to describe.
44
40
  const epochGuidance = input.allowedActions.includes("unmark")
45
41
  ? " Fold scheduling is epoch mode: fold records pending marks and moves no context bytes, and the runtime applies every pending mark in one rewrite when the fold event fires. Mark SEVERAL spans in one call: marks carries several {ids, brief} pairs, and one call answers with your whole picture, every span now held plus the unmarked remainder and what share of the non-fresh window it is. Between fold events nothing else in your context changes, so that result is where the picture lives. Mark freely as you work; when to fold is the runtime's to decide, and unmark withdraws a mark you no longer want."
46
42
  : "";
47
- // The recovery norm. Every placeholder already states the exact expand call, and a
48
- // full run answered questions about folded material from memory anyway, inventing
49
- // plausible values instead of reopening the fold. The syntax was discoverable; the
50
- // norm was not, so the norm is stated where the syntax lives.
51
43
  const recoveryGuidance = input.allowedActions.includes("expand")
52
44
  ? " A fold brief is an index entry, not the source: when a question or task depends on material that is folded, peek or expand that fold and answer from the exact bytes, never from memory of them. Recalling folded detail without reopening it is how specifics get misremembered."
53
45
  : "";
54
- // The standing facts about how this runtime behaves. They belong HERE, in the tool
55
- // surface, because the tool surface sits in the stable prefix: it is cached from the
56
- // first request and costs nothing per request thereafter, forever. The same sentences
57
- // delivered as in-window carriers cost a cache break on the pass that renders them
58
- // and every pass that displaces them. Anything always true is said once, here.
59
46
  const standingGuidance = " Folding is automatic, lossless and recoverable: the runtime folds stale spans on its own when the window needs room, nothing is ever discarded, and every folded span keeps its exact source. You are never asked to make room and no fold is ever announced in advance. Work normally and let it happen.";
60
47
  return {
61
48
  name: input.name,
62
49
  label: input.label,
63
50
  description: input.fullSurface
64
- ? `Page, peek, fold, expand, refold, protect, rebrief, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard 1200-character maximum.`
65
- : `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Call fold only by copying the exact eligibleChapter.action returned by status; if status has no eligibleChapter, continue the task without folding. Supplied fold briefs have a hard 1200-character maximum.`,
51
+ ? `Page, peek, fold, expand, refold, protect, rebrief, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`
52
+ : `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${standingGuidance} Call fold only by copying the exact eligibleChapter.action returned by status; if status has no eligibleChapter, continue the task without folding. Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
66
53
  parameters: {
67
54
  type: "object",
68
55
  additionalProperties: false,
@@ -87,8 +74,8 @@ export function buildActiveContextTool(input: {
87
74
  minLength: 1,
88
75
  maxLength: input.maxBriefChars,
89
76
  description: "Write one for every span you mark. You know why the span mattered and " +
90
- "what you will want back from it; a generator reading the span alone does not. " +
91
- "Leaving it out hands the brief to the summarizer rather than skipping it.",
77
+ "what you will want back from it; the deterministic brief that fills the gap " +
78
+ "reads the span alone and does not.",
92
79
  },
93
80
  },
94
81
  },
@@ -97,7 +84,7 @@ export function buildActiveContextTool(input: {
97
84
  type: "string",
98
85
  minLength: 1,
99
86
  maxLength: input.maxBriefChars,
100
- description: "Factual fold brief; keep it at most 1000 characters to stay below the hard 1200-character limit.",
87
+ description: `Factual fold brief, at most ${input.maxBriefChars} characters.`,
101
88
  },
102
89
  offset: { type: "integer", minimum: 0 },
103
90
  bytes: {
@@ -55,12 +55,6 @@ export function completeTurns(messages: unknown[]): CompleteTurn[] {
55
55
  return turns;
56
56
  }
57
57
 
58
- /**
59
- * Native compaction may resume the assistant after a summary without replaying
60
- * the compacted user message. Once a later user message exists, that closed
61
- * assistant/tool prefix is stale evidence, not part of the fresh user tail.
62
- * Keep the native summary itself raw and require a terminal assistant boundary.
63
- */
64
58
  export function leadingCompactionContinuation(messages: unknown[]): CompleteTurn | null {
65
59
  if (messageRole(messages[0]) !== "compactionSummary") return null;
66
60
  let firstUser = -1;
@@ -74,24 +68,11 @@ export function leadingCompactionContinuation(messages: unknown[]): CompleteTurn
74
68
  return end > 1 && terminalAssistant(messages[end - 1]) ? { start: 1, end } : null;
75
69
  }
76
70
 
77
- /**
78
- * The exact argument surface each read-only active-context action may carry. A context
79
- * call outside this surface is not classified read-only, so its batch never folds
80
- * unmarked.
81
- */
82
71
  export const READ_ONLY_CONTEXT_ACTION_ARGUMENTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
83
72
  status: Object.freeze(["action", "detail", "offset", "limit"]),
84
73
  peek: Object.freeze(["action", "id"]),
85
74
  });
86
75
 
87
- /**
88
- * May the ladder fold this completed call's batch without an agent mark?
89
- *
90
- * Yes for every tool, unless the deployment blacklisted it. The one carve-out is the
91
- * active-context tool itself: a mutating context call folding its own batch would be a
92
- * rewrite caused by a tool call, so only its read-only actions inside the exact argument
93
- * surface above are foldable.
94
- */
95
76
  export function isAutoFoldableToolCall(
96
77
  name: string,
97
78
  args?: unknown,
@@ -132,7 +113,6 @@ export interface ScannedToolBatches extends ValidatedToolBatch {
132
113
  unsafeIndices: Set<number>;
133
114
  }
134
115
 
135
- /** Validate each same-assistant tool batch inside one complete turn; results never cross a batch. */
136
116
  export function scanTurnToolBatches(
137
117
  messages: unknown[],
138
118
  turn: CompleteTurn,
@@ -220,7 +200,6 @@ export function scanTurnToolBatches(
220
200
  return { calls, unsafeIndices };
221
201
  }
222
202
 
223
- /** Validate a complete turn as wholly auto-foldable. Granular callers may still use its safe batches. */
224
203
  export function validateTurnToolBatch(
225
204
  messages: unknown[],
226
205
  turn: CompleteTurn,
@@ -240,12 +219,6 @@ export interface ChapterUnit {
240
219
  turnStart: number;
241
220
  }
242
221
 
243
- /**
244
- * Structural chapter segments are deliberately independent of terminal turns.
245
- * A long-running user turn is one segment even while the coordinator continues
246
- * issuing tools; a later user message closes the preceding segment without
247
- * making that boundary either necessary or sufficient for folding.
248
- */
249
222
  export function chapterSegments(messages: unknown[]): CompleteTurn[] {
250
223
  const starts: number[] = [];
251
224
  for (let index = 0; index < messages.length; index += 1) {
@@ -266,12 +239,6 @@ export function chapterSegments(messages: unknown[]): CompleteTurn[] {
266
239
  return segments;
267
240
  }
268
241
 
269
- /**
270
- * Build indivisible structurally closed units. Chapters may summarize completed
271
- * mutating and failed work, including an old prefix of the currently running
272
- * turn. Every tool-use assistant must own one complete, unique result batch;
273
- * any pending, unmatched, or unknown shape protects its entire segment.
274
- */
275
242
  export function structurallyClosedChapterUnits(messages: unknown[], segment: CompleteTurn): ChapterUnit[] {
276
243
  const units: ChapterUnit[] = [];
277
244
  const ownedCalls = new Set<string>();
@@ -344,26 +311,12 @@ export function unsafeChapterIndices(messages: unknown[]): Set<number> {
344
311
  }
345
312
  const unsafe = new Set<number>();
346
313
  for (let index = 0; index < messages.length; index += 1) if (!eligible.has(index)) unsafe.add(index);
347
- // The initiating request of the still-running segment remains raw even after
348
- // its older completed work has moved beyond the byte tail. It carries the
349
- // live marathon objective; subsequent structurally closed batches may fold.
350
314
  const current = segments.length ? segments[segments.length - 1] : undefined;
351
315
  if (current?.end === messages.length && !terminalAssistant(messages[current.end - 1]) &&
352
316
  messageRole(messages[current.start]) === "user") unsafe.add(current.start);
353
317
  return unsafe;
354
318
  }
355
319
 
356
- /**
357
- * The newest end of the window, where nothing folds.
358
- *
359
- * Two things decide it, and only two. The STRUCTURAL invariant comes first: an
360
- * unfinished turn is never foldable at any share, because the excursion in progress is
361
- * still using the evidence it gathered. Then the freshTail share, walked back turn by
362
- * turn until the suffix carries its bytes. The old hybrid rule -- three complete turns
363
- * OR 24,000 bytes, capped at a quarter of a small window -- is gone: it was three
364
- * numbers in two units answering one question, and at small windows the turn leg and
365
- * the byte leg disagreed about which was the protection.
366
- */
367
320
  export function freshBoundary(messages: unknown[], turns: CompleteTurn[], freshBytes: number): number {
368
321
  if (!messages.length) return 0;
369
322
  let unfinishedUser = messages.length;
@@ -411,22 +364,37 @@ export function toolFreshIndices(
411
364
  type EntryEvidence = { message: unknown; ref: BranchObject["ref"] };
412
365
 
413
366
  /**
414
- * Evidence for ONE session entry, derived once and kept.
367
+ * ONE CANONICALIZATION PER MESSAGE OBJECT.
415
368
  *
416
- * A session is append-only, and `buildContextEntries` hands back the session manager's own
417
- * stored entry objects rather than copies, so an entry mapped once cannot change shape
418
- * underneath us. Keying on the entry OBJECT rather than its id means a replaced entry
419
- * misses and is recomputed instead of served stale: there is no invalidation rule here to
420
- * get wrong, and a branch switch is simply a different path over the same stable objects.
369
+ * Mapping the branch canonicalizes and hashes EVERY event message, then canonicalizes a
370
+ * branch candidate again on each digest match. That is roughly twice the session's bytes
371
+ * of stringify-and-sha per derivation, and a derivation runs many times per request: on
372
+ * sol-20260813-paired rep 2 a single one cost 18.5 seconds against a 16.7 MB session,
373
+ * where rep 1 cost 3.5 against 7.1 MB. The work was never the mapping, it was rebuilding
374
+ * the same strings for messages that had not changed since the last pass.
421
375
  *
422
- * What this removes is not loop overhead. It is a `stableStringify` and a `sha256` per
423
- * message over the WHOLE history on every call, while the window that history projects
424
- * into stays the same size. That is superlinear in total session bytes, and it spent 135
425
- * of the 258 minutes of the 2026-08-11 rep, more than the run spent waiting on the model.
426
- *
427
- * `branchIndex` is deliberately NOT cached. It is a position in the branch being mapped
428
- * now, and a different leaf yields a different path across the same entries.
376
+ * This is gate 121's rule at the layer above it, and it carries gate 121's tradeoff
377
+ * unchanged and on purpose: the memo is keyed on the message OBJECT, so a replaced object
378
+ * misses and recomputes rather than serving stale, and a message MUTATED IN PLACE keeps
379
+ * its first canonical form. The session is append-only, so nothing mutates a message that
380
+ * has already been mapped; the cost is stated rather than guarded against.
429
381
  */
382
+ const MESSAGE_WIRE = new WeakMap<object, { wire: string; digest: string }>();
383
+
384
+ function messageWire(message: unknown): { wire: string; digest: string } | null {
385
+ const key = message && typeof message === "object" ? message as object : null;
386
+ if (key) {
387
+ const cached = MESSAGE_WIRE.get(key);
388
+ if (cached) return cached;
389
+ }
390
+ let wire: string;
391
+ try { wire = stableStringify(evidenceValue(message)); }
392
+ catch { return null; }
393
+ const canonical = { wire, digest: sha256Text(wire) };
394
+ if (key) MESSAGE_WIRE.set(key, canonical);
395
+ return canonical;
396
+ }
397
+
430
398
  const ENTRY_EVIDENCE = new WeakMap<object, {
431
399
  sessionId: string;
432
400
  projectEntry: (entry: Record<string, unknown>) => unknown[];
@@ -451,7 +419,6 @@ function entryEvidence(
451
419
  try {
452
420
  items.push({ message, ref: evidenceRef(sessionId, entryId, message) });
453
421
  } catch {
454
- // Unknown persisted shapes stay unavailable rather than weakening exact refs.
455
422
  }
456
423
  }
457
424
  }
@@ -471,7 +438,6 @@ export function mapActiveContext(input: {
471
438
  blacklistAutoFoldTools?: ReadonlySet<string>;
472
439
  readOnlyContextActions?: ReadonlySet<string>;
473
440
  contextWindow?: number;
474
- /** True when `contextWindow` is a declared serving budget rather than a raw window. */
475
441
  netBudget?: boolean;
476
442
  thresholds?: ActiveContextThresholds;
477
443
  }): ActiveContextSnapshot {
@@ -491,19 +457,16 @@ export function mapActiveContext(input: {
491
457
  let cursor = 0;
492
458
  for (let index = 0; index < input.eventMessages.length; index += 1) {
493
459
  const message = input.eventMessages[index];
494
- let wire: string | null = null;
495
- let digest: string | null = null;
496
- try {
497
- wire = stableStringify(evidenceValue(message));
498
- digest = sha256Text(wire);
499
- } catch {
460
+ const canonical = messageWire(message);
461
+ if (!canonical) {
500
462
  mapped.push({ index, message, ref: null });
501
463
  continue;
502
464
  }
465
+ const { wire, digest } = canonical;
503
466
  let match = -1;
504
467
  for (let branchIndex = cursor; branchIndex < branchObjects.length; branchIndex += 1) {
505
468
  const candidate = branchObjects[branchIndex];
506
- if (candidate.ref.sha256 === digest && stableStringify(evidenceValue(candidate.message)) === wire) {
469
+ if (candidate.ref.sha256 === digest && messageWire(candidate.message)?.wire === wire) {
507
470
  match = branchIndex;
508
471
  break;
509
472
  }
@@ -519,12 +482,6 @@ export function mapActiveContext(input: {
519
482
  Number.isFinite(input.contextWindow) && input.contextWindow > 0
520
483
  ? input.contextWindow
521
484
  : null;
522
- // One denominator: both zone widths are shares of the serving budget the declared
523
- // window yields, so a smaller window narrows the tails in proportion instead of
524
- // meeting a fixed byte floor that only a second constant could rescue.
525
- // A deployment that declared `providerInputBudget` passes its window in ALREADY NET of
526
- // the reservation it holds back, so the zone widths divide by it as given. Only the
527
- // descriptor fallback estimates a reservation out of the raw window.
528
485
  const thresholds = input.thresholds ?? DEFAULT_THRESHOLDS;
529
486
  const budgetTokens = input.netBudget === true
530
487
  ? (reportedContextWindow ?? DEFAULT_CONTEXT_WINDOW)
@@ -532,30 +489,8 @@ export function mapActiveContext(input: {
532
489
  const freshBytes = zoneBytes(thresholds.freshTail, budgetTokens);
533
490
  const turns = completeTurns(input.eventMessages);
534
491
  const boundary = freshBoundary(input.eventMessages, turns, freshBytes);
535
- // THERE IS NO POSITIONAL REACH HERE ANY MORE.
536
- //
537
- // This function used to compute a second boundary, a byte prefix of the window called
538
- // the stale zone, and the selectors would take nothing above it. Two reps died of it.
539
- // Rep 2 clamped it to the last CLOSED turn, so a marathon session that never closed
540
- // one had a zero-width zone: the last call announced 19 unmarked spans worth 274,173
541
- // tokens while the selector could propose nothing, no commit ever fired, and the
542
- // window was rejected twice at 375,830 estimated tokens. Rep 3 unclamped it and walked
543
- // raw bytes, so a folded prefix was charged for evidence it had already replaced: 17
544
- // folded results cost 1,002,801 raw bytes of a 1,072,081-byte allowance, the reach
545
- // froze at index 36 of 133, and the run died against the fence at 0.972 occupancy with
546
- // 25 takeable batches sitting above the line.
547
- //
548
- // Shane's ruling (2026-08-10): stale is a CLASS, not a position. A span is
549
- // automatically foldable because of what it IS -- a completed tool batch the deployment
550
- // did not blacklist, or older material a chapter or consolidation can compose -- and
551
- // never because of where it sits. What this function still produces is the protection
552
- // that is genuinely positional: the fresh byte tail, set-shaped, in `protectedIndices`
553
- // and `toolProtectedIndices`. The rest of the law is membership plus the agent's pins
554
- // plus the guard-with-waiver adjudicated once at commit.
555
492
  const protectedIndices = unsafeChapterIndices(input.eventMessages);
556
493
  const toolProtectedIndices = toolFreshIndices(input.eventMessages, turns, freshBytes);
557
- // Chapter protection is set-shaped: preserve the newest complete turns and
558
- // raw byte tail, while allowing an older closed prefix of the current turn.
559
494
  for (const index of toolProtectedIndices) protectedIndices.add(index);
560
495
  for (let mappedIndex = 0; mappedIndex < mapped.length; mappedIndex += 1) {
561
496
  const item = mapped[mappedIndex];
@@ -586,16 +521,6 @@ export function mapActiveContext(input: {
586
521
  return snapshot;
587
522
  }
588
523
 
589
- /**
590
- * The evidence boundary of the CURRENT turn: the position of the last terminal
591
- * assistant message. Everything after it was gathered by the excursion still in
592
- * progress, which is exactly the evidence a commit must not fold.
593
- *
594
- * Measured 2026-08-06 (rep 8): nineteen folds landed between the last read result and
595
- * the agent's next reply, rewriting the projection from 938k to 487k chars, so the
596
- * agent answered from a window where its own just-gathered evidence had become
597
- * placeholders. Freshness in bytes did not catch it; the boundary is a TURN.
598
- */
599
524
  export function currentTurnBoundary(snapshot: Pick<ActiveContextSnapshot, "messages">): number {
600
525
  let boundary = -1;
601
526
  for (let index = 0; index < snapshot.messages.length; index += 1) {
@@ -604,7 +529,6 @@ export function currentTurnBoundary(snapshot: Pick<ActiveContextSnapshot, "messa
604
529
  return boundary;
605
530
  }
606
531
 
607
- /** Every tool-result evidence key produced since that boundary. */
608
532
  export function currentTurnRefKeys(snapshot: ActiveContextSnapshot): Set<string> {
609
533
  const boundary = currentTurnBoundary(snapshot);
610
534
  const keys = new Set<string>();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fold",
3
- "version": "1.0.3",
3
+ "version": "2.0.0",
4
4
  "description": "Lossless, agent-governed context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
5
5
  "keywords": [
6
6
  "pi-package",