pi-fold 0.1.0 → 1.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.
@@ -9,8 +9,15 @@ import type {
9
9
  ActiveContextSnapshot,
10
10
  ActiveContextState,
11
11
  AdvisoryMilestone,
12
+ GuidanceProfile,
13
+ SurfacingSuggestion,
14
+ } from "./policy.ts";
15
+ import {
16
+ contextBrand,
17
+ DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
18
+ DEFAULT_GUIDANCE_PROFILE,
19
+ GUIDANCE_PROFILES,
12
20
  } from "./policy.ts";
13
- import { contextBrand, DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN } from "./policy.ts";
14
21
 
15
22
  export { ADVISORY_BUDGETS };
16
23
 
@@ -35,18 +42,41 @@ export function clearArmedAdvisory(state: ActiveContextState): ActiveContextStat
35
42
  return { ...state, advisory };
36
43
  }
37
44
 
38
- export function advisorySchedule(
45
+ export function normalizeGuidanceProfile(guidance: unknown = DEFAULT_GUIDANCE_PROFILE): GuidanceProfile {
46
+ if (!GUIDANCE_PROFILES.includes(guidance as GuidanceProfile)) {
47
+ throw new Error('guidance must be "pressure", "curation", or "minimal"');
48
+ }
49
+ return guidance as GuidanceProfile;
50
+ }
51
+
52
+ function guidanceRungs(
39
53
  snapshot: Pick<ActiveContextSnapshot, "policy" | "contextWindow">,
40
- ): AdvisorySchedule {
41
- const rungs = [
54
+ guidance: GuidanceProfile,
55
+ ): AdvisorySchedule["rungs"] {
56
+ const urgent = {
57
+ milestone: "urgent" as const,
58
+ threshold: hardFenceRatio(snapshot) - 0.03,
59
+ budget: ADVISORY_BUDGETS.urgent,
60
+ };
61
+ if (guidance === "minimal") return [urgent];
62
+ return [
63
+ ...(guidance === "curation"
64
+ ? [{ milestone: "orientation" as const, threshold: 0.25, budget: ADVISORY_BUDGETS.orientation }]
65
+ : []),
42
66
  { milestone: "notice" as const, threshold: 0.50, budget: ADVISORY_BUDGETS.notice },
43
67
  { milestone: "tools" as const, threshold: snapshot.policy.toolFoldRatio - 0.04,
44
68
  budget: ADVISORY_BUDGETS.tools },
45
69
  { milestone: "chapters" as const, threshold: snapshot.policy.prepareRatio - 0.05,
46
70
  budget: ADVISORY_BUDGETS.chapters },
47
- { milestone: "urgent" as const, threshold: hardFenceRatio(snapshot) - 0.03,
48
- budget: ADVISORY_BUDGETS.urgent },
71
+ urgent,
49
72
  ];
73
+ }
74
+
75
+ export function advisorySchedule(
76
+ snapshot: Pick<ActiveContextSnapshot, "policy" | "contextWindow">,
77
+ guidance: GuidanceProfile = DEFAULT_GUIDANCE_PROFILE,
78
+ ): AdvisorySchedule {
79
+ const rungs = guidanceRungs(snapshot, guidance);
50
80
  for (let index = rungs.length - 2; index >= 0; index -= 1) {
51
81
  rungs[index].threshold = Math.min(rungs[index].threshold, rungs[index + 1].threshold - 0.02);
52
82
  }
@@ -57,12 +87,36 @@ export function advisorySchedule(
57
87
  };
58
88
  }
59
89
 
90
+ /**
91
+ * Record that an armed advisory actually REACHED the agent.
92
+ *
93
+ * The budget used to be spent when a milestone armed. Any automatic fold in the same
94
+ * context pass then cleared the arm before the projection was built, so a working
95
+ * ladder drained every milestone budget without ever emitting a word. Measured
96
+ * 2026-08-06 on an identical pressure schedule: an idle-ladder session delivered 7
97
+ * advisories, and a tool-using session delivered 0 while reporting notice, tools and
98
+ * chapters each "delivered" once. All four instrumented runs were the second case.
99
+ */
100
+ export function recordAdvisoryDelivery(
101
+ state: ActiveContextState,
102
+ milestone: AdvisoryMilestone,
103
+ ): ActiveContextState {
104
+ const current = advisoryState(state);
105
+ const delivered = { ...current.delivered };
106
+ delivered[milestone] = (delivered[milestone] ?? 0) + 1;
107
+ const advisory = { ...current, delivered };
108
+ delete advisory.armed;
109
+ return { ...state, advisory };
110
+ }
111
+
60
112
  export function updateAdvisoryMilestone(
61
113
  currentState: ActiveContextState,
62
114
  ratio: number,
63
115
  schedule: AdvisorySchedule,
64
116
  scheduleChanged: boolean,
65
117
  scheduleKey: string,
118
+ /** Spend the milestone budget on DELIVERY rather than on arming. */
119
+ countOnDelivery = false,
66
120
  ): { state: ActiveContextState; milestone: AdvisoryMilestone | null } {
67
121
  const current = advisoryState(currentState);
68
122
  if (scheduleChanged) {
@@ -78,12 +132,28 @@ export function updateAdvisoryMilestone(
78
132
  highWater = Math.min(highWater, index > 0 ? schedule.rungs[index - 1].threshold : 0);
79
133
  }
80
134
  }
81
- const crossed = schedule.rungs.filter((rung) =>
82
- highWater < rung.threshold && ratio >= rung.threshold &&
83
- (current.delivered[rung.milestone] ?? 0) < rung.budget);
84
- const selected = crossed.at(-1) ?? null;
135
+ let selected: AdvisorySchedule["rungs"][number] | null;
136
+ if (countOnDelivery) {
137
+ // Only ever the highest rung the current pressure has reached, so a plateau does
138
+ // not replay a descending cascade of stale milestones. A rung that has never been
139
+ // DELIVERED stays armable even after the high-water mark has passed it: the old
140
+ // ratchet locked out every rung that armed once and was cleared before it spoke.
141
+ const reached = schedule.rungs.filter((rung) => ratio >= rung.threshold).at(-1) ?? null;
142
+ const deliveries = reached ? current.delivered[reached.milestone] ?? 0 : 0;
143
+ selected = reached && deliveries < reached.budget &&
144
+ (highWater < reached.threshold || deliveries === 0)
145
+ ? reached
146
+ : null;
147
+ } else {
148
+ const crossed = schedule.rungs.filter((rung) =>
149
+ highWater < rung.threshold && ratio >= rung.threshold &&
150
+ (current.delivered[rung.milestone] ?? 0) < rung.budget);
151
+ selected = crossed.at(-1) ?? null;
152
+ }
85
153
  const delivered = { ...current.delivered };
86
- if (selected) delivered[selected.milestone] = (delivered[selected.milestone] ?? 0) + 1;
154
+ if (selected && !countOnDelivery) {
155
+ delivered[selected.milestone] = (delivered[selected.milestone] ?? 0) + 1;
156
+ }
87
157
  const armed = selected
88
158
  ? { milestone: selected.milestone, threshold: selected.threshold, scheduleKey }
89
159
  : current.armed;
@@ -100,15 +170,52 @@ export function updateAdvisoryMilestone(
100
170
  };
101
171
  }
102
172
 
173
+ /**
174
+ * Wording rule for every dosage below: report what is HAPPENING and name the actions
175
+ * that change it. The earlier phrasing asked the agent to fold proactively, which is
176
+ * advice it can only act on by interrupting its own task; the runtime folds either way,
177
+ * so the honest thing to say is what it will do and what reacting buys.
178
+ */
179
+ function curationText(milestone: AdvisoryMilestone, percent: number, toolName: string): string {
180
+ if (milestone === "orientation") {
181
+ return "Collapsed folds are a browsable index of the work behind you, and their briefs sit in the cached " +
182
+ `prefix of the window, so paging that index costs almost nothing. Page it with ${toolName} ` +
183
+ `{"action":"status"} and expand what the current task needs with ${toolName} ` +
184
+ '{"action":"expand","id":"<fold-id>"}.';
185
+ }
186
+ if (milestone === "notice") {
187
+ return `Context is ${percent}% full, and the ladder is folding stale spans as it fills. Spans you fold ` +
188
+ `yourself carry your brief instead of a generated one: ${toolName} ` +
189
+ '{"action":"fold","marks":[{"ids":["<start>","<end>"],"brief":"<factual brief>"}]} batches several at ' +
190
+ `once, and ${toolName} {"action":"protect","ids":["<entry-id>"]} keeps what must stay raw out of every ` +
191
+ "fold. Continuing the task is the default; nothing here needs a reply.";
192
+ }
193
+ if (milestone === "tools") {
194
+ return "Completed read-only tool batches are what the ladder reclaims first, and their endpoint ids are in " +
195
+ "the live advisory. A batch it folds keeps a generated brief; one you fold keeps yours. Either way the " +
196
+ `exact entries come back with ${toolName} {"action":"expand","id":"<fold-id>"}, and a brief that does ` +
197
+ `not describe what you needed is corrected with ${toolName} ` +
198
+ '{"action":"rebrief","id":"<fold-id>","brief":"<factual brief>"}.';
199
+ }
200
+ return "Adjacent folds of finished work are the next thing the ladder consolidates. Handing two or more to " +
201
+ `${toolName} {"action":"fold","ids":["<fold-id>","<fold-id>"],"brief":"<factual brief>"} nests them under ` +
202
+ "a brief you wrote, leaving the oldest material deepest and still exactly recoverable; a boundary that " +
203
+ `came out wrong is re-cut with ${toolName} {"action":"reboundary","ids":["<start>","<end>"]}.`;
204
+ }
205
+
103
206
  export function milestoneText(
104
207
  milestone: AdvisoryMilestone,
105
208
  sessionId: string,
106
209
  threshold: number,
107
210
  toolName: string,
108
211
  brandNoun = DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
212
+ guidance: GuidanceProfile = DEFAULT_GUIDANCE_PROFILE,
109
213
  ): string {
110
214
  const percent = Math.round(threshold * 100);
111
215
  const prefix = `[${contextBrand(brandNoun)} milestone ${milestone}; session ${sessionId.slice(0, 16)}]`;
216
+ if (guidance === "curation" && milestone !== "urgent") {
217
+ return `${prefix} ${curationText(milestone, percent, toolName)}`;
218
+ }
112
219
  if (milestone === "notice") {
113
220
  return `${prefix} Context pressure has crossed ${percent}%. Automatic folding is available. ` +
114
221
  `Inspect candidates exactly with ${toolName} {"action":"status"}.`;
@@ -119,13 +226,55 @@ export function milestoneText(
119
226
  }
120
227
  if (milestone === "chapters") {
121
228
  return `${prefix} The chapter preparation rung begins at ${percent}%. ` +
122
- `Use eligibleChapter endpoints with ${toolName} ` +
123
- '{"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"}.';
229
+ `Closed chapters fold from the eligibleChapter endpoints in ${toolName} {"action":"status"}; ` +
230
+ `${toolName} {"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"} folds one with ` +
231
+ "your own brief instead of a generated one.";
124
232
  }
125
233
  return `${prefix} The hard context fence is near. The next automatic action is a committed chapter fold ` +
126
234
  "or the provider request is aborted before transmission.";
127
235
  }
128
236
 
237
+ /**
238
+ * The surfacing carrier. It rides the same ephemeral tail channel as the live
239
+ * advisory: appended after the stable prefix, never durable, never a mutation of
240
+ * the projection, and cleared whenever the next context event is built.
241
+ */
242
+ export function surfacingText(input: {
243
+ suggestions: readonly SurfacingSuggestion[];
244
+ brandNoun?: string;
245
+ }): string | null {
246
+ if (!input.suggestions.length) return null;
247
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
248
+ const lines = input.suggestions.map((suggestion, index) => [
249
+ `${index + 1}. ${suggestion.id}${suggestion.depth ? ` (depth ${suggestion.depth})` : ""}: ${suggestion.text}`,
250
+ ...(suggestion.alternateRoute ? [` peek (ephemeral read): ${suggestion.alternateRoute}`] : []),
251
+ ` expand (durable restore): ${suggestion.route}`,
252
+ ].join("\n"));
253
+ return boundReceiptText(
254
+ [
255
+ `[${brand} suggestions] ${input.suggestions.length} collapsed span(s) look relevant to the current ` +
256
+ "task. Peek reads one back ephemerally at any depth; expand restores it in place, outermost first. " +
257
+ "Ignoring them is a valid answer.",
258
+ ...lines,
259
+ ].join("\n"),
260
+ 2_048,
261
+ `[${brand} suggestions] Relevant collapsed spans are unavailable this pass.`,
262
+ );
263
+ }
264
+
265
+ export interface AdvisoryCuration {
266
+ /** Room left before the truthful fence, in tokens. */
267
+ headroomTokens: number | null;
268
+ budgetTokens: number;
269
+ pendingMarks: number;
270
+ eligibleMarks: number;
271
+ /** What the pending marks would free, in TOKENS. A percentage is not actionable. */
272
+ freedTokens: number;
273
+ eligibleFreedTokens: number;
274
+ /** The share of what the request currently pays for that no pending decision reclaims. */
275
+ unmarkedShare: number;
276
+ }
277
+
129
278
  export function liveAdvisoryText(input: {
130
279
  milestone: AdvisoryMilestone;
131
280
  ratio: number;
@@ -133,6 +282,7 @@ export function liveAdvisoryText(input: {
133
282
  chapterEndpoints: string[];
134
283
  remediationCount: number;
135
284
  brandNoun?: string;
285
+ curation?: AdvisoryCuration | null;
136
286
  }): string {
137
287
  const tools = input.toolEndpoints.length
138
288
  ? input.toolEndpoints.slice(0, 3).join(", ")
@@ -140,9 +290,16 @@ export function liveAdvisoryText(input: {
140
290
  const chapter = input.chapterEndpoints.length
141
291
  ? `${input.chapterEndpoints[0]}..${input.chapterEndpoints.at(-1)}`
142
292
  : "none";
293
+ const curation = input.curation
294
+ ? `headroom ${input.curation.headroomTokens ?? "unmeasured"} of ${input.curation.budgetTokens} tokens; ` +
295
+ `unmarked share ${Math.round(input.curation.unmarkedShare * 100)}%; ` +
296
+ `${input.curation.pendingMarks} pending mark(s), ${input.curation.eligibleMarks} eligible now, ` +
297
+ `together freeing about ${input.curation.eligibleFreedTokens} tokens of the ` +
298
+ `${input.curation.freedTokens} marked; `
299
+ : "";
143
300
  return boundReceiptText(
144
301
  `[${contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN)} advisory] ` +
145
- `pressure ${Math.round(input.ratio * 100)}%; milestone ${input.milestone}; ` +
302
+ `pressure ${Math.round(input.ratio * 100)}%; milestone ${input.milestone}; ${curation}` +
146
303
  `eligible read-only batch endpoints: ${tools}; eligibleChapter endpoints: ${chapter}; ` +
147
304
  `session milestone count: ${input.remediationCount}.`,
148
305
  2_048,
@@ -15,6 +15,26 @@ export function bytes(value: unknown): number {
15
15
  return Buffer.byteLength(typeof value === "string" ? value : stableStringify(value), "utf8");
16
16
  }
17
17
 
18
+ export function boundedUtf8(text: string, maximumBytes: number): string {
19
+ const buffer = Buffer.from(text, "utf8");
20
+ if (buffer.length <= maximumBytes) return text;
21
+ // A multi-byte sequence cut by the bound decodes to one trailing replacement
22
+ // character; dropping it keeps the slice an exact prefix of the source.
23
+ return buffer.subarray(0, maximumBytes).toString("utf8").replace(/\uFFFD$/, "");
24
+ }
25
+
26
+ /**
27
+ * The suffix of `text` beginning at a byte offset. A multi-byte sequence cut by the
28
+ * offset decodes to one leading replacement character; dropping it keeps the slice an
29
+ * exact suffix of the source, which is what makes a paged peek losslessly rejoinable.
30
+ */
31
+ export function utf8Slice(text: string, offsetBytes: number): string {
32
+ if (offsetBytes <= 0) return text;
33
+ const buffer = Buffer.from(text, "utf8");
34
+ if (offsetBytes >= buffer.length) return "";
35
+ return buffer.subarray(offsetBytes).toString("utf8").replace(/^�/, "");
36
+ }
37
+
18
38
  export function ownValue(value: unknown, key: string): unknown {
19
39
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
20
40
  const descriptor = Object.getOwnPropertyDescriptor(value, key);