pi-fold 0.1.1 → 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 +37 -6
- package/extensions/active-context.ts +2158 -255
- package/extensions/lib/advisory.ts +90 -19
- package/extensions/lib/canonical.ts +12 -0
- package/extensions/lib/curation.ts +437 -0
- package/extensions/lib/folding.ts +388 -21
- package/extensions/lib/instrumentation.ts +333 -0
- package/extensions/lib/measurement.ts +111 -0
- package/extensions/lib/persistence.ts +117 -2
- package/extensions/lib/policy.ts +314 -1
- package/extensions/lib/scheduling.ts +923 -0
- package/extensions/lib/selection.ts +437 -12
- package/extensions/lib/surfacing.ts +23 -6
- package/extensions/lib/tool-surface.ts +50 -4
- package/extensions/lib/transcript.ts +58 -4
- package/package.json +2 -2
|
@@ -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,17 +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
|
}) {
|
|
19
31
|
const peekGuidance = input.allowedActions.includes("peek")
|
|
20
|
-
?
|
|
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."
|
|
21
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.";
|
|
22
48
|
return {
|
|
23
49
|
name: input.name,
|
|
24
50
|
label: input.label,
|
|
25
51
|
description: input.fullSurface
|
|
26
|
-
? `Page, peek, fold, expand, refold, or
|
|
27
|
-
: `Use only the configured active-context actions: ${input.allowedActions.join(", ")}.${peekGuidance} 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.`,
|
|
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.`,
|
|
28
54
|
parameters: {
|
|
29
55
|
type: "object",
|
|
30
56
|
additionalProperties: false,
|
|
@@ -33,6 +59,21 @@ export function buildActiveContextTool(input: {
|
|
|
33
59
|
action: { type: "string", enum: input.allowedActions },
|
|
34
60
|
ids: { type: "array", minItems: 1, maxItems: 64, items: { type: "string", minLength: 1 } },
|
|
35
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
|
+
},
|
|
36
77
|
brief: {
|
|
37
78
|
type: "string",
|
|
38
79
|
minLength: 1,
|
|
@@ -40,8 +81,13 @@ export function buildActiveContextTool(input: {
|
|
|
40
81
|
description: "Factual fold brief; keep it at most 1000 characters to stay below the hard 1200-character limit.",
|
|
41
82
|
},
|
|
42
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
|
+
},
|
|
43
89
|
limit: { type: "integer", minimum: 1, maximum: 100 },
|
|
44
|
-
detail: { type: "string", enum: [
|
|
90
|
+
detail: { type: "string", enum: [...input.statusDetails] },
|
|
45
91
|
},
|
|
46
92
|
},
|
|
47
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": "0.
|
|
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",
|