pi-fold 2.0.0 → 3.1.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.
@@ -16,7 +16,6 @@ import {
16
16
  orderedRoots,
17
17
  refsInOrder,
18
18
  refsProtected,
19
- toolRefsProtected,
20
19
  visibleCollapsedRoots,
21
20
  } from "./measurement.ts";
22
21
  import {
@@ -28,7 +27,6 @@ import {
28
27
  import {
29
28
  ACTIVE_CONTEXT_POLICY,
30
29
  DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
31
- MAX_FOLD_SPAN_CHARS,
32
30
  } from "./policy.ts";
33
31
  import type {
34
32
  ActiveContextSnapshot,
@@ -42,9 +40,9 @@ import type {
42
40
  } from "./policy.ts";
43
41
  import {
44
42
  chapterSegments,
45
- currentTurnBoundary,
46
43
  scanTurnToolBatches,
47
44
  structurallyClosedChapterUnits,
45
+ deadAssistant,
48
46
  terminalAssistant,
49
47
  } from "./transcript.ts";
50
48
  import type { ChapterUnit } from "./transcript.ts";
@@ -105,7 +103,14 @@ export function resultCallIndex(snapshot: ActiveContextSnapshot, allowConsumedIn
105
103
  const message = snapshot.messages[index];
106
104
  if (messageRole(message) !== "assistant") continue;
107
105
  if (ownValue(message, "stopReason") === "toolUse") laterGenerations += 1;
108
- else if (complete && terminalAssistant(message)) laterGenerations = Math.max(laterGenerations, 1);
106
+ // A dead turn's own terminal grants the point a finished turn's does: the turn
107
+ // is only in completeTurns because a user message already stands after it, so
108
+ // no later response will ever consume this batch, and demanding one anyway is
109
+ // the hold that deadlocked sol-20260826-full2 (the 423KB peek copy behind an
110
+ // errored assistant, ineligible for thirty-five aborting passes).
111
+ else if (complete && (terminalAssistant(message) || deadAssistant(message))) {
112
+ laterGenerations = Math.max(laterGenerations, 1);
113
+ }
109
114
  }
110
115
  if ((complete && laterGenerations < 1) || (!complete && laterGenerations < 2)) continue;
111
116
  }
@@ -503,9 +508,7 @@ export function pinnedChildFold(
503
508
  const child = byId.get(part.foldId);
504
509
  if (!child) continue;
505
510
  const refs = flattenFoldRefs(child, state);
506
- const pinned = child.kind === "tool-result"
507
- ? toolRefsProtected(refs, state, snapshot)
508
- : refsProtected(refs, state, snapshot);
511
+ const pinned = refsProtected(refs, state, snapshot);
509
512
  if (pinned) return child;
510
513
  }
511
514
  return null;
@@ -513,7 +516,7 @@ export function pinnedChildFold(
513
516
 
514
517
  export function pinnedNestingRefusal(pinned: ActiveFold, toolName: string): string {
515
518
  return `fold refused: ${pinned.id} is pinned, and nesting it would hide context you asked to keep. ` +
516
- `Release it with ${toolName} {"action":"unprotect","ids":["${pinned.id}"]} first, ` +
519
+ `Release it with ${toolName} {"action":"unpin","ids":["${pinned.id}"]} first, ` +
517
520
  "or name a span that stops at its boundary.";
518
521
  }
519
522
 
@@ -676,7 +679,6 @@ function consolidationCandidate(
676
679
  if (refsProtected(raw, state, snapshot)) return null;
677
680
  if (raw.some((ref) => claims.refKeys.has(objectRefKey(ref)))) return null;
678
681
  const sourceRefs = candidateSourceRefs(parts, state);
679
- if (sourceRefs.length > snapshot.policy.maxFoldSourceRefs) return null;
680
682
  return { kind: "consolidation", parts, sourceRefs };
681
683
  }
682
684
 
@@ -734,15 +736,47 @@ export function automaticToolBatches(
734
736
  if (ids.size !== expected.length || expected.some((id) => !ids.has(id)) ||
735
737
  refs.some((ref) => !ref || ref.role !== "toolResult")) continue;
736
738
  const exactRefs = refs as EvidenceRef[];
737
- const size = group.reduce((total, { item }) => total + bytes(item.message), 0);
738
- if (exactRefs.length > snapshot.policy.maxFoldSourceRefs ||
739
- toolRefsProtected(exactRefs, state, snapshot) || refsInOrder(snapshot, exactRefs) === null ||
740
- size < snapshot.policy.minToolChars) continue;
739
+ const size = group.reduce((total, { item }) => total + messageBytes(item.message), 0);
740
+ if (refsProtected(exactRefs, state, snapshot) || refsInOrder(snapshot, exactRefs) === null ||
741
+ size < snapshot.thresholds.minFoldChars) continue;
741
742
  batches.push({ refs: exactRefs, indices: group.map(({ item }) => item.index), bytes: size });
742
743
  }
743
744
  return batches;
744
745
  }
745
746
 
747
+ /**
748
+ * The serialized size of ONE transcript message, derived once per message object.
749
+ *
750
+ * `selectAutomaticSpan` tries the batch lane first, so this runs up to
751
+ * MAX_FRONTIER_CUTS_PER_PASS times per context event from `frontierMarks`, once per staged
752
+ * mark in a fill, and again from `unmarkedRemainder`. Everything else in
753
+ * `automaticToolBatches` was already memoized; this was not, and it ran BEFORE the
754
+ * eligibility filter, on every complete group including ones that go on to be refused.
755
+ *
756
+ * IT IS KEYED ON THE MESSAGE OBJECT, which is only useful because of where the object comes
757
+ * from: `snapshot.mapped[i].message` is `input.eventMessages[index]` VERBATIM
758
+ * (transcript.ts:494-508), so it is the same object across every snapshot derived from the
759
+ * same session, and the memo hits. `snapshot.messages` is `clone(input.eventMessages)`
760
+ * (transcript.ts:531), fresh objects on every derivation, so a caller reading THAT would
761
+ * never hit and should not be routed here.
762
+ *
763
+ * Deliberately NOT a wrapper on `bytes()`, which is also called on transient arrays
764
+ * (folding.ts, scheduling.ts) whose keys could never hit and would only grow the map.
765
+ *
766
+ * The accepted cost is gate 121's: a message mutated in place keeps its first size.
767
+ */
768
+ const MESSAGE_BYTES = new WeakMap<object, number>();
769
+
770
+ export function messageBytes(message: unknown): number {
771
+ const key = message && typeof message === "object" ? message as object : null;
772
+ if (!key) return bytes(message);
773
+ const memoized = MESSAGE_BYTES.get(key);
774
+ if (memoized !== undefined) return memoized;
775
+ const size = bytes(message);
776
+ MESSAGE_BYTES.set(key, size);
777
+ return size;
778
+ }
779
+
746
780
  export function selectAutomaticToolBatch(
747
781
  snapshot: ActiveContextSnapshot,
748
782
  state: ActiveContextState,
@@ -770,9 +804,7 @@ export function selectAutomaticRefold(
770
804
  if (claimedFoldIds.has(id)) return [];
771
805
  const fold = state.folds.find((item) => item.id === id);
772
806
  const interval = fold ? foldInterval(fold, state, snapshot) : null;
773
- const protectedSource = fold && (fold.kind === "tool-result"
774
- ? toolRefsProtected(flattenFoldRefs(fold, state), state, snapshot)
775
- : refsProtected(flattenFoldRefs(fold, state), state, snapshot));
807
+ const protectedSource = fold && (refsProtected(flattenFoldRefs(fold, state), state, snapshot));
776
808
  return fold && interval && !protectedSource && !state.leases[id]
777
809
  ? [{ id, ...interval }]
778
810
  : [];
@@ -805,47 +837,9 @@ export function resolveFoldInputIds(
805
837
  export function chapterRangeIsUnitAligned(snapshot: ActiveContextSnapshot, start: number, end: number): boolean {
806
838
  const units = chapterUnits(snapshot).filter((unit) => unit.end > start && unit.start <= end);
807
839
  if (!units.length || units[0].start !== start || units.at(-1)!.end !== end + 1) return false;
808
- if (new Set(units.map((unit) => unit.turnStart)).size > snapshot.policy.maxChapterTurns) return false;
809
840
  return units.every((unit, index) => index === 0 || unit.start === units[index - 1].end);
810
841
  }
811
842
 
812
- export function splitCandidateBySize(
813
- snapshot: ActiveContextSnapshot,
814
- state: ActiveContextState,
815
- candidate: FoldCandidate,
816
- maxChars: number = MAX_FOLD_SPAN_CHARS,
817
- ): FoldCandidate[] {
818
- if (candidate.kind !== "chapter") return [candidate];
819
- const refs = candidate.sourceRefs.length ? candidate.sourceRefs : candidateSourceRefs(candidate.parts, state);
820
- const indices = refs.map((ref) => exactMapped(snapshot, ref)?.index ?? -1);
821
- if (!indices.length || indices.some((index) => index < 0)) return [candidate];
822
- const start = Math.min(...indices);
823
- const end = Math.max(...indices);
824
- if (spanBytes(snapshot, start, end + 1) <= maxChars) return [candidate];
825
- const units = chapterUnits(snapshot).filter((unit) => unit.start >= start && unit.end <= end + 1);
826
- if (units.length < 2) return [candidate];
827
- const split: FoldCandidate[] = [];
828
- const emit = (from: number, to: number): boolean => {
829
- const parts = partsForRange(snapshot, state, from, to, new Set<FoldKind>(["tool-result"]));
830
- if (!parts) return false;
831
- split.push({ kind: "chapter", parts, sourceRefs: candidateSourceRefs(parts, state) });
832
- return true;
833
- };
834
- let runStart = units[0].start;
835
- let runBytes = 0;
836
- for (const unit of units) {
837
- const size = spanBytes(snapshot, unit.start, unit.end);
838
- if (runBytes > 0 && runBytes + size > maxChars) {
839
- if (!emit(runStart, unit.start - 1)) return [candidate];
840
- runStart = unit.start;
841
- runBytes = 0;
842
- }
843
- runBytes += size;
844
- }
845
- if (runBytes > 0 && !emit(runStart, units.at(-1)!.end - 1)) return [candidate];
846
- return split.length ? split : [candidate];
847
- }
848
-
849
843
  export function candidateSpanChars(
850
844
  snapshot: ActiveContextSnapshot,
851
845
  state: ActiveContextState,
@@ -986,11 +980,6 @@ export function snapSpanIds(
986
980
  `${absorb ? "end" : "near"} boundary`);
987
981
  }
988
982
  }
989
- const boundary = currentTurnBoundary(snapshot);
990
- if (boundary >= 0 && end > boundary) {
991
- end = boundary;
992
- note("span reached into the turn still in flight; corrected back to the last closed turn");
993
- }
994
983
  const startUnit = units.find((unit) => start >= unit.start && start < unit.end);
995
984
  if (startUnit && startUnit.start !== start) {
996
985
  start = startUnit.start;
@@ -1001,23 +990,6 @@ export function snapSpanIds(
1001
990
  end = endUnit.end - 1;
1002
991
  note("span ended mid-unit; corrected to the end of its closed user/assistant/tool unit");
1003
992
  }
1004
- const covered = units.filter((unit) => unit.start >= start && unit.end <= end + 1);
1005
- if (covered.length) {
1006
- const turns: number[] = [];
1007
- let clamped = end;
1008
- for (const unit of covered) {
1009
- if (!turns.includes(unit.turnStart)) {
1010
- if (turns.length >= snapshot.policy.maxChapterTurns) break;
1011
- turns.push(unit.turnStart);
1012
- }
1013
- clamped = unit.end - 1;
1014
- }
1015
- if (clamped < end) {
1016
- end = clamped;
1017
- note(`span covered more than the ${snapshot.policy.maxChapterTurns}-turn chapter limit; ` +
1018
- "corrected to the last turn that fits");
1019
- }
1020
- }
1021
993
  if (start === passStart && end === passEnd) break;
1022
994
  }
1023
995
  if (!corrections.length || end < start ||
@@ -1066,15 +1038,10 @@ export function manualFoldCandidate(
1066
1038
  ids: string[],
1067
1039
  options: { allowProtected?: boolean } = {},
1068
1040
  ): FoldCandidate {
1069
- const blockedTool = (refs: EvidenceRef[]): boolean =>
1070
- !options.allowProtected && toolRefsProtected(refs, state, snapshot);
1071
1041
  const blocked = (refs: EvidenceRef[]): boolean =>
1072
1042
  !options.allowProtected && refsProtected(refs, state, snapshot);
1073
1043
  const owners = directFoldOwners(state.folds);
1074
1044
  const bounded = (candidate: FoldCandidate): FoldCandidate => {
1075
- if (candidate.sourceRefs.length > snapshot.policy.maxFoldSourceRefs) {
1076
- throw new Error(`Folds may include at most ${snapshot.policy.maxFoldSourceRefs} exact source references`);
1077
- }
1078
1045
  for (const part of candidate.parts) {
1079
1046
  if (part.kind !== "raw") continue;
1080
1047
  const ownerId = owners.get(objectRefKey(part.ref));
@@ -1098,10 +1065,10 @@ export function manualFoldCandidate(
1098
1065
  calls.length === first.batch.length &&
1099
1066
  new Set(calls.map((call) => call!.id)).size === first.batch.length &&
1100
1067
  first.batch.every((id) => calls.some((call) => call!.id === id));
1101
- if (completeBatch && !blockedTool(refs)) {
1068
+ if (completeBatch && !blocked(refs)) {
1102
1069
  return bounded({ kind: "tool-result", parts: refs.map((ref) => ({ kind: "raw", ref })), sourceRefs: refs });
1103
1070
  }
1104
- if (one && first && first.batch.length === 1 && !blockedTool(refs)) {
1071
+ if (one && first && first.batch.length === 1 && !blocked(refs)) {
1105
1072
  return bounded({ kind: "tool-result", parts: [{ kind: "raw", ref: refs[0] }], sourceRefs: refs });
1106
1073
  }
1107
1074
  }
@@ -1133,6 +1100,26 @@ export function manualFoldCandidate(
1133
1100
  return bounded({ kind: "chapter", parts, sourceRefs: refs });
1134
1101
  }
1135
1102
 
1103
+ // THE TOOL-CLIP VIEW HEAD (2026-08-24). A clipped result keeps its opening verbatim,
1104
+ // not collapsed: the view stays a readable result head in place, unlike a brief's
1105
+ // one-line quote. The paragraph rule is gate 134's: an opening block terminated by a
1106
+ // blank line is kept whole up to the cap; a result that is bulk from line one keeps its
1107
+ // first cap's worth. The caller states the cut, per gate 136: the marker rides beside
1108
+ // the count of what it hides, never silently.
1109
+ export function toolClipHead(text: string, cap: number): string {
1110
+ const lines = String(text ?? "").split(/\r?\n/);
1111
+ const kept: string[] = [];
1112
+ for (const line of lines) {
1113
+ if (line.trim()) kept.push(line);
1114
+ else if (kept.length) break;
1115
+ }
1116
+ const paragraph = kept.join("\n");
1117
+ const head = paragraph.length > 0 && paragraph.length <= cap
1118
+ ? paragraph
1119
+ : String(text ?? "").slice(0, cap);
1120
+ return head;
1121
+ }
1122
+
1136
1123
  export function oneLine(value: string, maximum: number): string {
1137
1124
  return boundedSubject(value.replace(/\s+/g, " ").trim(), maximum);
1138
1125
  }
@@ -1145,44 +1132,58 @@ export function deterministicChapterBrief(
1145
1132
  if (refs.length !== messages.length || !refs.length) {
1146
1133
  throw new Error("Deterministic chapter brief requires aligned exact evidence");
1147
1134
  }
1148
- const firstUser = messages.find((message) => messageRole(message) === "user");
1149
- const toolCounts = new Map<string, number>();
1135
+ // EVERY ENTRY IDENTIFIED IN SPAN ORDER (Shane 2026-08-24). The count-style brief
1136
+ // ("User: ... \u00b7 Tools: 3\u00d7bash \u00b7 Assistant: No assistant text in this span")
1137
+ // collapsed to the same anonymous line on every residue span of the pull workload:
1138
+ // sol-20260823-live rep 3 committed 188 byte-identical placeholders of that shape and
1139
+ // the agent re-peeked 76 folds 209 times to learn what each one held. The span's own
1140
+ // calls are the index: each call named with its arguments and its result's opening
1141
+ // line, an errored result marked, the user's ask and every assistant note seated in
1142
+ // span order, and what the cap refuses COUNTED rather than dropped (the gate-135 law
1143
+ // carried forward: a value recorded once in an assistant note between two batches must
1144
+ // reach the brief). Cuts run through oneLine, so "..." always means the exact source
1145
+ // continues.
1146
+ const callNames = new Map<string, string>();
1147
+ const pieces: string[] = [];
1150
1148
  for (const message of messages) {
1151
- if (messageRole(message) !== "assistant") continue;
1152
- for (const part of denseOwnArrayValues(ownValue(message, "content")) ?? []) {
1153
- if (ownValue(part, "type") !== "toolCall") continue;
1154
- const name = ownValue(part, "name");
1155
- if (typeof name === "string" && name) toolCounts.set(name, (toolCounts.get(name) ?? 0) + 1);
1149
+ const role = messageRole(message);
1150
+ if (role === "user") {
1151
+ const ask = contentText(message);
1152
+ if (ask.trim()) pieces.push(`User asked: ${oneLine(ask, 120)}`);
1153
+ } else if (role === "assistant") {
1154
+ for (const part of denseOwnArrayValues(ownValue(message, "content")) ?? []) {
1155
+ if (ownValue(part, "type") !== "toolCall") continue;
1156
+ const id = ownValue(part, "id");
1157
+ const name = ownValue(part, "name");
1158
+ if (typeof id !== "string" || !id || typeof name !== "string" || !name) continue;
1159
+ let args = "{}";
1160
+ try {
1161
+ args = JSON.stringify(ownValue(part, "arguments") ?? {}) ?? "{}";
1162
+ } catch {
1163
+ args = "{unserializable}";
1164
+ }
1165
+ callNames.set(id, `${name}(${oneLine(args, 80)})`);
1166
+ }
1167
+ const note = assistantNoteText(message);
1168
+ if (note) pieces.push(`Noted: ${boundedSubject(note, CHAPTER_NOTE_CHARS)}`);
1169
+ } else if (role === "toolResult") {
1170
+ const id = ownValue(message, "toolCallId");
1171
+ const name = ownValue(message, "toolName");
1172
+ const head = (typeof id === "string" ? callNames.get(id) : undefined) ??
1173
+ (typeof name === "string" && name ? `${name}(...)` : "tool(...)");
1174
+ const errored = ownValue(message, "isError") === true ? " [errored]" : "";
1175
+ pieces.push(`${head}${errored} \u2192 "${oneLine(contentText(message), 90)}"`);
1156
1176
  }
1157
1177
  }
1158
- // EVERY assistant note in the span, not the first line of the first one
1159
- // (Shane 2026-08-14). A chapter is the first fold that hides its span, and a
1160
- // value the agent recorded once on a later turn died with the old single
1161
- // line: sol-20260814-traps rep 2 lost trace-a-02 and trace-d-05 to exactly
1162
- // that shape, an assistant message between two tool batches whose words
1163
- // reached zero briefs. Each note is the message's leading paragraph, capped
1164
- // per note, seated whole in span order until the brief's own bound refuses
1165
- // the next one, with the count of what did not seat stated rather than cut.
1166
- const notes = messages.flatMap((message) => {
1167
- if (messageRole(message) !== "assistant") return [];
1168
- const note = assistantNoteText(message);
1169
- return note ? [boundedSubject(note, CHAPTER_NOTE_CHARS)] : [];
1170
- });
1171
- const ask = oneLine(firstUser ? contentText(firstUser) : "No user ask in this span", 90);
1172
- const tools = oneLine([...toolCounts.entries()]
1173
- .sort(([left], [right]) => left.localeCompare(right))
1174
- .map(([name, count]) => `${count}×${name}`)
1175
- .join(" ") || "no tools", 500);
1176
- const separator = " | ";
1178
+ const separator = " \u00b7 ";
1177
1179
  const compose = (kept: number): string => {
1178
- const omitted = notes.length - kept;
1179
- const shown = kept ? notes.slice(0, kept).join(separator)
1180
- : (notes.length ? "" : "No assistant text in this span");
1181
- const tail = omitted ? `${kept ? separator : ""}${omitted} more notes in this span` : "";
1182
- return `User: ${ask} · Tools: ${tools} · Assistant: ${shown}${tail}`;
1180
+ const omitted = pieces.length - kept;
1181
+ const shown = pieces.slice(0, kept).join(separator);
1182
+ const tail = omitted ? `${kept ? separator : ""}${omitted} more entries in this span` : "";
1183
+ return `${shown}${tail}`;
1183
1184
  };
1184
- let composed = compose(notes.length);
1185
- for (let kept = notes.length; kept > 0 && composed.length > ACTIVE_CONTEXT_POLICY.maxBriefChars; kept -= 1) {
1185
+ let composed = compose(pieces.length);
1186
+ for (let kept = pieces.length; kept > 0 && composed.length > ACTIVE_CONTEXT_POLICY.maxBriefChars; kept -= 1) {
1186
1187
  composed = compose(kept - 1);
1187
1188
  }
1188
1189
  const escapeName = (name: string) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -7,6 +7,8 @@
7
7
  * nothing: the next model call that reads a result, a natural boundary in the work.
8
8
  */
9
9
 
10
+ import { MEMORY_BODY_CHARS, MEMORY_KEY_CHARS, MEMORY_KEYS_MAX } from "./policy.ts";
11
+
10
12
  type ToolHandler = (
11
13
  toolCallId: string,
12
14
  params: Record<string, unknown>,
@@ -29,27 +31,30 @@ export function buildActiveContextTool(input: {
29
31
  handler: ToolHandler;
30
32
  }) {
31
33
  const peekGuidance = input.allowedActions.includes("peek")
32
- ? ` Peek is the point read: it returns one fold's index view -- every nested fold's brief in full, 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 stays in the window like any other tool result. Expand is the durable in-place restoration.`
34
+ ? ` 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.`
33
35
  : "";
34
- const correctionGuidance = input.allowedActions.includes("rebrief")
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
+ const correctionGuidance = input.allowedActions.includes("reboundary")
37
+ ? " 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."
36
38
  : "";
37
- const pinGuidance = input.allowedActions.includes("protect")
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."
39
+ const pinGuidance = input.allowedActions.includes("pin")
40
+ ? " Pin with ids holds folds or entries against automatic folding, consolidation and refolding until unpin releases them, so an expanded span you still need stays expanded. Pin 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."
39
41
  : "";
40
- const epochGuidance = input.allowedActions.includes("unmark")
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."
42
+ const epochGuidance = input.allowedActions.includes("brief")
43
+ ? " THE RUNTIME CUTS THE FOLDS AND YOU ANNOTATE THEM. As raw material piles up it is cut into pending folds behind you, stalest first. A pending fold moves NO context bytes: you keep seeing the raw material exactly as it is, and the fold reaches your window only when a commit applies it. You are told when folds are cut and which ones carry no brief yet. Answer with brief, one id and one sentence: you know why the span mattered and what you will want back from it, and the deterministic brief that fills the gap reads the span alone and does not. Writing a brief on a pending fold is FREE, because the fold is not in your window yet; briefing a standing fold is refused, because its placeholder is in your window and rewriting it rewrites your context from that point on. reboundary re-cuts a span the runtime got wrong, unmark withdraws a cut you do not want taken, and when to commit stays the runtime's to decide."
42
44
  : "";
43
45
  const recoveryGuidance = input.allowedActions.includes("expand")
44
46
  ? " 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."
45
47
  : "";
48
+ const memoryGuidance = input.allowedActions.includes("remember")
49
+ ? ` WORKING MEMORY: a session-scoped dictionary you maintain beside the fold index. remember with key and body writes or updates an entry (${MEMORY_KEY_CHARS}-character keys, ${MEMORY_BODY_CHARS}-character bodies, ${MEMORY_KEYS_MAX} entries); an empty body removes it; recall with keys reads bodies back, or everything when keys is omitted. The bodies live outside your window: only a one-glance table of contents rides your context, refreshed at each commit, so writing an entry costs your window almost nothing. Keep entries current as the work moves: when a fact an entry records changes, or new information lands that belongs in one, update it then rather than at the end.`
50
+ : "";
46
51
  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.";
47
52
  return {
48
53
  name: input.name,
49
54
  label: input.label,
50
55
  description: input.fullSurface
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.`,
56
+ ? `Page, peek, brief, expand, refold, pin, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${memoryGuidance}${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.`
57
+ : `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${pinGuidance}${epochGuidance}${recoveryGuidance}${memoryGuidance}${standingGuidance} Supplied fold briefs have a hard ${input.maxBriefChars}-character maximum.`,
53
58
  parameters: {
54
59
  type: "object",
55
60
  additionalProperties: false,
@@ -58,35 +63,25 @@ export function buildActiveContextTool(input: {
58
63
  action: { type: "string", enum: input.allowedActions },
59
64
  ids: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1 } },
60
65
  id: { type: "string", minLength: 1 },
61
- marks: {
62
- type: "array",
63
- minItems: 1,
64
- maxItems: 64,
65
- description: "Fold only: several spans with their own briefs in one call, which is the shape to prefer. An invalid or overlapping span is corrected to the nearest valid edge and the correction is reported back.",
66
- items: {
67
- type: "object",
68
- additionalProperties: false,
69
- required: ["ids"],
70
- properties: {
71
- ids: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1 } },
72
- brief: {
73
- type: "string",
74
- minLength: 1,
75
- maxLength: input.maxBriefChars,
76
- description: "Write one for every span you mark. You know why the span mattered and " +
77
- "what you will want back from it; the deterministic brief that fills the gap " +
78
- "reads the span alone and does not.",
79
- },
80
- },
81
- },
82
- },
83
66
  brief: {
84
67
  type: "string",
85
68
  minLength: 1,
86
69
  maxLength: input.maxBriefChars,
87
- description: `Factual fold brief, at most ${input.maxBriefChars} characters.`,
70
+ description: "Brief only: what you will want back from this pending fold that " +
71
+ "its automatic brief cannot know. Your sentence is kept BESIDE the automatic " +
72
+ `brief, never instead of it, at most ${input.maxBriefChars} characters. Free ` +
73
+ "to write, because the fold it names is not in your window yet.",
88
74
  },
89
75
  offset: { type: "integer", minimum: 0 },
76
+ ephemeral: {
77
+ type: "boolean",
78
+ description: "Peek only. Default TRUE: the returned bytes ride your context " +
79
+ "exactly until your next message, then their place holds a one-line " +
80
+ "placeholder and your reply is the surviving trace, so extract what you need " +
81
+ "into that reply. Peeking again is lossless. Pass false to keep the result " +
82
+ "standing like any other tool result, which is what you want when the bytes " +
83
+ "have to survive several turns of work.",
84
+ },
90
85
  bytes: {
91
86
  type: "integer",
92
87
  minimum: input.minPeekSliceBytes,
@@ -94,6 +89,31 @@ export function buildActiveContextTool(input: {
94
89
  },
95
90
  limit: { type: "integer", minimum: 1, maximum: 100 },
96
91
  detail: { type: "string", enum: [...input.statusDetails] },
92
+ ...(input.allowedActions.includes("remember")
93
+ ? {
94
+ key: {
95
+ type: "string",
96
+ minLength: 1,
97
+ maxLength: MEMORY_KEY_CHARS,
98
+ description: "remember only: the working-memory entry to write, update or " +
99
+ "(with an empty body) remove.",
100
+ },
101
+ body: {
102
+ type: "string",
103
+ maxLength: MEMORY_BODY_CHARS,
104
+ description: "remember only: the entry's full text, replacing what the key " +
105
+ "held. Empty removes the entry.",
106
+ },
107
+ keys: {
108
+ type: "array",
109
+ minItems: 1,
110
+ maxItems: MEMORY_KEYS_MAX,
111
+ items: { type: "string", minLength: 1 },
112
+ description: "recall only: the working-memory entries to read. Omit to read " +
113
+ "every entry.",
114
+ },
115
+ }
116
+ : {}),
97
117
  },
98
118
  },
99
119
  execute: input.handler,
@@ -103,8 +123,10 @@ export function buildActiveContextTool(input: {
103
123
  export function buildActiveContextCommands(input: {
104
124
  statusName: string;
105
125
  foldName: string;
126
+ editorName: string;
106
127
  statusHandler: CommandHandler;
107
128
  foldHandler: CommandHandler;
129
+ editorHandler: CommandHandler;
108
130
  }) {
109
131
  return [
110
132
  {
@@ -114,9 +136,14 @@ export function buildActiveContextCommands(input: {
114
136
  },
115
137
  {
116
138
  name: input.foldName,
117
- description: "Losslessly fold a stale context span, or \"commit\" to apply every eligible mark now; " +
139
+ description: "Commit every staged fold in one epoch; with explicit ids, folds exactly that span now; " +
118
140
  "works without a main-model request",
119
141
  handler: input.foldHandler,
120
142
  },
143
+ {
144
+ name: input.editorName,
145
+ description: "Open the context window editor: occupancy, fold roots and briefs, staged marks, pins",
146
+ handler: input.editorHandler,
147
+ },
121
148
  ];
122
149
  }