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.
@@ -562,10 +562,6 @@ function mcpServerForTool(toolName, fallback) {
562
562
  : fallback;
563
563
  }
564
564
 
565
- function conventionalMcpTool(toolName) {
566
- return mcpServerForTool(toolName, null) !== null;
567
- }
568
-
569
565
  export function registerEvidenceIngestion(pi, {
570
566
  entryTypePrefix = DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
571
567
  } = {}) {
@@ -626,7 +622,7 @@ export function registerEvidenceIngestion(pi, {
626
622
  return projectEvidenceText(text, descriptor, "utf8-head", truncation ? { truncation } : undefined);
627
623
  }
628
624
 
629
- if (conventionalMcpTool(toolName)) {
625
+ if (mcpServerForTool(toolName, null) !== null) {
630
626
  const envelope = eventEnvelope(event);
631
627
  const serialized = `${stableStringify(envelope)}\n`;
632
628
  if (utf8Bytes(serialized) <= TOOL_RESULT_PROJECTION_BYTES) return;
@@ -1,8 +1,17 @@
1
1
  import { registerActiveContext } from "./active-context.ts";
2
2
  import { registerEvidenceIngestion } from "./evidence.js";
3
3
 
4
+ export {
5
+ applyFoldSettingsEdit,
6
+ loadFoldSettingsFile,
7
+ readFoldSettingsFile,
8
+ registerFoldSettings,
9
+ saveFoldSettingsFile,
10
+ } from "./settings.ts";
11
+
4
12
  const PUBLIC_OPTIONS = Object.freeze([
5
- "thresholds", "providerInputBudget", "blacklistAutoFoldTools", "guidance",
13
+ "thresholds", "providerInputBudget", "blacklistAutoFoldTools", "toolFoldThreshold",
14
+ "workingMemory", "postFoldNotice",
6
15
  ]);
7
16
 
8
17
  const GENERATOR_DELETED = "the model brief generator is deleted (2026-08-14): across 15 sealed " +
@@ -16,15 +25,20 @@ const INVERTED_AUTO_FOLD_LIST = "renamed blacklistAutoFoldTools, and the sense i
16
25
  "it meant to permit";
17
26
 
18
27
  const REFUSED_OPTIONS = Object.freeze({
28
+ guidance: "deleted 2026-08-23: it switched the copy that taught the agent to " +
29
+ "CHOOSE SPANS, and the agent does not choose spans. The runtime cuts at the frontier and " +
30
+ "the agent annotates what it cut, so the guidance it switched is replaced wholesale by " +
31
+ "the post-fold notice. That notice has its own option, postFoldNotice, which defaults " +
32
+ "false: the deterministic shape is what the campaign measured",
19
33
  toolName: "the deployment identity is hardwired to pi-fold: the tool is pi_fold_context",
20
34
  toolLabel: "the deployment identity is hardwired to pi-fold",
21
35
  brandNoun: "the deployment identity is hardwired to pi-fold",
22
36
  entryTypePrefix: "the deployment identity is hardwired to pi-fold: durable state lives " +
23
37
  "under pi-fold-active-context, and moving it would strand every fold already written",
24
38
  commandPrefix: "the deployment identity is hardwired to pi-fold: the commands are " +
25
- "/pi-fold-context and /fold-context",
39
+ "/fold, /fold-editor, /fold-status and /fold-settings",
26
40
  commandNames: "the deployment identity is hardwired to pi-fold: the commands are " +
27
- "/pi-fold-context and /fold-context",
41
+ "/fold, /fold-editor, /fold-status and /fold-settings",
28
42
  isMcpTool: "MCP tools are recognized by the mcp__server__tool naming convention. The " +
29
43
  "predicate defaulted to false, so conventionally named MCP tools were never classified " +
30
44
  "unless a host supplied one, which is a documented feature that never ran",
@@ -194,8 +194,30 @@ export function isObjectRef(value: unknown): value is EvidenceRef {
194
194
  typeof sha256 === "string" && reflectApply(regexpTest, sha256Pattern, [sha256]);
195
195
  }
196
196
 
197
+ // THE HOT PATH (issue #2, 2026-08-28). This is the most-called function in the package:
198
+ // every mark, span, claim, eligibility and measurement pass keys its refs through it,
199
+ // and a reporter profiling a 467KB window found the serializer under it at the top of a
200
+ // V8 profile with the process pegged at 80 percent for forty minutes. The general
201
+ // serializer earns its cost on arbitrary values, checking for proxies, cycles, accessors
202
+ // and non-plain prototypes; none of that is in question for three strings off a ref.
203
+ //
204
+ // The output is IDENTICAL, not merely equivalent, and that is the whole design of this
205
+ // shape: safeJson emits object keys in own-property order rather than sorted, so the
206
+ // literal below is the same byte sequence its object form produced, and string escaping
207
+ // is native JSON.stringify on both paths. It matters because these keys order
208
+ // `protectionSha256`'s sort and reach `branchSha256`'s missing-digest, so a format that
209
+ // merely round-trips would move digests that sealed sessions carry. Anything that is not
210
+ // three strings falls through to the serializer, which owns every remaining case.
211
+ // Measured 8.5x, 4.27us to 0.50us per call, over 2,000 refs. Gate 150 pins the identity.
197
212
  export function objectRefKey(ref: EvidenceRef): string {
198
- return stableStringify({ sessionId: ref.sessionId, entryId: ref.entryId, role: ref.role });
213
+ const sessionId = ref.sessionId;
214
+ const entryId = ref.entryId;
215
+ const role = ref.role;
216
+ if (typeof sessionId === "string" && typeof entryId === "string" && typeof role === "string") {
217
+ return `{"sessionId":${jsonStringify(sessionId)},"entryId":${jsonStringify(entryId)},` +
218
+ `"role":${jsonStringify(role)}}`;
219
+ }
220
+ return stableStringify({ sessionId, entryId, role });
199
221
  }
200
222
 
201
223
  export function sameObjectIdentity(left: EvidenceRef, right: EvidenceRef): boolean {
@@ -15,6 +15,40 @@ export function bytes(value: unknown): number {
15
15
  return Buffer.byteLength(typeof value === "string" ? value : stableStringify(value), "utf8");
16
16
  }
17
17
 
18
+ /**
19
+ * Does `heldText`, the serialization of the first `held` elements, stand as a prefix of
20
+ * `bodyText`, the serialization of the whole array?
21
+ *
22
+ * ARRAY SERIALIZATION IS PREFIX-FREE BY CONSTRUCTION, which is the whole reason this can be
23
+ * answered from a string the caller already has instead of by serializing a slice. `safeJson`
24
+ * emits `[`, then the elements separated by `,`, then `]`: no whitespace, no trailing comma.
25
+ * So the serialization of the first `held` elements is exactly the head of the whole one with
26
+ * `]` in place of the `,` that follows element `held - 1`.
27
+ *
28
+ * The two ends break that rule and are branched on rather than argued away. At `held` 0 the
29
+ * prefix is `[]` and there is no comma to match, so testing `"[," ` would be nonsense. At
30
+ * `held === length` there is no following element and the two strings are simply equal.
31
+ *
32
+ * The comma is the reason this is not a bare `startsWith`, and it is worth being exact about
33
+ * when it earns its place. JSON's object and string forms SELF-TERMINATE, with `}` and a
34
+ * closing quote, so for the message arrays this has one caller for, a bare `startsWith` would
35
+ * happen to agree. Numbers do not self-terminate: `[1]` is a leading substring of `[12]` and
36
+ * a bare `startsWith` accepts it. The separator is what makes the predicate correct for any
37
+ * array rather than for today's caller, and a false positive in that caller splices a stale
38
+ * head in front of a live tail. Gate 154 drives the numeric case for exactly this reason.
39
+ */
40
+ export function serializedPrefixHolds(
41
+ bodyText: string,
42
+ heldText: string | null | undefined,
43
+ held: number,
44
+ length: number,
45
+ ): boolean {
46
+ if (heldText === null || heldText === undefined || length < held) return false;
47
+ if (held === 0) return heldText === "[]";
48
+ if (held === length) return bodyText === heldText;
49
+ return bodyText.startsWith(`${heldText.slice(0, -1)},`);
50
+ }
51
+
18
52
  export function boundedUtf8(text: string, maximumBytes: number): string {
19
53
  const buffer = Buffer.from(text, "utf8");
20
54
  if (buffer.length <= maximumBytes) return text;
@@ -1,71 +1,112 @@
1
1
  import { boundReceiptText } from "./measurement.ts";
2
+ import { oneLine } from "./selection.ts";
2
3
  import {
3
4
  contextBrand,
4
- CONTEXT_MARK_RESPONSE_BYTES,
5
5
  CONTEXT_RECEIPT_BLOCK_BYTES,
6
+ FOLD_NOTICE_BYTES,
6
7
  DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
7
8
  MAX_CONTEXT_RECEIPTS,
8
9
  } from "./policy.ts";
9
10
 
10
- export function markAwarenessText(input: {
11
- held: ReadonlyArray<{ id: string; kind: string; tokens: number }>;
12
- remainder: { spans: number; tokens: number; share: number; candidates: ReadonlyArray<{ id: string; tokens: number }> };
11
+ /**
12
+ * THE POST-FOLD NOTICE: THE ONE MOMENT THE AGENT KNOWS SOMETHING THE RUNTIME DOES NOT.
13
+ *
14
+ * The runtime has just cut folds behind the agent, out of material the agent is still
15
+ * looking at. It knows the spans, the sizes and the staleness; it does not know why any
16
+ * of it mattered. So the notice states what was cut and asks for exactly one thing, a
17
+ * brief, and it states the price of answering, which is nothing: the folds it names are
18
+ * PENDING, so a brief written now is stored outside the projection and reaches the window
19
+ * only when the commit writes that placeholder for the first time.
20
+ *
21
+ * It is batched rather than continuous. One notice per cut is noise, and noise is how
22
+ * guidance gets ignored; this one appears when enough folds are standing unbriefed to be
23
+ * worth a turn's attention, and it is ephemeral, so it never accumulates.
24
+ */
25
+ export function foldNoticeText(input: {
26
+ unbriefed: ReadonlyArray<{ id: string; kind: string; tokens: number; brief?: string }>;
27
+ pending: number;
13
28
  toolName: string;
14
29
  brandNoun?: string;
15
30
  }): string {
16
31
  const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
17
- const { remainder } = input;
18
- const held = input.held.length
19
- ? input.held.map((span) => `${span.id} (${span.kind}, about ${span.tokens} tokens)`).join("; ")
20
- : "none";
21
- const candidates = remainder.candidates.length
22
- ? remainder.candidates.map((item) => `${item.id} (about ${item.tokens} tokens)`).join("; ")
23
- : "none";
24
- return boundReceiptText([
25
- `[${brand} marks] Held until they age out or the next fold event: ${held}.`,
26
- `Unmarked remainder: ${remainder.spans} span(s), about ${remainder.tokens} tokens of stale mass, ` +
27
- `${Math.round(remainder.share * 100)}% of the non-fresh window.`,
28
- `Largest unmarked by reclaim value: ${candidates}.`,
29
- `Mark several spans in one ${input.toolName} call: one call, and this whole picture comes back with it.`,
30
- ].join("\n"), CONTEXT_MARK_RESPONSE_BYTES, `[${brand} marks] Held; the remainder is unavailable this pass.`);
32
+ // THE INSTRUCTION IS SEATED BEFORE THE LIST, and the list is what gives when the
33
+ // notice runs out of room. The first cut of this built one string with the ids in the
34
+ // opening line and handed the whole thing to the bound, so a seven-fold batch spent its
35
+ // budget on ids and lost "here is how to answer" off the end: the agent was told which
36
+ // folds were unbriefed and not told that briefing them was free, which is the only fact
37
+ // in here it cannot work out for itself. Fold count grows without limit and the
38
+ // instruction does not, so the instruction is fixed and the ids are the variable part.
39
+ const head = [
40
+ `[${brand} folds] ${input.unbriefed.length} of your ${input.pending} pending fold(s) carry ` +
41
+ "no brief yet. These spans are cut but NOT folded: every byte they cover is still in " +
42
+ "front of you exactly as it was, and they enter your window only when a commit " +
43
+ "applies them.",
44
+ `Give each one a sentence: ${input.toolName} {"action":"brief","id":"<fold-id>","brief":"..."}. ` +
45
+ "You know why the span mattered and what you will want back from it; the automatic " +
46
+ "brief reads the span alone and does not. Writing it now costs your window nothing, " +
47
+ "because the fold is not in your window yet.",
48
+ `Wrong boundary? ${input.toolName} {"action":"reboundary","ids":[...]} re-cuts it. ` +
49
+ `Should not be taken at all? ${input.toolName} {"action":"unmark","ids":["<fold-id>"]}.`,
50
+ ].join("\n");
51
+ // WHAT IT COULD NOT NAME, IT COUNTS (gate 136's law, and gate 115's shape one level
52
+ // out): a list that stops early and says nothing reads as a complete list.
53
+ // EACH ROW IDENTIFIES ITS SPAN. The pending mark carries the deterministic brief the
54
+ // runtime cut it with, and a notice that withholds it makes the agent guess: the
55
+ // dogfooded 1M session answered "I need to brief three pending folds. What are they?
56
+ // Likely: ..." from memory, and a wrong guess writes an accurate-sounding brief onto
57
+ // the wrong fold. The head is the identification; the agent adds what it cannot know.
58
+ const NOTICE_ROW_CHARS = 160;
59
+ const entries = input.unbriefed
60
+ .map((fold) => `${fold.id} (${fold.kind}, ~${fold.tokens} tokens)` +
61
+ (fold.brief ? `: ${oneLine(fold.brief, NOTICE_ROW_CHARS)}` : ""));
62
+ const overflowText = (seated: number) =>
63
+ `${input.toolName} {"action":"status"} lists the other ${input.unbriefed.length - seated}.`;
64
+ let seated = entries.length;
65
+ let list = `Unbriefed: ${entries.join("; ")}.`;
66
+ while (seated > 0 && Buffer.byteLength(`${head}\n${list}`, "utf8") > FOLD_NOTICE_BYTES) {
67
+ seated -= 1;
68
+ list = seated === 0
69
+ ? overflowText(0)
70
+ : `Unbriefed: ${entries.slice(0, seated).join("; ")}. ${overflowText(seated)}`;
71
+ }
72
+ return boundReceiptText(`${head}\n${list}`, FOLD_NOTICE_BYTES,
73
+ `[${brand} folds] Pending folds are waiting for briefs; the list is unavailable this pass.`);
31
74
  }
32
75
 
33
- export function contextRiderText(input: {
76
+ /**
77
+ * THE WORKING MEMORY'S TABLE OF CONTENTS (Shane, 2026-08-26). One copy rides the
78
+ * projection, refreshed at each commit by the carrier freeze: keys only, freshest first,
79
+ * because the bodies live in state and are read on demand. The instruction is fixed and
80
+ * the key list is the variable part, exactly as the fold notice above, and a list that
81
+ * stops early counts what it dropped (gate 136's law). It shares FOLD_NOTICE_BYTES: both
82
+ * are one-glance carriers and a second bound would be a knob with no second job.
83
+ */
84
+ export function memoryTocText(input: {
85
+ entries: ReadonlyArray<{ key: string; chars: number }>;
34
86
  toolName: string;
35
87
  brandNoun?: string;
36
- pendingAgentMarks: number;
37
- eligibleMarks: number;
38
- freedTokens: number;
39
- eligibleFreedTokens: number;
40
- anchors: string[];
41
- pinnedShare: number;
42
- maxPinnedShare: number;
43
88
  }): string {
44
89
  const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
45
- const anchors = input.anchors.length
46
- ? input.anchors.slice(0, 3).join(", ")
47
- : "none";
48
- const pinnedPercent = Math.round(input.pinnedShare * 100);
49
- const capPercent = Math.round(input.maxPinnedShare * 100);
50
- return boundReceiptText(
51
- [
52
- `[${brand} notice] A fold commit just landed; the next one will batch every pending mark ` +
53
- "into one rewrite, and marks are free until then.",
54
- "Mark FINISHED units at their clean boundaries; batches beat singles: " +
55
- `${input.toolName} {"action":"fold","marks":[{"ids":["<start>","<end>"],"brief":"<factual brief>"}]} ` +
56
- "carries several decisions into that single rewrite, each keeping your brief instead of a " +
57
- `generated one. ${input.pendingAgentMarks} of your mark(s) pending; ` +
58
- `${input.eligibleMarks} mark(s) eligible now, freeing about ${input.eligibleFreedTokens} ` +
59
- `of the ${input.freedTokens} marked token(s).`,
60
- `Completed units ready to mark, largest first: ${anchors}.`,
61
- `Pinning: ${input.toolName} {"action":"protect","ids":["<entry-id>"]} holds entries raw through ` +
62
- `every fold, and {"action":"unprotect"} releases them. Pinned context is ${pinnedPercent}% of ` +
63
- `the working window against a ${capPercent}% cap; at the cap, protect refuses until ` +
64
- "something is released.",
65
- ].join("\n"),
66
- 2_048,
67
- `[${brand} notice] Post-commit curation details are unavailable this pass.`,
68
- );
90
+ const head = [
91
+ `[${brand} memory] Your working memory holds ${input.entries.length} ` +
92
+ `entr${input.entries.length === 1 ? "y" : "ies"}, freshest first.`,
93
+ `Read any of them with ${input.toolName} {"action":"recall","keys":["<key>"]}. ` +
94
+ `Update an entry whose facts have changed, or add one, with ${input.toolName} ` +
95
+ '{"action":"remember","key":"<key>","body":"..."}; an empty body removes the entry.',
96
+ ].join("\n");
97
+ const rows = input.entries.map((entry) => `${entry.key} (${entry.chars} chars)`);
98
+ const overflowText = (seated: number) =>
99
+ `${input.toolName} {"action":"recall"} lists the other ${input.entries.length - seated}.`;
100
+ let seated = rows.length;
101
+ let list = `Entries: ${rows.join("; ")}.`;
102
+ while (seated > 0 && Buffer.byteLength(`${head}\n${list}`, "utf8") > FOLD_NOTICE_BYTES) {
103
+ seated -= 1;
104
+ list = seated === 0
105
+ ? overflowText(0)
106
+ : `Entries: ${rows.slice(0, seated).join("; ")}. ${overflowText(seated)}`;
107
+ }
108
+ return boundReceiptText(`${head}\n${list}`, FOLD_NOTICE_BYTES,
109
+ `[${brand} memory] Working memory has entries; the list is unavailable this pass.`);
69
110
  }
70
111
 
71
112
  export interface ContextReceipt {
@@ -79,8 +120,6 @@ export interface ContextReceipt {
79
120
  occupancyAfter: number | null;
80
121
  spansFolded: number;
81
122
  toolResultsFolded: number;
82
- splitFolds: number;
83
- splitFromChars: number;
84
123
  absorbedWedges: number;
85
124
  recovered: boolean;
86
125
  protectedBytes: number;
@@ -99,8 +138,6 @@ export function contextReceipt(input: Partial<ContextReceipt> & { kind: string;
99
138
  occupancyAfter: input.occupancyAfter ?? null,
100
139
  spansFolded: input.spansFolded ?? 0,
101
140
  toolResultsFolded: input.toolResultsFolded ?? 0,
102
- splitFolds: input.splitFolds ?? 0,
103
- splitFromChars: input.splitFromChars ?? 0,
104
141
  absorbedWedges: input.absorbedWedges ?? 0,
105
142
  recovered: input.recovered ?? false,
106
143
  protectedBytes: input.protectedBytes ?? 0,
@@ -131,9 +168,6 @@ export function receiptLine(receipt: ContextReceipt): string {
131
168
  `about ${receipt.freedTokens} tokens freed.`,
132
169
  occupancy,
133
170
  folded,
134
- receipt.splitFolds
135
- ? `A ${receipt.splitFromChars}-char span was split into ${receipt.splitFolds} bite-sized folds.`
136
- : "",
137
171
  receipt.absorbedWedges
138
172
  ? `${receipt.absorbedWedges} short stale span(s) wedged between folds were absorbed into their ` +
139
173
  "later neighbour rather than left as crumbs."
@@ -142,7 +176,7 @@ export function receiptLine(receipt: ContextReceipt): string {
142
176
  ? "This ran as overflow recovery: the request would not have been transmissible, and it was rebuilt rather than dropped."
143
177
  : "",
144
178
  receipt.protectedBytes
145
- ? `${receipt.protectedBytes} byte(s) stay pinned under protect; unprotect releases them when the work moves on.`
179
+ ? `${receipt.protectedBytes} byte(s) stay pinned; unpin releases them when the work moves on.`
146
180
  : "",
147
181
  receipt.note ?? "",
148
182
  ];
@@ -160,9 +194,8 @@ export function receiptBlockText(input: {
160
194
  `[${brand} actions] Recent automatic context actions, newest last. Exact source remains expandable.`;
161
195
  const verbs = `Correct any of it: ${input.toolName} ` +
162
196
  "{\"action\":\"expand\",\"id\":\"<fold-id>\"} restores a span in place; " +
163
- "{\"action\":\"rebrief\",\"id\":\"<fold-id>\",\"brief\":\"<factual brief>\"} replaces a brief that " +
164
- "does not describe what you needed; {\"action\":\"reboundary\",\"id\":\"<fold-id>\"} returns a " +
165
- "mis-cut fold to raw so you can fold the span you meant.";
197
+ "{\"action\":\"reboundary\",\"id\":\"<fold-id>\"} returns a mis-cut fold to raw so you can " +
198
+ "fold the span you meant.";
166
199
  const fixed = Buffer.byteLength(header, "utf8") + Buffer.byteLength(verbs, "utf8") + 1;
167
200
  if (fixed > CONTEXT_RECEIPT_BLOCK_BYTES) {
168
201
  return `[${brand} actions] Recent automatic context actions are unavailable this pass.`;