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.
- package/README.md +97 -7
- package/extensions/active-context.ts +2252 -182
- package/extensions/lib/advisory.ts +171 -14
- package/extensions/lib/canonical.ts +20 -0
- package/extensions/lib/curation.ts +437 -0
- package/extensions/lib/folding.ts +468 -22
- package/extensions/lib/instrumentation.ts +333 -0
- package/extensions/lib/measurement.ts +111 -0
- package/extensions/lib/persistence.ts +158 -2
- package/extensions/lib/policy.ts +380 -4
- package/extensions/lib/scheduling.ts +923 -0
- package/extensions/lib/selection.ts +437 -12
- package/extensions/lib/surfacing.ts +349 -0
- package/extensions/lib/tool-surface.ts +52 -3
- package/extensions/lib/transcript.ts +58 -4
- package/package.json +2 -2
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { stableStringify } from "../json.ts";
|
|
2
|
+
import {
|
|
3
|
+
clone,
|
|
4
|
+
contentText,
|
|
5
|
+
messageRole,
|
|
6
|
+
} from "./canonical.ts";
|
|
7
|
+
import {
|
|
8
|
+
foldDepth,
|
|
9
|
+
orderedFoldTree,
|
|
10
|
+
} from "./folding.ts";
|
|
11
|
+
import {
|
|
12
|
+
foldInterval,
|
|
13
|
+
refsProtected,
|
|
14
|
+
toolRefsProtected,
|
|
15
|
+
} from "./measurement.ts";
|
|
16
|
+
import {
|
|
17
|
+
flattenFoldRefs,
|
|
18
|
+
foldBrief,
|
|
19
|
+
} from "./persistence.ts";
|
|
20
|
+
import {
|
|
21
|
+
SURFACING_CHAR_BUDGET,
|
|
22
|
+
SURFACING_COOLDOWN_ORDINALS,
|
|
23
|
+
SURFACING_DEPTH_WEIGHT,
|
|
24
|
+
SURFACING_LEXICAL_WEIGHT,
|
|
25
|
+
SURFACING_MAX_DEPTH,
|
|
26
|
+
SURFACING_MAX_LOG_RECORDS,
|
|
27
|
+
SURFACING_MAX_TASK_CHARS,
|
|
28
|
+
SURFACING_MAX_TEXT_CHARS,
|
|
29
|
+
SURFACING_MIN_SCORE,
|
|
30
|
+
SURFACING_OUTCOME_WINDOW_ORDINALS,
|
|
31
|
+
SURFACING_RECENCY_WEIGHT,
|
|
32
|
+
SURFACING_RECENT_TASK_SPANS,
|
|
33
|
+
SURFACING_SOURCE_ID,
|
|
34
|
+
SURFACING_TOP_K,
|
|
35
|
+
} from "./policy.ts";
|
|
36
|
+
import type {
|
|
37
|
+
ActiveContextSnapshot,
|
|
38
|
+
ActiveContextState,
|
|
39
|
+
SuggestionSource,
|
|
40
|
+
SuggestionSourceInput,
|
|
41
|
+
SurfacingCandidate,
|
|
42
|
+
SurfacingRecord,
|
|
43
|
+
SurfacingSuggestion,
|
|
44
|
+
} from "./policy.ts";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Deterministic, model-free surfacing. Every input is either the transcript's own
|
|
48
|
+
* ordering or the durable log: no wall clock, no randomness, no embedding call, so
|
|
49
|
+
* the same session state always produces the same slate in the same order.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
const SURFACING_STOPWORDS: ReadonlySet<string> = new Set([
|
|
53
|
+
"about", "after", "again", "against", "already", "also", "another", "because", "been",
|
|
54
|
+
"before", "being", "between", "both", "came", "come", "could", "does", "doing", "done",
|
|
55
|
+
"down", "during", "each", "either", "else", "even", "ever", "every", "exact", "exactly",
|
|
56
|
+
"first", "from", "have", "having", "here", "into", "just", "keep", "kept", "like", "made",
|
|
57
|
+
"make", "many", "more", "most", "much", "must", "need", "next", "none", "only", "other",
|
|
58
|
+
"over", "same", "should", "since", "some", "such", "take", "than", "that", "their", "them",
|
|
59
|
+
"then", "there", "these", "they", "this", "those", "through", "under", "until", "upon",
|
|
60
|
+
"very", "want", "were", "what", "when", "where", "which", "while", "will", "with", "would",
|
|
61
|
+
"your",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export function surfacingTokens(value: string): string[] {
|
|
65
|
+
const tokens: string[] = [];
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
for (const raw of value.toLowerCase().split(/[^a-z0-9_]+/)) {
|
|
68
|
+
const token = raw.replace(/^_+|_+$/g, "");
|
|
69
|
+
if (token.length < 4 || SURFACING_STOPWORDS.has(token) || seen.has(token)) continue;
|
|
70
|
+
seen.add(token);
|
|
71
|
+
tokens.push(token);
|
|
72
|
+
}
|
|
73
|
+
return tokens;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The most recent user/assistant text spans: what the session is working on right now. */
|
|
77
|
+
export function recentTaskText(
|
|
78
|
+
snapshot: Pick<ActiveContextSnapshot, "messages">,
|
|
79
|
+
spans = SURFACING_RECENT_TASK_SPANS,
|
|
80
|
+
): string[] {
|
|
81
|
+
const collected: string[] = [];
|
|
82
|
+
let chars = 0;
|
|
83
|
+
for (let index = snapshot.messages.length - 1; index >= 0 && collected.length < spans; index -= 1) {
|
|
84
|
+
const message = snapshot.messages[index];
|
|
85
|
+
const role = messageRole(message);
|
|
86
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
87
|
+
const text = contentText(message).trim();
|
|
88
|
+
if (!text) continue;
|
|
89
|
+
const bounded = text.slice(0, SURFACING_MAX_TASK_CHARS - chars);
|
|
90
|
+
if (!bounded) break;
|
|
91
|
+
collected.push(bounded);
|
|
92
|
+
chars += bounded.length;
|
|
93
|
+
if (chars >= SURFACING_MAX_TASK_CHARS) break;
|
|
94
|
+
}
|
|
95
|
+
return collected.reverse();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function taskTokenSet(
|
|
99
|
+
snapshot: Pick<ActiveContextSnapshot, "messages">,
|
|
100
|
+
spans = SURFACING_RECENT_TASK_SPANS,
|
|
101
|
+
): ReadonlySet<string> {
|
|
102
|
+
return new Set(surfacingTokens(recentTaskText(snapshot, spans).join(" ")));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Share of the candidate's own vocabulary the recent task text also uses. */
|
|
106
|
+
export function lexicalOverlap(candidateText: string, taskTokens: ReadonlySet<string>): number {
|
|
107
|
+
const tokens = surfacingTokens(candidateText);
|
|
108
|
+
if (!tokens.length || !taskTokens.size) return 0;
|
|
109
|
+
let matched = 0;
|
|
110
|
+
for (const token of tokens) if (taskTokens.has(token)) matched += 1;
|
|
111
|
+
return matched / tokens.length;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function roundedScore(value: number): number {
|
|
115
|
+
return Math.round(Math.max(0, Math.min(1, value)) * 10_000) / 10_000;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function scoreSurfacingCandidate(
|
|
119
|
+
candidate: SurfacingCandidate,
|
|
120
|
+
taskTokens: ReadonlySet<string>,
|
|
121
|
+
positionCeiling: number,
|
|
122
|
+
): number {
|
|
123
|
+
const lexical = lexicalOverlap(candidate.text, taskTokens);
|
|
124
|
+
const position = candidate.position ?? 0;
|
|
125
|
+
const recency = positionCeiling > 0 ? Math.max(0, Math.min(1, position / positionCeiling)) : 0;
|
|
126
|
+
const depth = Math.min(Math.max(0, candidate.depth ?? 0), SURFACING_MAX_DEPTH) / SURFACING_MAX_DEPTH;
|
|
127
|
+
return roundedScore(
|
|
128
|
+
SURFACING_LEXICAL_WEIGHT * lexical +
|
|
129
|
+
SURFACING_RECENCY_WEIGHT * recency +
|
|
130
|
+
SURFACING_DEPTH_WEIGHT * depth,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Ids suppressed by hysteresis: suggested recently, not acted on, still inside the cooldown. */
|
|
135
|
+
export function cooledDownIds(
|
|
136
|
+
log: readonly SurfacingRecord[],
|
|
137
|
+
ordinal: number,
|
|
138
|
+
cooldown = SURFACING_COOLDOWN_ORDINALS,
|
|
139
|
+
): Set<string> {
|
|
140
|
+
const suppressed = new Set<string>();
|
|
141
|
+
for (const record of log) {
|
|
142
|
+
if (record.outcome === "accept" || record.ordinal === ordinal) continue;
|
|
143
|
+
if (ordinal < record.ordinal + cooldown) suppressed.add(surfacingKey(record.source, record.id));
|
|
144
|
+
}
|
|
145
|
+
return suppressed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function surfacingKey(source: string, id: string): string {
|
|
149
|
+
return `${source}::${id}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function rankSurfacingCandidates(input: {
|
|
153
|
+
candidates: readonly SurfacingCandidate[];
|
|
154
|
+
taskTokens: ReadonlySet<string>;
|
|
155
|
+
positionCeiling: number;
|
|
156
|
+
suppressed?: ReadonlySet<string>;
|
|
157
|
+
topK?: number;
|
|
158
|
+
minimumScore?: number;
|
|
159
|
+
charBudget?: number;
|
|
160
|
+
}): SurfacingSuggestion[] {
|
|
161
|
+
const topK = input.topK ?? SURFACING_TOP_K;
|
|
162
|
+
const minimumScore = input.minimumScore ?? SURFACING_MIN_SCORE;
|
|
163
|
+
const charBudget = input.charBudget ?? SURFACING_CHAR_BUDGET;
|
|
164
|
+
const scored = input.candidates
|
|
165
|
+
.map((candidate) => ({
|
|
166
|
+
...candidate,
|
|
167
|
+
text: candidate.text.replace(/\s+/g, " ").trim().slice(0, SURFACING_MAX_TEXT_CHARS),
|
|
168
|
+
score: scoreSurfacingCandidate(candidate, input.taskTokens, input.positionCeiling),
|
|
169
|
+
}))
|
|
170
|
+
.filter((candidate) => candidate.text && candidate.score >= minimumScore &&
|
|
171
|
+
!input.suppressed?.has(surfacingKey(candidate.source, candidate.id)))
|
|
172
|
+
// Ties break on source then id so the slate never depends on candidate arrival order.
|
|
173
|
+
.sort((left, right) => right.score - left.score ||
|
|
174
|
+
left.source.localeCompare(right.source) || left.id.localeCompare(right.id));
|
|
175
|
+
const selected: SurfacingSuggestion[] = [];
|
|
176
|
+
let used = 0;
|
|
177
|
+
for (const candidate of scored) {
|
|
178
|
+
if (selected.length >= topK) break;
|
|
179
|
+
const cost = candidate.text.length + candidate.id.length + candidate.route.length +
|
|
180
|
+
(candidate.alternateRoute?.length ?? 0);
|
|
181
|
+
if (used + cost > charBudget) continue;
|
|
182
|
+
used += cost;
|
|
183
|
+
selected.push(candidate);
|
|
184
|
+
}
|
|
185
|
+
return selected;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The session's monotonic transcript ordinal; recency and cooldown are measured in these. */
|
|
189
|
+
export function surfacingOrdinal(snapshot: Pick<ActiveContextSnapshot, "mapped">): number {
|
|
190
|
+
return snapshot.mapped.length;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function surfacingLog(state: Pick<ActiveContextState, "surfacing">): SurfacingRecord[] {
|
|
194
|
+
return state.surfacing ? clone(state.surfacing) : [];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Property ORDER is load-bearing: the state digest is a stable stringify, so a
|
|
199
|
+
* `surfacing` key assigned onto a state that did not already carry one would land
|
|
200
|
+
* after `pendingMarks`/`advisory`/`prepared` and replay as digest drift. Rebuild the
|
|
201
|
+
* tail of the record in the same order `parseActiveContextState` produces.
|
|
202
|
+
*/
|
|
203
|
+
export function withSurfacingLog(
|
|
204
|
+
state: ActiveContextState,
|
|
205
|
+
log: readonly SurfacingRecord[],
|
|
206
|
+
): ActiveContextState {
|
|
207
|
+
const bounded = log.slice(Math.max(0, log.length - SURFACING_MAX_LOG_RECORDS));
|
|
208
|
+
const head = { ...state };
|
|
209
|
+
delete head.surfacing;
|
|
210
|
+
delete head.pendingMarks;
|
|
211
|
+
delete head.advisory;
|
|
212
|
+
delete head.prepared;
|
|
213
|
+
return {
|
|
214
|
+
...head,
|
|
215
|
+
...(bounded.length ? { surfacing: clone(bounded) as SurfacingRecord[] } : {}),
|
|
216
|
+
...(state.pendingMarks?.length ? { pendingMarks: clone(state.pendingMarks) } : {}),
|
|
217
|
+
...(state.advisory ? { advisory: clone(state.advisory) } : {}),
|
|
218
|
+
...(state.prepared ? { prepared: clone(state.prepared) } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A shown suggestion the agent never peeked or expanded inside the outcome window is a reject. */
|
|
223
|
+
export function resolvedSurfacingLog(
|
|
224
|
+
log: readonly SurfacingRecord[],
|
|
225
|
+
ordinal: number,
|
|
226
|
+
outcomeWindow = SURFACING_OUTCOME_WINDOW_ORDINALS,
|
|
227
|
+
): SurfacingRecord[] {
|
|
228
|
+
return log.map((record) => record.outcome === "shown" && ordinal > record.ordinal + outcomeWindow
|
|
229
|
+
? { ...record, outcome: "reject" as const }
|
|
230
|
+
: { ...record });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Marks a suggestion accepted when the agent peeks or expands it inside the outcome window. */
|
|
234
|
+
export function acceptSurfacingSuggestion(
|
|
235
|
+
state: ActiveContextState,
|
|
236
|
+
id: string,
|
|
237
|
+
ordinal: number,
|
|
238
|
+
outcomeWindow = SURFACING_OUTCOME_WINDOW_ORDINALS,
|
|
239
|
+
): ActiveContextState {
|
|
240
|
+
const log = surfacingLog(state);
|
|
241
|
+
let changed = false;
|
|
242
|
+
const next = log.map((record) => {
|
|
243
|
+
if (record.id !== id || record.outcome !== "shown" || ordinal > record.ordinal + outcomeWindow) return record;
|
|
244
|
+
changed = true;
|
|
245
|
+
return { ...record, outcome: "accept" as const };
|
|
246
|
+
});
|
|
247
|
+
return changed ? withSurfacingLog(state, next) : state;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The built-in source: every currently collapsed fold, nested ones included. */
|
|
251
|
+
export function foldBriefCandidates(input: SuggestionSourceInput): SurfacingCandidate[] {
|
|
252
|
+
const { state, snapshot, toolName } = input;
|
|
253
|
+
const collapsed = orderedFoldTree(state, snapshot).filter((fold) => !state.expanded.includes(fold.id));
|
|
254
|
+
return collapsed.flatMap((fold) => {
|
|
255
|
+
const interval = foldInterval(fold, state, snapshot);
|
|
256
|
+
if (!interval) return [];
|
|
257
|
+
const refs = flattenFoldRefs(fold, state);
|
|
258
|
+
const blocked = fold.kind === "tool-result"
|
|
259
|
+
? toolRefsProtected(refs, state, snapshot)
|
|
260
|
+
: refsProtected(refs, state, snapshot);
|
|
261
|
+
if (blocked) return [];
|
|
262
|
+
return [{
|
|
263
|
+
source: SURFACING_SOURCE_ID,
|
|
264
|
+
id: fold.id,
|
|
265
|
+
text: foldBrief(fold, state),
|
|
266
|
+
route: `${toolName} {"action":"expand","id":"${fold.id}"}`,
|
|
267
|
+
alternateRoute: `${toolName} {"action":"peek","id":"${fold.id}"}`,
|
|
268
|
+
position: interval.end,
|
|
269
|
+
depth: foldDepth(state, fold),
|
|
270
|
+
}];
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export const FOLD_BRIEF_SUGGESTION_SOURCE: SuggestionSource = Object.freeze({
|
|
275
|
+
id: SURFACING_SOURCE_ID,
|
|
276
|
+
candidates: foldBriefCandidates,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
export function collectSuggestionCandidates(
|
|
280
|
+
sources: readonly SuggestionSource[],
|
|
281
|
+
input: SuggestionSourceInput,
|
|
282
|
+
): SurfacingCandidate[] {
|
|
283
|
+
return sources.flatMap((source) => {
|
|
284
|
+
let produced: unknown;
|
|
285
|
+
// One faulty external source may never take the carrier, the projection, or the
|
|
286
|
+
// ladder down with it; it simply contributes nothing this pass.
|
|
287
|
+
try { produced = source.candidates(input); }
|
|
288
|
+
catch { return []; }
|
|
289
|
+
if (!Array.isArray(produced)) return [];
|
|
290
|
+
return produced.flatMap((candidate) => validSurfacingCandidate(candidate, source.id)
|
|
291
|
+
? [{ ...candidate, source: source.id }]
|
|
292
|
+
: []);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function validSurfacingCandidate(value: unknown, sourceId: string): value is SurfacingCandidate {
|
|
297
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
298
|
+
const candidate = value as Partial<SurfacingCandidate>;
|
|
299
|
+
return Boolean(sourceId) && typeof candidate.id === "string" && Boolean(candidate.id) &&
|
|
300
|
+
typeof candidate.text === "string" && Boolean(candidate.text.trim()) &&
|
|
301
|
+
typeof candidate.route === "string" && Boolean(candidate.route) &&
|
|
302
|
+
(candidate.alternateRoute === undefined || typeof candidate.alternateRoute === "string") &&
|
|
303
|
+
(candidate.position === undefined || Number.isFinite(candidate.position)) &&
|
|
304
|
+
(candidate.depth === undefined || Number.isFinite(candidate.depth));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One pass of the selector: resolve outstanding outcomes, rank what is eligible now,
|
|
309
|
+
* and log what will be shown. Pure: the caller owns persistence and rendering.
|
|
310
|
+
*/
|
|
311
|
+
export function updateSurfacing(input: {
|
|
312
|
+
state: ActiveContextState;
|
|
313
|
+
snapshot: ActiveContextSnapshot;
|
|
314
|
+
sources: readonly SuggestionSource[];
|
|
315
|
+
toolName: string;
|
|
316
|
+
}): { state: ActiveContextState; suggestions: SurfacingSuggestion[] } {
|
|
317
|
+
const { state, snapshot } = input;
|
|
318
|
+
const ordinal = surfacingOrdinal(snapshot);
|
|
319
|
+
const resolved = resolvedSurfacingLog(surfacingLog(state), ordinal);
|
|
320
|
+
const shownAtOrdinal = new Set(resolved
|
|
321
|
+
.filter((record) => record.ordinal === ordinal)
|
|
322
|
+
.map((record) => surfacingKey(record.source, record.id)));
|
|
323
|
+
const suggestions = rankSurfacingCandidates({
|
|
324
|
+
candidates: collectSuggestionCandidates(input.sources, {
|
|
325
|
+
state,
|
|
326
|
+
snapshot,
|
|
327
|
+
toolName: input.toolName,
|
|
328
|
+
}),
|
|
329
|
+
taskTokens: taskTokenSet(snapshot),
|
|
330
|
+
positionCeiling: Math.max(0, snapshot.mapped.length - 1),
|
|
331
|
+
suppressed: cooledDownIds(resolved, ordinal),
|
|
332
|
+
});
|
|
333
|
+
// Re-projecting the same ordinal re-renders the same slate; it is one showing,
|
|
334
|
+
// so it neither duplicates a log record nor trips its own cooldown.
|
|
335
|
+
const appended = suggestions.flatMap((suggestion) =>
|
|
336
|
+
shownAtOrdinal.has(surfacingKey(suggestion.source, suggestion.id))
|
|
337
|
+
? []
|
|
338
|
+
: [{
|
|
339
|
+
source: suggestion.source,
|
|
340
|
+
id: suggestion.id,
|
|
341
|
+
score: suggestion.score,
|
|
342
|
+
ordinal,
|
|
343
|
+
outcome: "shown" as const,
|
|
344
|
+
}]);
|
|
345
|
+
const log = [...resolved, ...appended];
|
|
346
|
+
const next = withSurfacingLog(state, log);
|
|
347
|
+
const unchanged = stableStringify(state.surfacing ?? null) === stableStringify(next.surfacing ?? null);
|
|
348
|
+
return { state: unchanged ? state : next, suggestions };
|
|
349
|
+
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wording rule for everything in this file: describe units of WORK, never units of
|
|
3
|
+
* conversation. Text that presupposes turn-ending replies ("after the reply",
|
|
4
|
+
* "between tasks") tells a single-turn staged agent that a reply is the natural next
|
|
5
|
+
* move, and it rides in the tool surface of every request. It is the top unproven
|
|
6
|
+
* suspect for the iteration-2 phase flips, and saying the true thing instead costs
|
|
7
|
+
* nothing: the next model call that reads a result, a natural boundary in the work.
|
|
8
|
+
*/
|
|
9
|
+
|
|
1
10
|
type ToolHandler = (
|
|
2
11
|
toolCallId: string,
|
|
3
12
|
params: Record<string, unknown>,
|
|
@@ -14,14 +23,34 @@ export function buildActiveContextTool(input: {
|
|
|
14
23
|
allowedActions: readonly string[];
|
|
15
24
|
fullSurface: boolean;
|
|
16
25
|
maxBriefChars: number;
|
|
26
|
+
statusDetails: readonly string[];
|
|
27
|
+
minPeekSliceBytes: number;
|
|
28
|
+
defaultPeekBytes: number;
|
|
17
29
|
handler: ToolHandler;
|
|
18
30
|
}) {
|
|
31
|
+
const peekGuidance = input.allowedActions.includes("peek")
|
|
32
|
+
? ` Peek is the point read: it returns one fold's index view -- every nested fold's brief in full, plus the head and tail of the raw source with the omitted middle stated -- at any depth, ancestors still collapsed, changing nothing. It returns at most ${input.defaultPeekBytes} bytes unless you widen it with bytes, and it stays in the window like any other tool result. Expand is the durable in-place restoration.`
|
|
33
|
+
: "";
|
|
34
|
+
const correctionGuidance = input.allowedActions.includes("rebrief")
|
|
35
|
+
? " Curation is correctable: rebrief replaces a fold's brief, and reboundary with ids re-cuts a span into exactly one fold, which merges adjacent folds when the span covers several and splits one when the span sits inside it. Reboundary with a single id returns that fold's span to raw."
|
|
36
|
+
: "";
|
|
37
|
+
// Marking is yours, folding is the runtime's. The epoch guidance says how marks
|
|
38
|
+
// behave and stops there: there is no agent-callable commit verb to describe.
|
|
39
|
+
const epochGuidance = input.allowedActions.includes("unmark")
|
|
40
|
+
? " Fold scheduling is epoch mode: fold records pending marks and moves no context bytes, and the runtime applies every pending mark in one rewrite when the fold event fires. Mark SEVERAL spans in one call: marks carries several {ids, brief} pairs, and one call answers with your whole picture, every span now held plus the unmarked remainder and what share of the non-fresh window it is. Between fold events nothing else in your context changes, so that result is where the picture lives. Mark freely as you work; when to fold is the runtime's to decide, and unmark withdraws a mark you no longer want."
|
|
41
|
+
: "";
|
|
42
|
+
// The standing facts about how this runtime behaves. They belong HERE, in the tool
|
|
43
|
+
// surface, because the tool surface sits in the stable prefix: it is cached from the
|
|
44
|
+
// first request and costs nothing per request thereafter, forever. The same sentences
|
|
45
|
+
// delivered as in-window carriers cost a cache break on the pass that renders them
|
|
46
|
+
// and every pass that displaces them. Anything always true is said once, here.
|
|
47
|
+
const standingGuidance = " Folding is automatic, lossless and recoverable: the runtime folds stale spans on its own when the window needs room, nothing is ever discarded, and every folded span keeps its exact source. You are never asked to make room and no fold is ever announced in advance. Work normally and let it happen.";
|
|
19
48
|
return {
|
|
20
49
|
name: input.name,
|
|
21
50
|
label: input.label,
|
|
22
51
|
description: input.fullSurface
|
|
23
|
-
?
|
|
24
|
-
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}
|
|
52
|
+
? `Page, peek, fold, expand, refold, protect, rebrief, or re-cut exact Pi active-context evidence.${peekGuidance}${correctionGuidance}${epochGuidance}${standingGuidance} Mutations persist immediately and affect the next model call inside the same continuing turn; no turn boundary is required. Supplied fold briefs have a hard 1200-character maximum.`
|
|
53
|
+
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance}${correctionGuidance}${epochGuidance}${standingGuidance} Call fold only by copying the exact eligibleChapter.action returned by status; if status has no eligibleChapter, continue the task without folding. Supplied fold briefs have a hard 1200-character maximum.`,
|
|
25
54
|
parameters: {
|
|
26
55
|
type: "object",
|
|
27
56
|
additionalProperties: false,
|
|
@@ -30,6 +59,21 @@ export function buildActiveContextTool(input: {
|
|
|
30
59
|
action: { type: "string", enum: input.allowedActions },
|
|
31
60
|
ids: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1 } },
|
|
32
61
|
id: { type: "string", minLength: 1 },
|
|
62
|
+
marks: {
|
|
63
|
+
type: "array",
|
|
64
|
+
minItems: 1,
|
|
65
|
+
maxItems: 64,
|
|
66
|
+
description: "Fold only: several spans with their own briefs in one call, which is the shape to prefer. An invalid or overlapping span is corrected to the nearest valid edge and the correction is reported back.",
|
|
67
|
+
items: {
|
|
68
|
+
type: "object",
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
required: ["ids"],
|
|
71
|
+
properties: {
|
|
72
|
+
ids: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1 } },
|
|
73
|
+
brief: { type: "string", minLength: 1, maxLength: input.maxBriefChars },
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
33
77
|
brief: {
|
|
34
78
|
type: "string",
|
|
35
79
|
minLength: 1,
|
|
@@ -37,8 +81,13 @@ export function buildActiveContextTool(input: {
|
|
|
37
81
|
description: "Factual fold brief; keep it at most 1000 characters to stay below the hard 1200-character limit.",
|
|
38
82
|
},
|
|
39
83
|
offset: { type: "integer", minimum: 0 },
|
|
84
|
+
bytes: {
|
|
85
|
+
type: "integer",
|
|
86
|
+
minimum: input.minPeekSliceBytes,
|
|
87
|
+
description: `Peek only: bytes of exact source to return, widening or narrowing the ${input.defaultPeekBytes}-byte default.`,
|
|
88
|
+
},
|
|
40
89
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
41
|
-
detail: { type: "string", enum: [
|
|
90
|
+
detail: { type: "string", enum: [...input.statusDetails] },
|
|
42
91
|
},
|
|
43
92
|
},
|
|
44
93
|
execute: input.handler,
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
evidenceRef,
|
|
4
4
|
evidenceValue,
|
|
5
5
|
isPlainRecord,
|
|
6
|
+
objectRefKey,
|
|
6
7
|
sha256Text,
|
|
7
8
|
stableStringify,
|
|
8
9
|
} from "../json.ts";
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
|
|
21
22
|
DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
|
|
22
23
|
DEFAULT_CONTEXT_WINDOW,
|
|
24
|
+
READ_ONLY_CONTEXT_ACTIONS_DEFAULT,
|
|
23
25
|
READ_ONLY_TOOLS_DEFAULT,
|
|
24
26
|
} from "./policy.ts";
|
|
25
27
|
import type {
|
|
@@ -69,15 +71,29 @@ export function leadingCompactionContinuation(messages: unknown[]): CompleteTurn
|
|
|
69
71
|
return end > 1 && terminalAssistant(messages[end - 1]) ? { start: 1, end } : null;
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/**
|
|
75
|
+
* The exact argument surface each read-only active-context action may carry. A call
|
|
76
|
+
* outside this surface is not classified read-only, so its batch never folds.
|
|
77
|
+
*/
|
|
78
|
+
export const READ_ONLY_CONTEXT_ACTION_ARGUMENTS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
79
|
+
status: Object.freeze(["action", "detail", "offset", "limit"]),
|
|
80
|
+
peek: Object.freeze(["action", "id"]),
|
|
81
|
+
});
|
|
82
|
+
|
|
72
83
|
export function isReadOnlyContextTool(
|
|
73
84
|
name: string,
|
|
74
85
|
args?: unknown,
|
|
75
86
|
toolName = DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
|
|
76
87
|
readOnlyTools: ReadonlySet<string> = READ_ONLY_TOOLS_DEFAULT,
|
|
88
|
+
readOnlyContextActions: ReadonlySet<string> = READ_ONLY_CONTEXT_ACTIONS_DEFAULT,
|
|
77
89
|
): boolean {
|
|
78
90
|
if (readOnlyTools.has(name)) return true;
|
|
79
|
-
if (name !== toolName || !isPlainRecord(args)
|
|
80
|
-
const
|
|
91
|
+
if (name !== toolName || !isPlainRecord(args)) return false;
|
|
92
|
+
const action = ownValue(args, "action");
|
|
93
|
+
if (typeof action !== "string" || !readOnlyContextActions.has(action)) return false;
|
|
94
|
+
const permitted = ownValue(READ_ONLY_CONTEXT_ACTION_ARGUMENTS, action);
|
|
95
|
+
if (!Array.isArray(permitted)) return false;
|
|
96
|
+
const allowed = new Set(permitted as string[]);
|
|
81
97
|
const keys = Reflect.ownKeys(args);
|
|
82
98
|
for (let index = 0; index < keys.length; index += 1) {
|
|
83
99
|
if (typeof keys[index] !== "string" || !allowed.has(keys[index] as string)) return false;
|
|
@@ -110,6 +126,7 @@ export function scanTurnToolBatches(
|
|
|
110
126
|
allowIncomplete = false,
|
|
111
127
|
toolName = DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
|
|
112
128
|
readOnlyTools: ReadonlySet<string> = READ_ONLY_TOOLS_DEFAULT,
|
|
129
|
+
readOnlyContextActions: ReadonlySet<string> = READ_ONLY_CONTEXT_ACTIONS_DEFAULT,
|
|
113
130
|
): ScannedToolBatches | null {
|
|
114
131
|
if (!Number.isSafeInteger(turn.start) || !Number.isSafeInteger(turn.end) ||
|
|
115
132
|
turn.start < 0 || turn.end > messages.length || turn.start >= turn.end) return null;
|
|
@@ -146,7 +163,9 @@ export function scanTurnToolBatches(
|
|
|
146
163
|
const id = ownValue(part, "id");
|
|
147
164
|
const name = ownValue(part, "name");
|
|
148
165
|
if (typeof id !== "string" || !id || typeof name !== "string" || !name ||
|
|
149
|
-
calls.has(id) || !isReadOnlyContextTool(
|
|
166
|
+
calls.has(id) || !isReadOnlyContextTool(
|
|
167
|
+
name, ownValue(part, "arguments"), toolName, readOnlyTools, readOnlyContextActions,
|
|
168
|
+
)) safe = false;
|
|
150
169
|
else calls.set(id, { id, name, assistantIndex: index });
|
|
151
170
|
}
|
|
152
171
|
for (let resultIndex = index + 1; resultIndex < end; resultIndex += 1) {
|
|
@@ -194,8 +213,11 @@ export function validateTurnToolBatch(
|
|
|
194
213
|
turn: CompleteTurn,
|
|
195
214
|
toolName = DEFAULT_ACTIVE_CONTEXT_TOOL_NAME,
|
|
196
215
|
readOnlyTools: ReadonlySet<string> = READ_ONLY_TOOLS_DEFAULT,
|
|
216
|
+
readOnlyContextActions: ReadonlySet<string> = READ_ONLY_CONTEXT_ACTIONS_DEFAULT,
|
|
197
217
|
): ValidatedToolBatch | null {
|
|
198
|
-
const scanned = scanTurnToolBatches(
|
|
218
|
+
const scanned = scanTurnToolBatches(
|
|
219
|
+
messages, turn, false, toolName, readOnlyTools, readOnlyContextActions,
|
|
220
|
+
);
|
|
199
221
|
return scanned && scanned.unsafeIndices.size === 0 ? { calls: scanned.calls } : null;
|
|
200
222
|
}
|
|
201
223
|
|
|
@@ -380,6 +402,7 @@ export function mapActiveContext(input: {
|
|
|
380
402
|
brandNoun?: string;
|
|
381
403
|
entryTypePrefix?: string;
|
|
382
404
|
readOnlyTools?: ReadonlySet<string>;
|
|
405
|
+
readOnlyContextActions?: ReadonlySet<string>;
|
|
383
406
|
contextWindow?: number;
|
|
384
407
|
}): ActiveContextSnapshot {
|
|
385
408
|
const policy = Object.freeze({ ...ACTIVE_CONTEXT_POLICY, ...(input.policy ?? {}) }) as typeof ACTIVE_CONTEXT_POLICY;
|
|
@@ -473,7 +496,38 @@ export function mapActiveContext(input: {
|
|
|
473
496
|
brandNoun: input.brandNoun ?? DEFAULT_ACTIVE_CONTEXT_BRAND_NOUN,
|
|
474
497
|
entryTypePrefix: input.entryTypePrefix ?? DEFAULT_ACTIVE_CONTEXT_ENTRY_TYPE_PREFIX,
|
|
475
498
|
readOnlyTools: input.readOnlyTools ?? READ_ONLY_TOOLS_DEFAULT,
|
|
499
|
+
readOnlyContextActions: input.readOnlyContextActions ?? READ_ONLY_CONTEXT_ACTIONS_DEFAULT,
|
|
476
500
|
contextWindow: reportedContextWindow ?? DEFAULT_CONTEXT_WINDOW,
|
|
477
501
|
windowSource: reportedContextWindow === null ? "fallback" : "reported",
|
|
478
502
|
};
|
|
479
503
|
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The evidence boundary of the CURRENT turn: the position of the last terminal
|
|
507
|
+
* assistant message. Everything after it was gathered by the excursion still in
|
|
508
|
+
* progress, which is exactly the evidence a commit must not fold.
|
|
509
|
+
*
|
|
510
|
+
* Measured 2026-08-06 (rep 8): nineteen folds landed between the last read result and
|
|
511
|
+
* the agent's next reply, rewriting the projection from 938k to 487k chars, so the
|
|
512
|
+
* agent answered from a window where its own just-gathered evidence had become
|
|
513
|
+
* placeholders. Freshness in bytes did not catch it; the boundary is a TURN.
|
|
514
|
+
*/
|
|
515
|
+
export function currentTurnBoundary(snapshot: Pick<ActiveContextSnapshot, "messages">): number {
|
|
516
|
+
let boundary = -1;
|
|
517
|
+
for (let index = 0; index < snapshot.messages.length; index += 1) {
|
|
518
|
+
if (terminalAssistant(snapshot.messages[index])) boundary = index;
|
|
519
|
+
}
|
|
520
|
+
return boundary;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Every tool-result evidence key produced since that boundary. */
|
|
524
|
+
export function currentTurnRefKeys(snapshot: ActiveContextSnapshot): Set<string> {
|
|
525
|
+
const boundary = currentTurnBoundary(snapshot);
|
|
526
|
+
const keys = new Set<string>();
|
|
527
|
+
for (const item of snapshot.mapped) {
|
|
528
|
+
if (item.index <= boundary || !item.ref) continue;
|
|
529
|
+
if (messageRole(item.message) !== "toolResult") continue;
|
|
530
|
+
keys.add(objectRefKey(item.ref));
|
|
531
|
+
}
|
|
532
|
+
return keys;
|
|
533
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-fold",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Lossless, agent-governed context folding for the Pi coding agent
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Lossless, agent-governed context folding for the Pi coding agent: only the oldest context changes; the fresh window stays untouched.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|