pi-fold 0.1.0 → 0.1.1

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
@@ -1,10 +1,12 @@
1
1
  # pi-fold
2
2
 
3
- pi-fold provides lossless, agent-governed context folding for Pi. It changes only the oldest eligible context while leaving the fresh window untouched.
3
+ pi-fold lets a Pi agent govern its own context window. The registered `active_context` tool gives the session's agent seven actions over its transcript: `status`, `peek`, `fold`, `expand`, `refold`, `protect`, and `unprotect`. Folding is lossless: a fold replaces a stale span with a short brief while keeping SHA-256-verified references to every original entry, so any fold can be expanded back to its exact source. Advisory milestones tell the agent what is eligible as measured context pressure rises; the agent decides what stays hot, what folds, and what is pinned.
4
+
5
+ When the agent does not act, an autonomous ladder manages the window the same lossless way: it folds completed read-only tool batches, folds structurally closed chapters, and consolidates existing folds into a hierarchy only past explicit thresholds. Only the oldest eligible context changes; the fresh window is never touched.
4
6
 
5
7
  ![pi-fold folding a live session](https://raw.githubusercontent.com/shaneconner/pi-fold/main/docs/demo.gif)
6
8
 
7
- *A live session at 5.5× speed: the autonomous ladder folds a completed read batch mid-conversation — no command issued — and `/context` reports the fold.*
9
+ *A live session at 5.5× speed: the autonomous ladder folds a completed read batch mid-conversation, no command issued, and `/context` reports the fold.*
8
10
 
9
11
  ## Status
10
12
 
@@ -22,10 +24,64 @@ pi install npm:pi-fold
22
24
 
23
25
  pi-fold maps canonical session entries into a fold lattice. A fold replaces an eligible stale span with a short placeholder while retaining ordered references to the exact source entries. Expansion checks the stored SHA-256 identities before restoring that source.
24
26
 
25
- An autonomous ladder first folds completed read-only tool batches, then refolds or consolidates existing folds, and finally folds structurally closed chapters. Cadence and fresh-tail rules prevent repeated or premature folding. Chapter briefs are generated by the session's model by default, using one request per fold with a bounded input span, bounded output, and no tool calls. Every summarizer failure falls back to a deterministic brief; set `summarizer: "deterministic"` to disable model calls entirely. The default spends provider tokens, as native compaction does. Advisory milestones describe eligible actions as measured context pressure rises, and the provider-window fence blocks an unsafe request if no lossless fold can make room.
27
+ Collapsed folds double as a browsable index of earlier work. The briefs sit in the stable prefix of the window, which providers typically serve from cache, so the agent can page through them at little cost, note which spans matter to its current task, expand those, and leave the rest folded.
28
+
29
+ The briefs are the index, and reading a fold is not the same as committing to it. `peek` is the ephemeral read: it returns one fold's exact SHA-256-verified source as a tool result, at any depth and with the ancestors still collapsed, without touching the projection; the content lands in the fresh window like any other read and can later fold away as a completed read batch. `expand` is the commitment, restoring that source in place until the fold is refolded, and `status` with `detail: "tree"` lists every fold, nested ones included, in transcript order with its depth and parent. Expansion is outside-in, level by level: expanding a parent reveals its children's briefs, each expandable in turn, and collapsing a parent re-collapses its descendants.
30
+
31
+ The agent's own actions come first. Through the context tool it folds any structurally closed span it judges stale, folds completed tool batches, consolidates two or more adjacent folds into a deeper one at any time, expands any fold whose detail is needed again, and protects entries that must stay raw. None of the ladder's pressure or width thresholds apply to these actions.
32
+
33
+ Every fold, agent-made or automatic, obeys the same structural rules. Folds form a DAG whose leaves are the exact session entries, and every fold record keeps ordered references down to those leaves, so expansion at any depth restores the originals after SHA-256 verification. Folds nest rather than partially overlap. Chapter folds align to closed user/assistant/tool-batch units, consolidations take two or more adjacent folds, and protected or fresh evidence never folds.
34
+
35
+ The autonomous ladder is deliberately more conservative than the agent. It folds only completed read-only tool batches and structurally closed chapters, absorbs only the tool-result folds inside a chapter's own span, and creates folds of folds only past explicit thresholds, taking adjacent folds oldest first. Those thresholds are more than ten visible collapsed folds, which keeps the brief index tidy long before pressure matters, or total measured context usage (all of it, not just briefs) reaching 85% of the provider window, at which point consolidation squeezes existing folds to reclaim room. Briefs themselves are bounded at 1,200 characters each, so the visible index normally costs about 1% of the window. Cadence and fresh-tail rules prevent repeated or premature automatic folding.
36
+
37
+ Chapter briefs are generated by the session's model by default, using one request per fold with a bounded input span, bounded output, and no tool calls. Every summarizer failure falls back to a deterministic brief; set `summarizer: "deterministic"` to disable model calls entirely. The default spends provider tokens, as native compaction does. A cheaper model from the same provider is the recommended summarizer: for example, a session on GPT 5.6 Sol can delegate fold briefs to GPT 5.6 Luna at medium effort with `summarizer: { provider: "openai", model: "gpt-5.6-luna", effort: "medium" }`, using the provider id from your Pi model registry. Advisory milestones describe eligible actions as measured context pressure rises, and the provider-window fence blocks an unsafe request if no lossless fold can make room.
38
+
39
+ Milestone wording comes in three guidance profiles over the same rungs, thresholds, and per-session budgets. The default `"pressure"` frames each milestone as rising context pressure; `"curation"` frames the same milestones as curating context for the current task and adds one early orientation advisory, at 25% of the window, that introduces the fold index; `"minimal"` delivers only the urgent fence advisory.
26
40
 
27
41
  By default, evidence ingestion writes read-only artifact files under the session directory's `pi-fold-evidence/`, sets them to mode `0444`, and enforces a 512 MB session cap. These immutable files provide exact-recovery anchors for oversized tool results. Set `evidenceIngestion: false` to disable the hook and all evidence writes.
28
42
 
43
+ ## Surfacing relevant folds
44
+
45
+ Paging the fold index is something the agent can do at any moment. Surfacing is that same act performed on its behalf when it did not think to look. As a session grows, the material most worth reopening tends to sit in the oldest and deepest folds, which are exactly the ones the agent stops seeing. pi-fold scores every collapsed fold against what the session is working on right now and hands the best two or three back with the fold id and both affordances, so the agent can peek one ephemerally, expand it in place, or dismiss the whole slate and carry on. The judgment stays with the agent; the mechanism only puts the option where it can be acted on.
46
+
47
+ The selector is deterministic and model free: no extra provider call, no embeddings, no randomness, no wall clock. A fold scores on the lexical overlap between its brief and the most recent user and assistant text, plus a small recency component read from transcript position and a small bonus for depth (a deeply nested fold is otherwise invisible). Those structural components alone can never clear the threshold, so a suggestion always rests on evidence that the span matches the task in hand, and when nothing clears the threshold, nothing is shown at all. A fold that was suggested and left alone is suppressed for a cooldown; a fold that is currently expanded or protected is never suggested.
48
+
49
+ Suggestions ride the same ephemeral channel as the advisory milestones: appended at the tail of the window, after the stable prefix, so no cached prefix is invalidated and nothing durable is written to the transcript. They never share an advisory with urgent fence text, where the only useful next action is the fold that keeps the request transmissible.
50
+
51
+ Every suggestion shown is recorded in durable session state with its source, item id, score and transcript ordinal. The record resolves to `accept` when the agent peeks or expands that item within a short window of turns, and to `reject` otherwise. `active_context {"action":"status"}` returns the live slate and the whole log under `automatic.surfacing`. Set `surfacing: false` to register no sources and emit nothing. Slate size, threshold, cooldown and character budget are internal constants rather than options: they are the knobs the accept and reject record exists to settle.
52
+
53
+ ### Suggestion sources
54
+
55
+ pi-fold owns the carrier, not the content. Any extension can put its own items into the same slate, with the same ranking, the same budget, and the same accept and reject log. The built-in fold-brief source is registered through that identical interface.
56
+
57
+ ```js
58
+ registerPiFold(pi, {
59
+ setSuggestionSourceRegistrar: (register) => {
60
+ const memory = register({
61
+ id: "my-memory",
62
+ candidates: ({ state, snapshot, toolName }) => [
63
+ {
64
+ id: "note-42",
65
+ text: "Durable note the current task may want back.",
66
+ route: "recall {\"address\":\"note-42\"}",
67
+ },
68
+ ],
69
+ });
70
+ // memory.accepted("note-42") when the agent acts on it; memory.unregister() to withdraw.
71
+ },
72
+ });
73
+ ```
74
+
75
+ A candidate needs an id, the text that is scored and shown, and a route string naming the action that retrieves it. Optional `position` (a transcript ordinal, where higher is more recent) and `depth` feed the recency and depth components. A source that throws, or an item that is malformed, costs its own items and nothing else: the carrier, the projection, and the ladder are never at risk.
76
+
77
+ ## Relation to prior work
78
+
79
+ pi-fold combines two recent lines of work in one mechanism. Lossless hierarchical compaction, exemplified by LCM ([arXiv:2605.04050](https://arxiv.org/abs/2605.04050)) with an interactive walkthrough at [losslesscontext.ai](https://www.losslesscontext.ai/), builds a summary DAG over older messages with lossless pointers to every original, but the system alone decides when to compact. Self-GC ([arXiv:2607.00692](https://arxiv.org/abs/2607.00692)) treats context as indexed, recoverable objects with fold, mask, and prune actions, proposed by a side-channel planner under harness enforcement. In pi-fold the session agent itself holds the verbs, in-band: it can fold, expand, refold, protect, and consolidate on its own judgment, and the autonomous ladder is the fallback; a session that never touches the tool degrades gracefully into lossless hierarchical compaction.
80
+
81
+ Context-Folding ([arXiv:2510.11967](https://arxiv.org/abs/2510.11967), ByteDance Seed) has the agent branch into a sub-trajectory and fold it on completion, with the behavior shaped by reinforcement learning; the fold keeps a summary of the outcome rather than a lossless path back. MemGPT and its successor [Letta](https://github.com/letta-ai/letta) are the canonical agent-held memory-verb systems: the agent edits its own memory blocks and archives while the window pages against external stores. Two things separate pi-fold from that line. First, what is governed: Letta manages traffic between the window and outside storage, and when its message queue overflows the in-window record becomes a lossy recursive summary; pi-fold restructures the transcript in place. Second, recovery: theirs is retrieval, a search that re-ingests whatever comes back as new tokens; ours is expansion, which restores the exact folded entries after SHA-256 verification. Further inspirations include Hindsight and Hermes.
82
+
83
+ The design also pairs with Pi's session trees: a fold's collapsed-or-expanded state is a natural unit for curating exactly which context a subagent or workflow leg inherits.
84
+
29
85
  ## Configuration
30
86
 
31
87
  The package entry calls `registerPiFold(pi)` with the defaults below. Hosts that call the named `registerPiFold` export directly may pass an options object. Replacement lists and sets replace the defaults; they are not additive.
@@ -40,8 +96,11 @@ The package entry calls `registerPiFold(pi)` with the defaults below. Hosts that
40
96
  | `commandNames` | `undefined` | Optional full-name overrides for the effective defaults `{ status: "context", fold: "fold-context" }`. Both names must be distinct kebab-case strings; supplied values take precedence over `commandPrefix`. |
41
97
  | `summarizer` | `"session"` | Brief generator: the live session model, `"deterministic"` for no model calls, or `{ provider, model, effort? }` resolved through Pi's model registry. Cannot be combined with `summarizeContextSpan`. |
42
98
  | `summarizeContextSpan` | `undefined` | Optional async brief generator. Results must include a useful bounded brief, provider/model/effort attribution, and `toolCalls: 0`; failure uses the deterministic brief. |
99
+ | `guidance` | `"pressure"` | Advisory wording profile: `"pressure"`, `"curation"` for task-relevance self-curation plus an early orientation advisory, or `"minimal"` for the urgent advisory alone. Rungs, thresholds, and budgets are the same in every profile. |
43
100
  | `setProjectionProvider` | `undefined` | Optional host callback that receives the [projection-candidate provider](#projection-candidate-records). Normal Pi context events do not require it. |
44
- | `toolActions` | all six actions | Replacement allowlist drawn from `status`, `fold`, `expand`, `refold`, `protect`, and `unprotect`. |
101
+ | `surfacing` | `true` | Score collapsed folds against the recent task context and offer the best few back through the ephemeral tail channel. Set `false` to register no suggestion sources and emit nothing. |
102
+ | `setSuggestionSourceRegistrar` | `undefined` | Optional host callback that receives the [suggestion source](#suggestion-sources) registration function. |
103
+ | `toolActions` | all seven actions | Replacement allowlist drawn from `status`, `peek`, `fold`, `expand`, `refold`, `protect`, and `unprotect`. |
45
104
  | `blockingTools` | `["Agent"]` | Replacement list of tool names that trigger one opportunistic stale-tool fold before the call. Use `[]` to disable it. |
46
105
  | `readOnlyTools` | `new Set(["read", "grep", "find", "ls"])` | Replacement set of tool names whose completed batches may fold automatically. Defaults to Pi's built-in read-only tools. Pass a `ReadonlySet<string>`. |
47
106
  | `isMcpTool` | `() => false` | Synchronous predicate enabling evidence ingestion for oversized structured MCP results. Names shaped as `mcp__<server>__<tool>` project the actual server; other names use the neutral namespace derived from `entryTypePrefix`. |
@@ -10,6 +10,8 @@ import {
10
10
  clearArmedAdvisory,
11
11
  liveAdvisoryText,
12
12
  milestoneText,
13
+ normalizeGuidanceProfile,
14
+ surfacingText,
13
15
  updateAdvisoryMilestone,
14
16
  } from "./lib/advisory.ts";
15
17
  import {
@@ -25,6 +27,8 @@ import {
25
27
  commitPreparedFold,
26
28
  encodedFoldSource,
27
29
  foldCandidatesDetail,
30
+ foldTreeDetail,
31
+ peekFoldSource,
28
32
  prepareFold,
29
33
  preparedFoldError,
30
34
  projectActiveContext,
@@ -85,6 +89,7 @@ import {
85
89
  DEFAULT_ACTIVE_CONTEXT_TOOL_LABEL,
86
90
  DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
87
91
  DEFAULT_CONTEXT_WINDOW,
92
+ DEFAULT_SURFACING_ENABLED,
88
93
  entryTypeNamespace,
89
94
  READ_ONLY_TOOLS_DEFAULT,
90
95
  USER_RESCUE_MAX_SOURCE_CHARS,
@@ -97,7 +102,10 @@ import type {
97
102
  FoldCandidate,
98
103
  FoldKind,
99
104
  FoldRecordEntry,
105
+ GuidanceProfile,
100
106
  PreparedFold,
107
+ SuggestionSource,
108
+ SurfacingSuggestion,
101
109
  } from "./lib/policy.ts";
102
110
  import {
103
111
  automaticToolBrief,
@@ -107,6 +115,12 @@ import {
107
115
  selectAutomaticToolBatch,
108
116
  selectAutomaticToolForRung,
109
117
  } from "./lib/selection.ts";
118
+ import {
119
+ acceptSurfacingSuggestion,
120
+ FOLD_BRIEF_SUGGESTION_SOURCE,
121
+ surfacingOrdinal,
122
+ updateSurfacing,
123
+ } from "./lib/surfacing.ts";
110
124
  import { buildActiveContextCommands, buildActiveContextTool } from "./lib/tool-surface.ts";
111
125
  import { mapActiveContext } from "./lib/transcript.ts";
112
126
 
@@ -118,11 +132,22 @@ export * from "./lib/measurement.ts";
118
132
  export * from "./lib/persistence.ts";
119
133
  export * from "./lib/policy.ts";
120
134
  export * from "./lib/selection.ts";
135
+ export * from "./lib/surfacing.ts";
121
136
  export * from "./lib/transcript.ts";
122
137
 
138
+ /** Handle a suggestion source keeps: withdraw itself, and report that its item was acted on. */
139
+ export interface SuggestionSourceHandle {
140
+ unregister: () => void;
141
+ accepted: (id: string) => void;
142
+ }
143
+
144
+ export type SuggestionSourceRegistrar = (source: SuggestionSource) => SuggestionSourceHandle;
145
+
123
146
  export function registerActiveContext(pi: any, options: {
124
147
  summarizeContextSpan?: (request: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>;
125
148
  setProjectionProvider?: (provider: (ctx: any) => Array<Record<string, unknown>>) => void;
149
+ setSuggestionSourceRegistrar?: (register: SuggestionSourceRegistrar) => void;
150
+ surfacing?: boolean;
126
151
  toolActions?: readonly ActiveContextToolAction[];
127
152
  toolName?: string;
128
153
  toolLabel?: string;
@@ -132,13 +157,22 @@ export function registerActiveContext(pi: any, options: {
132
157
  commandNames?: { status?: string; fold?: string };
133
158
  readOnlyTools?: ReadonlySet<string>;
134
159
  blockingTools?: readonly string[];
135
- }): { projectionCandidates: (ctx: any) => Array<Record<string, unknown>> } {
160
+ guidance?: GuidanceProfile;
161
+ }): {
162
+ projectionCandidates: (ctx: any) => Array<Record<string, unknown>>;
163
+ registerSuggestionSource: SuggestionSourceRegistrar;
164
+ } {
136
165
  const toolName = options.toolName ?? DEFAULT_ACTIVE_CONTEXT_TOOL_NAME;
137
166
  const toolLabel = options.toolLabel ?? DEFAULT_ACTIVE_CONTEXT_TOOL_LABEL;
138
167
  const brandNoun = options.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN;
139
168
  const entryTypePrefix = options.entryTypePrefix ?? DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX;
140
169
  const commandPrefix = options.commandPrefix ?? "";
141
170
  const readOnlyTools = options.readOnlyTools ?? READ_ONLY_TOOLS_DEFAULT;
171
+ const guidance = normalizeGuidanceProfile(options.guidance);
172
+ if (options.surfacing !== undefined && typeof options.surfacing !== "boolean") {
173
+ throw new Error("surfacing must be a boolean");
174
+ }
175
+ const surfacingEnabled = options.surfacing ?? DEFAULT_SURFACING_ENABLED;
142
176
  if (!toolName || !toolLabel || !brandNoun || !entryTypePrefix || typeof commandPrefix !== "string" ||
143
177
  (commandPrefix && !/^[a-z0-9-]+$/.test(commandPrefix)) ||
144
178
  [...readOnlyTools].some((name) => typeof name !== "string" || !name)) {
@@ -166,6 +200,7 @@ export function registerActiveContext(pi: any, options: {
166
200
  const foldRecordEntryType = `${entryTypePrefix}-fold-record`;
167
201
  const milestoneProjectionType = `${entryTypePrefix}-milestone`;
168
202
  const advisoryProjectionType = `${entryTypePrefix}-advisory`;
203
+ const surfacingProjectionType = `${entryTypePrefix}-surfacing`;
169
204
  const entryNamespace = entryTypeNamespace(entryTypePrefix);
170
205
  const providerMeasurementEntryType = `${entryNamespace}-provider-context-measurement`;
171
206
  const nativeReceiptEntryType = `${entryNamespace}-native-compaction-receipt`;
@@ -247,6 +282,14 @@ export function registerActiveContext(pi: any, options: {
247
282
  hardFenceReleasedProjectionKeys: new Set<string>(),
248
283
  };
249
284
 
285
+ // Owns the suggestion sources feeding the ephemeral surfacing carrier plus the slate
286
+ // rendered on the current context event. Nothing here is durable: the carrier is
287
+ // rebuilt from state on every projection, and only the log lives in session state.
288
+ const surfacing = {
289
+ sources: [] as SuggestionSource[],
290
+ suggestions: [] as SurfacingSuggestion[],
291
+ };
292
+
250
293
  // Owns native-compaction decisions and completion retry state; nativeReceiptQueue serializes it.
251
294
  const nativeCompaction = {
252
295
  lastThresholdDecision: null as Record<string, unknown> | null,
@@ -1018,14 +1061,40 @@ export function registerActiveContext(pi: any, options: {
1018
1061
  if (ownsSlot && sessionIdentityStillValid(ctx, snapshot.sessionId, capturedGeneration)) updateStatus(ctx);
1019
1062
  });
1020
1063
  };
1064
+ // Appended at the very TAIL, after the stable prefix, so a suggestion never
1065
+ // invalidates a cached prefix and never becomes durable transcript.
1066
+ const appendSurfacing = (projected: unknown[], snapshot: ActiveContextSnapshot): unknown[] => {
1067
+ const content = surfacingText({ suggestions: surfacing.suggestions, brandNoun });
1068
+ if (!content) return projected;
1069
+ projected.push({
1070
+ role: "custom",
1071
+ customType: surfacingProjectionType,
1072
+ content,
1073
+ display: false,
1074
+ details: {
1075
+ source: activeContextSource(entryTypePrefix),
1076
+ ephemeral: true,
1077
+ suggestions: surfacing.suggestions.map((suggestion) => ({
1078
+ source: suggestion.source,
1079
+ id: suggestion.id,
1080
+ score: suggestion.score,
1081
+ })),
1082
+ },
1083
+ timestamp: typeof ownValue(snapshot.messages.at(-1), "timestamp") === "number"
1084
+ ? ownValue(snapshot.messages.at(-1), "timestamp")
1085
+ : 0,
1086
+ });
1087
+ return projected;
1088
+ };
1021
1089
  const projectWithAdvisory = (snapshot: ActiveContextSnapshot): unknown[] => {
1022
1090
  const projected = projectActiveContext(snapshot, persistence.state!).filter((message) => {
1023
1091
  const customType = ownValue(message, "customType");
1024
- return customType !== milestoneProjectionType && customType !== advisoryProjectionType;
1092
+ return customType !== milestoneProjectionType && customType !== advisoryProjectionType &&
1093
+ customType !== surfacingProjectionType;
1025
1094
  });
1026
1095
  const armed = advisoryState(persistence.state!).armed;
1027
1096
  if (!armed || armed.milestone !== advisory.armedMilestone || measurements.latestRatio === null ||
1028
- measurements.latestRatio < 0.85 * armed.threshold) return projected;
1097
+ measurements.latestRatio < 0.85 * armed.threshold) return appendSurfacing(projected, snapshot);
1029
1098
  const status = activeContextStatus(snapshot, persistence.state!, 0, 1, snapshot.policy.maxFoldSourceRefs);
1030
1099
  const eligible = ownValue(status, "eligibleChapter");
1031
1100
  const startId = ownValue(eligible, "startId");
@@ -1040,7 +1109,9 @@ export function registerActiveContext(pi: any, options: {
1040
1109
  projected.push({
1041
1110
  role: "custom",
1042
1111
  customType: milestoneProjectionType,
1043
- content: milestoneText(armed.milestone, persistence.state!.sessionId, armed.threshold, toolName, brandNoun),
1112
+ content: milestoneText(
1113
+ armed.milestone, persistence.state!.sessionId, armed.threshold, toolName, brandNoun, guidance,
1114
+ ),
1044
1115
  display: false,
1045
1116
  details: { source: activeContextSource(entryTypePrefix), ephemeral: true, milestone: armed.milestone },
1046
1117
  timestamp: 0,
@@ -1062,7 +1133,9 @@ export function registerActiveContext(pi: any, options: {
1062
1133
  ? ownValue(snapshot.messages.at(-1), "timestamp")
1063
1134
  : 0,
1064
1135
  });
1065
- return projected;
1136
+ // A suggestion never shares an advisory with urgent fence text: at the fence the
1137
+ // only useful next action is the fold that keeps the request transmissible.
1138
+ return armed.milestone === "urgent" ? projected : appendSurfacing(projected, snapshot);
1066
1139
  };
1067
1140
  const commitDeterministicCandidate = async (
1068
1141
  snapshot: ActiveContextSnapshot,
@@ -1285,6 +1358,67 @@ export function registerActiveContext(pi: any, options: {
1285
1358
  };
1286
1359
  options.setProjectionProvider?.(projectionCandidates);
1287
1360
 
1361
+ /**
1362
+ * The suggestion-source hook. The built-in fold-brief source registers through this
1363
+ * same call, so an external memory system's items reach the identical carrier,
1364
+ * budget, ranking, and accept/reject log; pi-fold owns the channel, not the content.
1365
+ */
1366
+ const registerSuggestionSource: SuggestionSourceRegistrar = (source: SuggestionSource) => {
1367
+ if (!source || typeof source !== "object" || typeof source.id !== "string" || !source.id ||
1368
+ typeof source.candidates !== "function") {
1369
+ throw new Error("A suggestion source needs a nonempty id and a candidates function");
1370
+ }
1371
+ if (surfacing.sources.some((registered) => registered.id === source.id)) {
1372
+ throw new Error(`Suggestion source '${source.id}' is already registered`);
1373
+ }
1374
+ surfacing.sources.push(source);
1375
+ return {
1376
+ unregister: () => {
1377
+ surfacing.sources = surfacing.sources.filter((registered) => registered !== source);
1378
+ },
1379
+ accepted: (id: string) => {
1380
+ if (!persistence.state || typeof id !== "string" || !id) return;
1381
+ // In-memory only: the next persisted state carries it. Reporting an outcome
1382
+ // must never write a durable entry of its own.
1383
+ persistence.state = acceptSurfacingSuggestion(
1384
+ persistence.state,
1385
+ id,
1386
+ lifecycle.latestSnapshot ? surfacingOrdinal(lifecycle.latestSnapshot) : 0,
1387
+ );
1388
+ },
1389
+ };
1390
+ };
1391
+ if (surfacingEnabled) registerSuggestionSource(FOLD_BRIEF_SUGGESTION_SOURCE);
1392
+ options.setSuggestionSourceRegistrar?.(registerSuggestionSource);
1393
+
1394
+ /**
1395
+ * Selects this context event's slate and logs what will be shown. Selection is
1396
+ * deterministic and model-free, and a selector fault costs the session nothing but
1397
+ * its suggestions: the projection itself is never at risk.
1398
+ *
1399
+ * Returns whether the log now differs from the DURABLE one rather than merely from
1400
+ * the pre-selection value: an outcome marked by an ephemeral peek carries no
1401
+ * persistence of its own and would otherwise never reach the session file.
1402
+ */
1403
+ const refreshSurfacing = (snapshot: ActiveContextSnapshot): boolean => {
1404
+ surfacing.suggestions = [];
1405
+ if (!surfacingEnabled || !surfacing.sources.length || !persistence.state) return false;
1406
+ try {
1407
+ const update = updateSurfacing({
1408
+ state: persistence.state,
1409
+ snapshot,
1410
+ sources: surfacing.sources,
1411
+ toolName,
1412
+ });
1413
+ surfacing.suggestions = update.suggestions;
1414
+ persistence.state = update.state;
1415
+ } catch {
1416
+ surfacing.suggestions = [];
1417
+ }
1418
+ return stableStringify(persistence.state.surfacing ?? null) !==
1419
+ stableStringify(persistence.persisted?.surfacing ?? null);
1420
+ };
1421
+
1288
1422
  // A same-session start/tree reload is a projection-generation mutation.
1289
1423
  // Queue it behind every context authority → preparation → commit →
1290
1424
  // projection transaction, then serialize the actual load with the action
@@ -1381,7 +1515,7 @@ export function registerActiveContext(pi: any, options: {
1381
1515
  if (!persistence.state) return false;
1382
1516
  const ratio = contextUsageRatio(measurement);
1383
1517
  if (ratio === null) return false;
1384
- const schedule = advisorySchedule(snapshot);
1518
+ const schedule = advisorySchedule(snapshot, guidance);
1385
1519
  const scheduleKey = sha256Value({
1386
1520
  schedule: schedule.key,
1387
1521
  provider: measurement.provider,
@@ -1484,8 +1618,9 @@ export function registerActiveContext(pi: any, options: {
1484
1618
  } else {
1485
1619
  measurements.latestRatio = contextUsageRatio(measurements.lastProviderMeasurement);
1486
1620
  }
1487
- if ((advisoryChanged || measurementStateChanged) && persistence.state && persistence.persisted &&
1488
- !sameStateProjection(persistence.state, persistence.persisted)) {
1621
+ const surfacingChanged = refreshSurfacing(snapshot);
1622
+ if ((advisoryChanged || measurementStateChanged || surfacingChanged) && persistence.state &&
1623
+ persistence.persisted && !sameStateProjection(persistence.state, persistence.persisted)) {
1489
1624
  mutationAttempted = true;
1490
1625
  await persistThroughActionQueue(ctx);
1491
1626
  persistedSucceeded = true;
@@ -1729,10 +1864,10 @@ export function registerActiveContext(pi: any, options: {
1729
1864
  const snapshot = authoritativeSnapshotFor(ctx);
1730
1865
  if (action === "status") {
1731
1866
  const detail = ownValue(params, "detail");
1732
- if (detail !== undefined && detail !== "fold_candidates") {
1733
- throw new Error("status detail must be 'fold_candidates'");
1867
+ if (detail !== undefined && detail !== "fold_candidates" && detail !== "tree") {
1868
+ throw new Error("status detail must be 'fold_candidates' or 'tree'");
1734
1869
  }
1735
- const schedule = advisorySchedule(snapshot);
1870
+ const schedule = advisorySchedule(snapshot, guidance);
1736
1871
  return toolPayload({
1737
1872
  ...activeContextStatus(
1738
1873
  snapshot,
@@ -1750,6 +1885,16 @@ export function registerActiveContext(pi: any, options: {
1750
1885
  ])),
1751
1886
  armedMilestone: advisory.armedMilestone,
1752
1887
  advisory: advisoryState(persistence.state),
1888
+ surfacing: {
1889
+ enabled: surfacingEnabled,
1890
+ sources: surfacing.sources.map((source) => source.id),
1891
+ shown: surfacing.suggestions.map((suggestion) => ({
1892
+ source: suggestion.source,
1893
+ id: suggestion.id,
1894
+ score: suggestion.score,
1895
+ })),
1896
+ log: clone(persistence.state.surfacing ?? []),
1897
+ },
1753
1898
  historicalGuidanceEntries: advisory.historicalGuidanceEntries,
1754
1899
  warningRatio: snapshot.policy.warningRatio,
1755
1900
  toolFoldRatio: snapshot.policy.toolFoldRatio,
@@ -1801,12 +1946,33 @@ export function registerActiveContext(pi: any, options: {
1801
1946
  failedPreparationIds: ladder.failedPreparations,
1802
1947
  }),
1803
1948
  } : {}),
1949
+ ...(detail === "tree" ? { tree: foldTreeDetail(snapshot, persistence.state) } : {}),
1804
1950
  });
1805
1951
  }
1952
+ // Peeking or expanding a suggested fold is the accept signal. Peek stays
1953
+ // ephemeral: the mark rides the next persisted state, never an entry of its own.
1954
+ const noteSurfacingAccept = (id: string): void => {
1955
+ if (!surfacingEnabled || !persistence.state) return;
1956
+ persistence.state = acceptSurfacingSuggestion(persistence.state, id, surfacingOrdinal(snapshot));
1957
+ };
1958
+ if (action === "peek") {
1959
+ const id = String(params.id ?? "").trim();
1960
+ if (!id) throw new Error("peek requires id");
1961
+ const payload = toolPayload(peekFoldSource({
1962
+ foldId: id,
1963
+ state: persistence.state,
1964
+ entries: ctx.sessionManager.getEntries?.() ?? ctx.sessionManager.getBranch(),
1965
+ sessionId: ctx.sessionManager.getSessionId(),
1966
+ maximumBytes: snapshot.policy.maxChapterChars,
1967
+ }));
1968
+ noteSurfacingAccept(id);
1969
+ return payload;
1970
+ }
1806
1971
  if (action === "expand" || action === "refold") {
1807
1972
  const id = String(params.id ?? "").trim();
1808
1973
  if (!id) throw new Error(`${action} requires id`);
1809
1974
  requireActiveFold(snapshot, persistence.state, id);
1975
+ if (action === "expand") noteSurfacingAccept(id);
1810
1976
  let next = setFoldProjectionState(persistence.state, id, action === "expand" ? "expanded" : "folded");
1811
1977
  if (action === "expand") next = withExpandLease(next, id);
1812
1978
  else {
@@ -1983,8 +2149,9 @@ export function registerActiveContext(pi: any, options: {
1983
2149
  persistence.persisted = null;
1984
2150
  lifecycle.latestSnapshot = null;
1985
2151
  advisory.armedMilestone = null;
2152
+ surfacing.suggestions = [];
1986
2153
  try { ctx.ui?.setStatus?.(entryTypePrefix, undefined); } catch { /* Shutdown cannot be blocked by UI. */ }
1987
2154
  });
1988
2155
 
1989
- return { projectionCandidates };
2156
+ return { projectionCandidates, registerSuggestionSource };
1990
2157
  }
@@ -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
  }
@@ -100,15 +130,43 @@ export function updateAdvisoryMilestone(
100
130
  };
101
131
  }
102
132
 
133
+ function curationText(milestone: AdvisoryMilestone, percent: number, toolName: string): string {
134
+ if (milestone === "orientation") {
135
+ return "Collapsed folds are a browsable index of the work behind you, and their briefs sit in the cached " +
136
+ `prefix of the window, so paging that index costs almost nothing. Page it with ${toolName} ` +
137
+ `{"action":"status"} and expand what the current task needs with ${toolName} ` +
138
+ '{"action":"expand","id":"<fold-id>"}.';
139
+ }
140
+ if (milestone === "notice") {
141
+ return `Context is ${percent}% full; curate it against the task you are on now. Fold the spans that task ` +
142
+ `no longer needs with ${toolName} ` +
143
+ '{"action":"fold","ids":["<start>","<end>"],"brief":"<factual brief>"}, and keep what must stay raw out ' +
144
+ `of every fold with ${toolName} {"action":"protect","ids":["<entry-id>"]}.`;
145
+ }
146
+ if (milestone === "tools") {
147
+ return "Completed read-only tool batches are the cheapest thing to fold, and their endpoint ids are in the " +
148
+ "live advisory. Fold the batches whose detail this task is finished with; each one expands back to the " +
149
+ `exact entries later with ${toolName} {"action":"expand","id":"<fold-id>"}.`;
150
+ }
151
+ return "Fold up: hand two or more adjacent folds of finished work to " +
152
+ `${toolName} {"action":"fold","ids":["<fold-id>","<fold-id>"],"brief":"<factual brief>"} so they nest in ` +
153
+ "one deeper fold, leaving the oldest material deepest and still exactly recoverable. A closed chapter " +
154
+ `folds first, from the eligibleChapter endpoints in ${toolName} {"action":"status"}.`;
155
+ }
156
+
103
157
  export function milestoneText(
104
158
  milestone: AdvisoryMilestone,
105
159
  sessionId: string,
106
160
  threshold: number,
107
161
  toolName: string,
108
162
  brandNoun = DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
163
+ guidance: GuidanceProfile = DEFAULT_GUIDANCE_PROFILE,
109
164
  ): string {
110
165
  const percent = Math.round(threshold * 100);
111
166
  const prefix = `[${contextBrand(brandNoun)} milestone ${milestone}; session ${sessionId.slice(0, 16)}]`;
167
+ if (guidance === "curation" && milestone !== "urgent") {
168
+ return `${prefix} ${curationText(milestone, percent, toolName)}`;
169
+ }
112
170
  if (milestone === "notice") {
113
171
  return `${prefix} Context pressure has crossed ${percent}%. Automatic folding is available. ` +
114
172
  `Inspect candidates exactly with ${toolName} {"action":"status"}.`;
@@ -126,6 +184,34 @@ export function milestoneText(
126
184
  "or the provider request is aborted before transmission.";
127
185
  }
128
186
 
187
+ /**
188
+ * The surfacing carrier. It rides the same ephemeral tail channel as the live
189
+ * advisory: appended after the stable prefix, never durable, never a mutation of
190
+ * the projection, and cleared whenever the next context event is built.
191
+ */
192
+ export function surfacingText(input: {
193
+ suggestions: readonly SurfacingSuggestion[];
194
+ brandNoun?: string;
195
+ }): string | null {
196
+ if (!input.suggestions.length) return null;
197
+ const brand = contextBrand(input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN);
198
+ const lines = input.suggestions.map((suggestion, index) => [
199
+ `${index + 1}. ${suggestion.id}${suggestion.depth ? ` (depth ${suggestion.depth})` : ""}: ${suggestion.text}`,
200
+ ...(suggestion.alternateRoute ? [` peek (ephemeral read): ${suggestion.alternateRoute}`] : []),
201
+ ` expand (durable restore): ${suggestion.route}`,
202
+ ].join("\n"));
203
+ return boundReceiptText(
204
+ [
205
+ `[${brand} suggestions] ${input.suggestions.length} collapsed span(s) look relevant to the current ` +
206
+ "task. Peek reads one back ephemerally at any depth; expand restores it in place, outermost first. " +
207
+ "Ignoring them is a valid answer.",
208
+ ...lines,
209
+ ].join("\n"),
210
+ 2_048,
211
+ `[${brand} suggestions] Relevant collapsed spans are unavailable this pass.`,
212
+ );
213
+ }
214
+
129
215
  export function liveAdvisoryText(input: {
130
216
  milestone: AdvisoryMilestone;
131
217
  ratio: number;
@@ -15,6 +15,14 @@ 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
+
18
26
  export function ownValue(value: unknown, key: string): unknown {
19
27
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
20
28
  const descriptor = Object.getOwnPropertyDescriptor(value, key);