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 +63 -4
- package/extensions/active-context.ts +179 -12
- package/extensions/lib/advisory.ts +92 -6
- package/extensions/lib/canonical.ts +8 -0
- package/extensions/lib/folding.ts +88 -9
- package/extensions/lib/persistence.ts +43 -2
- package/extensions/lib/policy.ts +66 -3
- package/extensions/lib/surfacing.ts +332 -0
- package/extensions/lib/tool-surface.ts +6 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# pi-fold
|
|
2
2
|
|
|
3
|
-
pi-fold
|
|
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
|

|
|
6
8
|
|
|
7
|
-
*A live session at 5.5× speed: the autonomous ladder folds a completed read batch mid-conversation
|
|
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1488
|
-
|
|
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
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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);
|