@astrosheep/pi-context 0.25.0 → 0.25.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/dist/build-info.json +2 -2
- package/dist/extension.js +224 -134
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/context-window.js +15 -0
- package/dist/src/context/prompts.js +4 -7
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +108 -60
- package/dist/src/context/runtime.js +8 -93
- package/dist/src/index.js +2 -2
- package/dist/src/notes/address.js +1 -1
- package/dist/src/notes/tools.js +3 -3
- package/dist/src/protocol.js +3 -5
- package/dist/test/agent-loop.test.js +3 -1
- package/dist/test/boot.integration.test.js +56 -4
- package/dist/test/helpers/extension.js +1 -2
- package/dist/test/notes.integration.test.js +1 -4
- package/dist/test/notes.test.js +2 -2
- package/dist/test/reset-lifecycle.test.js +199 -2
- package/docs/reset-lifecycle.md +57 -0
- package/package.json +1 -1
- package/src/context/boot.ts +68 -0
- package/src/context/context-window.ts +15 -0
- package/src/context/prompts.ts +4 -7
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +183 -56
- package/src/context/runtime.ts +9 -104
- package/src/index.ts +2 -2
- package/src/notes/address.ts +1 -1
- package/src/notes/tools.ts +3 -3
- package/src/protocol.ts +3 -6
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext, SessionBoundaryDraft, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { BOOT_TYPE, CONTINUATION, CONTINUATION_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
|
|
4
|
+
import { currentWindowId, isWindowBootEntry, isWindowMarker, previousWindowId, type WindowMarker } from "./context-window.js";
|
|
5
|
+
import { buildBootMessage, sendBoot, type IncompleteNotesNotifier } from "./boot.js";
|
|
6
|
+
|
|
7
|
+
export type ResetTailState = { readonly boot: boolean; readonly continuation: boolean };
|
|
8
|
+
|
|
9
|
+
/** Match the hidden continuation entry that carries the one reset message. */
|
|
10
|
+
export function isWindowContinuationEntry(entry: SessionEntry): boolean {
|
|
11
|
+
return entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The single continuation sender: the only reset prose persisted for a window. */
|
|
15
|
+
export function sendContinuation(pi: ExtensionAPI): void {
|
|
16
|
+
pi.sendMessage(
|
|
17
|
+
{ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
18
|
+
{ triggerTurn: false },
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The closed, ordered reset shape: marker, matching boot, continuation. This is the one
|
|
24
|
+
* source of the reset message and the one place that mints the new window identity.
|
|
25
|
+
*/
|
|
26
|
+
export function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
|
|
27
|
+
const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
28
|
+
const usedWindowIds = new Set(
|
|
29
|
+
ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
|
|
30
|
+
);
|
|
31
|
+
let windowId: string;
|
|
32
|
+
do {
|
|
33
|
+
windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
|
|
34
|
+
} while (usedWindowIds.has(windowId));
|
|
35
|
+
const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
|
|
36
|
+
return [
|
|
37
|
+
{ type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
|
|
38
|
+
{ type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
|
|
39
|
+
{ type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
40
|
+
] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Persist the marker and send both hidden reset messages; returns the new window id. */
|
|
44
|
+
export function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
|
|
45
|
+
const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
|
|
46
|
+
pi.appendEntry(marker.customType, marker.data);
|
|
47
|
+
pi.sendMessage(
|
|
48
|
+
{ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
|
|
49
|
+
{ triggerTurn: false },
|
|
50
|
+
);
|
|
51
|
+
pi.sendMessage(
|
|
52
|
+
{ customType: continuation.customType, content: continuation.content, display: continuation.display },
|
|
53
|
+
{ triggerTurn: false },
|
|
54
|
+
);
|
|
55
|
+
return boot.details.windowId;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Inspect the persisted tail of a reset marker. It reports which reset messages are present
|
|
60
|
+
* only while the tail stays repairable: metadata may follow the marker, but real conversation,
|
|
61
|
+
* a foreign message, a later marker, or a misordered/duplicate reset artifact refuses repair.
|
|
62
|
+
*/
|
|
63
|
+
export function inspectResetTail(ctx: ExtensionContext, markerId: string, windowId: string): ResetTailState | undefined {
|
|
64
|
+
const branch = ctx.sessionManager.getBranch();
|
|
65
|
+
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
66
|
+
if (markerIndex < 0) return undefined;
|
|
67
|
+
let boot = false;
|
|
68
|
+
let continuation = false;
|
|
69
|
+
for (const entry of branch.slice(markerIndex + 1)) {
|
|
70
|
+
if (isWindowBootEntry(entry, windowId)) {
|
|
71
|
+
// The boot is unique and must precede the continuation; a repeat or a late
|
|
72
|
+
// boot would move the provider boundary or misorder the reset shape.
|
|
73
|
+
if (boot || continuation) return undefined;
|
|
74
|
+
boot = true;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (isWindowContinuationEntry(entry)) {
|
|
78
|
+
if (continuation || !boot) return undefined;
|
|
79
|
+
continuation = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (isWindowMarker(entry) || entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary") {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { boot, continuation };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True once the marker's tail already carries its boot and continuation in a valid order. */
|
|
90
|
+
export function resetTailCommitted(ctx: ExtensionContext, markerId: string, windowId: string): boolean {
|
|
91
|
+
const tail = inspectResetTail(ctx, markerId, windowId);
|
|
92
|
+
return tail?.boot === true && tail.continuation === true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
|
|
96
|
+
export function repairResetTail(pi: ExtensionAPI, ctx: ExtensionContext, marker: WindowMarker, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
|
|
97
|
+
const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
|
|
98
|
+
if (!tail || (tail.boot && tail.continuation)) return;
|
|
99
|
+
if (!tail.boot) sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
|
|
100
|
+
if (!tail.continuation) sendContinuation(pi);
|
|
101
|
+
}
|
|
@@ -26,23 +26,161 @@ function isOverflowLike(message: AgentMessage, ctx: ExtensionContext): boolean {
|
|
|
26
26
|
(ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Reset-control state. The concern is split into two independent, explicitly typed axes so
|
|
31
|
+
* that no combination of independently combinable lifecycle booleans is legal by accident:
|
|
32
|
+
*
|
|
33
|
+
* - `request` is the single pending explicit wipe request ("none" or "explicit"). Repeated
|
|
34
|
+
* requests deduplicate while one is pending.
|
|
35
|
+
* - `overflow` is the bounded provider-overflow recovery chain:
|
|
36
|
+
* - "idle" no overflow failure is pending; recovery is available for a later chain.
|
|
37
|
+
* - "pending" an overflow failure is pending; recovery is still available.
|
|
38
|
+
* - "pending-spent" an overflow failure is pending, but recovery was already spent this chain.
|
|
39
|
+
* - "spent" recovery was spent and the failure is no longer pending.
|
|
40
|
+
*
|
|
41
|
+
* All eight combinations of the two axes are reachable (a request may arrive while an overflow
|
|
42
|
+
* chain is pending, spent, or settled), and each has a defined transition. There is no state
|
|
43
|
+
* whose fields silently contradict one another.
|
|
44
|
+
*/
|
|
45
|
+
export type ResetRequestPhase = "none" | "explicit";
|
|
46
|
+
export type ResetOverflowPhase = "idle" | "pending" | "pending-spent" | "spent";
|
|
47
|
+
|
|
48
|
+
export interface ResetControlState {
|
|
49
|
+
readonly request: ResetRequestPhase;
|
|
50
|
+
readonly overflow: ResetOverflowPhase;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function initialResetControl(): ResetControlState {
|
|
54
|
+
return { request: "none", overflow: "idle" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Facts for a completed turn. The three `get`-supplied fields are guards the adapter resolves
|
|
59
|
+
* lazily: the reducer reads each one only in the branch where the previous adapter consulted it,
|
|
60
|
+
* so policy resolution and pending-message probes keep their original call ordering.
|
|
61
|
+
*/
|
|
62
|
+
export interface ResetTurnEndFacts {
|
|
63
|
+
readonly aborted: boolean;
|
|
64
|
+
readonly overflow: boolean;
|
|
65
|
+
readonly failed: boolean;
|
|
66
|
+
readonly enabled: boolean;
|
|
67
|
+
readonly queued: boolean;
|
|
68
|
+
readonly automaticResetEnabled: boolean;
|
|
69
|
+
readonly thresholdDue: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Facts for the pre-settlement boundary; policy guards stay lazy for the same reason. */
|
|
73
|
+
export interface ResetBeforeSettleFacts {
|
|
74
|
+
readonly queued: boolean;
|
|
75
|
+
readonly enabled: boolean;
|
|
76
|
+
readonly automaticResetEnabled: boolean;
|
|
77
|
+
readonly aborted: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type ResetControlEvent =
|
|
81
|
+
| { readonly type: "request" }
|
|
82
|
+
| { readonly type: "turn_end"; readonly facts: ResetTurnEndFacts }
|
|
83
|
+
| { readonly type: "before_settle"; readonly facts: ResetBeforeSettleFacts }
|
|
84
|
+
| { readonly type: "settled" }
|
|
85
|
+
| { readonly type: "abort" }
|
|
86
|
+
| { readonly type: "clear" };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The requested effect of a transition. Effects are named, not performed: marker/boot/
|
|
90
|
+
* continuation writes, continuation requests, and UI notices stay in the adapter.
|
|
91
|
+
*
|
|
92
|
+
* - "none" keep any return value to the drafts already collected for this event.
|
|
93
|
+
* - "requested" a new explicit reset request was recorded.
|
|
94
|
+
* - "already-requested" a request was already pending and was deduplicated.
|
|
95
|
+
* - "commit-boundary" build and commit the reset boundary now (turn_end).
|
|
96
|
+
* - "recover-overflow" build and commit the one bounded overflow recovery now (settle).
|
|
97
|
+
*/
|
|
98
|
+
export type ResetControlEffect =
|
|
99
|
+
| "none"
|
|
100
|
+
| "requested"
|
|
101
|
+
| "already-requested"
|
|
102
|
+
| "commit-boundary"
|
|
103
|
+
| "recover-overflow";
|
|
104
|
+
|
|
105
|
+
export interface ResetControlResult {
|
|
106
|
+
readonly state: ResetControlState;
|
|
107
|
+
readonly effect: ResetControlEffect;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Pure reset-control transition. It performs no writes, no policy resolution of its own, and
|
|
112
|
+
* no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
|
|
113
|
+
* full transition table without a live Pi session.
|
|
114
|
+
*/
|
|
115
|
+
export function reduceResetControl(state: ResetControlState, event: ResetControlEvent): ResetControlResult {
|
|
116
|
+
switch (event.type) {
|
|
117
|
+
case "request": {
|
|
118
|
+
if (state.request === "explicit") return { state, effect: "already-requested" };
|
|
119
|
+
return { state: { ...state, request: "explicit" }, effect: "requested" };
|
|
120
|
+
}
|
|
121
|
+
case "turn_end": {
|
|
122
|
+
const facts = event.facts;
|
|
123
|
+
const requested = state.request === "explicit";
|
|
124
|
+
// The explicit request is consumed by the turn boundary whether or not it commits.
|
|
125
|
+
const request: ResetRequestPhase = "none";
|
|
126
|
+
if (facts.aborted) {
|
|
127
|
+
// An aborted turn drops the whole boundary: no explicit request, no overflow chain.
|
|
128
|
+
return { state: { request, overflow: "idle" }, effect: "none" };
|
|
129
|
+
}
|
|
130
|
+
if (facts.overflow) {
|
|
131
|
+
// A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
|
|
132
|
+
// A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
|
|
133
|
+
const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
|
|
134
|
+
const spent = state.overflow === "pending-spent" || state.overflow === "spent";
|
|
135
|
+
const overflow: ResetOverflowPhase = pending
|
|
136
|
+
? (spent ? "pending-spent" : "pending")
|
|
137
|
+
: (spent ? "spent" : "idle");
|
|
138
|
+
return { state: { request, overflow }, effect: "none" };
|
|
139
|
+
}
|
|
140
|
+
// Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
|
|
141
|
+
// error leaves the armed overflow chain untouched for the settle boundary.
|
|
142
|
+
const overflow: ResetOverflowPhase = facts.failed ? state.overflow : "idle";
|
|
143
|
+
if (!facts.enabled || facts.failed) return { state: { request, overflow }, effect: "none" };
|
|
144
|
+
// Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
|
|
145
|
+
const commit = requested || facts.thresholdDue;
|
|
146
|
+
return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
|
|
147
|
+
}
|
|
148
|
+
case "before_settle": {
|
|
149
|
+
const facts = event.facts;
|
|
150
|
+
if (state.overflow !== "pending" && state.overflow !== "pending-spent") return { state, effect: "none" };
|
|
151
|
+
// Let a queued turn run first; its own turn_end settles or clears this chain.
|
|
152
|
+
if (facts.queued) return { state, effect: "none" };
|
|
153
|
+
const spent = state.overflow === "pending-spent";
|
|
154
|
+
if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
|
|
155
|
+
return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
|
|
156
|
+
}
|
|
157
|
+
// The recovery is one-use per failure chain. Committing it spends the attempt.
|
|
158
|
+
return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
|
|
159
|
+
}
|
|
160
|
+
case "settled":
|
|
161
|
+
// Settlement ends the failure chain but leaves a pending explicit request armed.
|
|
162
|
+
return { state: { ...state, overflow: "idle" }, effect: "none" };
|
|
163
|
+
case "abort":
|
|
164
|
+
case "clear":
|
|
165
|
+
return { state: initialResetControl(), effect: "none" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
29
169
|
/**
|
|
30
170
|
* Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
|
|
31
171
|
* compaction summaries: turn_end commits explicit/threshold resets after a complete tool
|
|
32
172
|
* batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
|
|
33
173
|
* native recovery attempt has been cancelled.
|
|
174
|
+
*
|
|
175
|
+
* This function is the effect adapter: it captures Pi events, translates them into pure
|
|
176
|
+
* reset-control transitions, and performs the resulting marker/boot/continuation writes and
|
|
177
|
+
* notifications. The decision of what to do lives entirely in `reduceResetControl`.
|
|
34
178
|
*/
|
|
35
179
|
export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions) {
|
|
36
|
-
let
|
|
37
|
-
let
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
const clear = () => {
|
|
42
|
-
explicitRequested = false;
|
|
43
|
-
overflowPending = false;
|
|
44
|
-
overflowRecoveryUsed = false;
|
|
45
|
-
};
|
|
180
|
+
let sessionActive = true;
|
|
181
|
+
let control = initialResetControl();
|
|
182
|
+
|
|
183
|
+
const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
|
|
46
184
|
const resetBoundaryResult = (entries: SessionBoundaryDraft[], ctx: ExtensionContext) => {
|
|
47
185
|
try {
|
|
48
186
|
return { entries: [...entries, ...options.buildReset(ctx)], continue: true as const };
|
|
@@ -56,58 +194,48 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
|
|
|
56
194
|
};
|
|
57
195
|
|
|
58
196
|
pi.on("turn_end", (event, ctx) => {
|
|
59
|
-
if (!
|
|
60
|
-
const requested = explicitRequested;
|
|
61
|
-
explicitRequested = false;
|
|
197
|
+
if (!sessionActive) return undefined;
|
|
62
198
|
const aborted = isAbort(event.message, event.outcome, ctx);
|
|
63
199
|
const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
|
|
64
200
|
// Lifecycle owns whether drafts are acceptable for this turn. Budget only
|
|
65
201
|
// drains its instance-local staging, so aborts and disabled mode cannot commit it.
|
|
66
202
|
const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
|
|
67
203
|
const entries = [...(event.entries ?? []), ...budgetEntries];
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// older overflow failure before the settle boundary gets a chance to recover it.
|
|
84
|
-
if (event.outcome !== "error") {
|
|
85
|
-
overflowPending = false;
|
|
86
|
-
overflowRecoveryUsed = false;
|
|
87
|
-
}
|
|
88
|
-
if (!options.isEnabled() || event.outcome === "error") return entries.length > 0 ? { entries } : undefined;
|
|
89
|
-
// The completed response may be the first event whose persisted usage crosses the
|
|
90
|
-
// reserve, so a final assistant response does not defer the reset until another prompt.
|
|
91
|
-
const autoThreshold = options.budget.resetDue(ctx);
|
|
92
|
-
if (!requested && !autoThreshold) return entries.length > 0 ? { entries } : undefined;
|
|
93
|
-
return resetBoundaryResult(entries, ctx);
|
|
204
|
+
const decision = reduceResetControl(control, {
|
|
205
|
+
type: "turn_end",
|
|
206
|
+
facts: {
|
|
207
|
+
aborted,
|
|
208
|
+
overflow: aborted ? false : isOverflowLike(event.message, ctx),
|
|
209
|
+
failed: event.outcome === "error",
|
|
210
|
+
enabled: options.isEnabled(),
|
|
211
|
+
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
212
|
+
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
213
|
+
get thresholdDue() { return options.budget.resetDue(ctx); },
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
control = decision.state;
|
|
217
|
+
if (decision.effect === "commit-boundary") return resetBoundaryResult(entries, ctx);
|
|
218
|
+
return entries.length > 0 ? { entries } : undefined;
|
|
94
219
|
});
|
|
95
220
|
|
|
96
221
|
pi.on("agent_before_settle", (event: AgentBeforeSettleEvent, ctx) => {
|
|
97
|
-
if (!
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
222
|
+
if (!sessionActive) return undefined;
|
|
223
|
+
const decision = reduceResetControl(control, {
|
|
224
|
+
type: "before_settle",
|
|
225
|
+
facts: {
|
|
226
|
+
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
227
|
+
get enabled() { return options.isEnabled(); },
|
|
228
|
+
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
229
|
+
get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
control = decision.state;
|
|
233
|
+
if (decision.effect !== "recover-overflow") return undefined;
|
|
106
234
|
return resetBoundaryResult(event.entries, ctx);
|
|
107
235
|
});
|
|
108
236
|
|
|
109
237
|
pi.on("session_before_compact", (event, ctx) => {
|
|
110
|
-
if (!
|
|
238
|
+
if (!sessionActive) return undefined;
|
|
111
239
|
if (event.signal.aborted) return { cancel: true };
|
|
112
240
|
const markerExists = currentReset(ctx) !== undefined;
|
|
113
241
|
if (options.isEnabled() || markerExists) {
|
|
@@ -127,18 +255,17 @@ export function registerResetLifecycle(pi: ExtensionAPI, options: ResetOptions)
|
|
|
127
255
|
pi.on("agent_settled", () => {
|
|
128
256
|
// A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
|
|
129
257
|
// user prompt starts a new chain; successful continuations clear this earlier.
|
|
130
|
-
|
|
131
|
-
overflowRecoveryUsed = false;
|
|
258
|
+
control = reduceResetControl(control, { type: "settled" }).state;
|
|
132
259
|
});
|
|
133
|
-
pi.on("session_start", () => { clear();
|
|
260
|
+
pi.on("session_start", () => { clear(); sessionActive = true; });
|
|
134
261
|
pi.on("session_tree", clear);
|
|
135
|
-
pi.on("session_shutdown", () => { clear(); options.budget.clear();
|
|
262
|
+
pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
|
|
136
263
|
|
|
137
264
|
return {
|
|
138
265
|
request() {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return "rollover_requested";
|
|
266
|
+
const decision = reduceResetControl(control, { type: "request" });
|
|
267
|
+
control = decision.state;
|
|
268
|
+
return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
|
|
142
269
|
},
|
|
143
270
|
clear,
|
|
144
271
|
};
|
package/src/context/runtime.ts
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { VERSION, defineTool, type ExtensionAPI, type ExtensionContext, type SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import { registerBudget } from "./budget.js";
|
|
5
4
|
import { output } from "../tool-output.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { loadNotesSnapshot, type NotesSnapshot } from "../notes/notes-snapshot.js";
|
|
9
|
-
import { renderBootBlock } from "./prompts.js";
|
|
10
|
-
import { currentReset, currentWindowId, isWindowBoot, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
|
|
5
|
+
import { migrateLegacyHomes } from "../notes/paths.js";
|
|
6
|
+
import { currentReset, isWindowMarker, projectRootWindow, projectWindow, rootWindowId } from "./context-window.js";
|
|
11
7
|
import { registerResetLifecycle } from "./reset-lifecycle.js";
|
|
8
|
+
import { buildResetDrafts, persistManualReset, resetTailCommitted } from "./reset-artifacts.js";
|
|
9
|
+
import { ensureBoot, type IncompleteNotesNotifier } from "./boot.js";
|
|
12
10
|
|
|
13
11
|
declare const __PI_CONTEXT_BUILD__: { version: string; sourceHash: string };
|
|
14
12
|
|
|
@@ -17,100 +15,6 @@ const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
|
|
|
17
15
|
? "unbundled source (build unknown)"
|
|
18
16
|
: `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
|
|
19
17
|
|
|
20
|
-
type IncompleteNotesNotifier = (ctx: ExtensionContext, windowId: string, snapshot: NotesSnapshot) => void;
|
|
21
|
-
|
|
22
|
-
function bootContent(ctx: ExtensionContext, currentId: string, previousId: string | undefined, resetLine: boolean, notes: NotesSnapshot): string {
|
|
23
|
-
return renderBootBlock({
|
|
24
|
-
agentName: agentSlug(ctx),
|
|
25
|
-
modelName: modelSlug(ctx),
|
|
26
|
-
firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
|
|
27
|
-
currentWindowId: currentId,
|
|
28
|
-
previousWindowId: previousId,
|
|
29
|
-
resetLine,
|
|
30
|
-
notes,
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function buildResetDrafts(ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier) {
|
|
35
|
-
const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
36
|
-
const usedWindowIds = new Set(
|
|
37
|
-
ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId),
|
|
38
|
-
);
|
|
39
|
-
let windowId: string;
|
|
40
|
-
do {
|
|
41
|
-
windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
|
|
42
|
-
} while (usedWindowIds.has(windowId));
|
|
43
|
-
const notes = loadNotesSnapshot(ctx);
|
|
44
|
-
notifyIncompleteNotes?.(ctx, windowId, notes);
|
|
45
|
-
return [
|
|
46
|
-
{ type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
|
|
47
|
-
{
|
|
48
|
-
type: "custom_message",
|
|
49
|
-
customType: BOOT_TYPE,
|
|
50
|
-
content: bootContent(ctx, windowId, currentWindowId(ctx), true, notes),
|
|
51
|
-
display: false,
|
|
52
|
-
details: { windowId },
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
type: "custom_message",
|
|
56
|
-
customType: CONTINUATION_TYPE,
|
|
57
|
-
content: CONTINUATION,
|
|
58
|
-
display: false,
|
|
59
|
-
},
|
|
60
|
-
] satisfies [SessionBoundaryDraft, SessionBoundaryDraft, SessionBoundaryDraft];
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function ensureBoot(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): void {
|
|
64
|
-
const reset = currentReset(ctx);
|
|
65
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
66
|
-
const windowId = reset?.data?.windowId ?? rootWindowId(sessionId);
|
|
67
|
-
if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId))) return;
|
|
68
|
-
if (reset && !resetBootMayBeRepaired(ctx, reset.id, windowId)) return;
|
|
69
|
-
let previousId: string | undefined = reset ? rootWindowId(sessionId) : undefined;
|
|
70
|
-
if (reset) {
|
|
71
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
72
|
-
if (entry.id === reset.id) break;
|
|
73
|
-
if (isWindowMarker(entry)) previousId = entry.data.windowId;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
const notes = loadNotesSnapshot(ctx);
|
|
77
|
-
notifyIncompleteNotes?.(ctx, windowId, notes);
|
|
78
|
-
pi.sendMessage(
|
|
79
|
-
{ customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, reset !== undefined, notes), display: false, details: { windowId } },
|
|
80
|
-
{ triggerTurn: false },
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function persistManualReset(pi: ExtensionAPI, ctx: ExtensionContext, notifyIncompleteNotes?: IncompleteNotesNotifier): string {
|
|
85
|
-
const [marker, boot] = buildResetDrafts(ctx, notifyIncompleteNotes);
|
|
86
|
-
pi.appendEntry(marker.customType, marker.data);
|
|
87
|
-
pi.sendMessage(
|
|
88
|
-
{ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details },
|
|
89
|
-
{ triggerTurn: false },
|
|
90
|
-
);
|
|
91
|
-
return boot.details.windowId;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function resetBootMayBeRepaired(ctx: ExtensionContext, markerId: string, windowId: string): boolean {
|
|
95
|
-
const branch = ctx.sessionManager.getBranch();
|
|
96
|
-
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
97
|
-
if (markerIndex < 0) return false;
|
|
98
|
-
const afterMarker = branch.slice(markerIndex + 1);
|
|
99
|
-
// A raw boot is authoritative even when a later context_edit hides it from the
|
|
100
|
-
// projection. Appending another boot at the tail would move the boundary.
|
|
101
|
-
if (afterMarker.some((entry) => isWindowBootEntry(entry, windowId))) return false;
|
|
102
|
-
// Only a genuinely incomplete marker tail can be repaired. Once conversation or
|
|
103
|
-
// a context-bearing custom message follows it, refusing is safer than guessing.
|
|
104
|
-
return !afterMarker.some((entry) => entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary");
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function isWindowBootEntry(entry: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>[number], windowId: string): boolean {
|
|
108
|
-
return entry.type === "custom_message" && entry.customType === BOOT_TYPE &&
|
|
109
|
-
typeof entry.details === "object" && entry.details !== null &&
|
|
110
|
-
typeof (entry.details as { windowId?: unknown }).windowId === "string" &&
|
|
111
|
-
(entry.details as { windowId: string }).windowId === windowId;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
18
|
function branchHasWindowMarker(ctx: ExtensionContext, fromId?: string): boolean {
|
|
115
19
|
return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
|
|
116
20
|
}
|
|
@@ -121,18 +25,19 @@ export function registerContext(pi: ExtensionAPI, settingsManager?: SettingsMana
|
|
|
121
25
|
let missingBootNotice: string | undefined;
|
|
122
26
|
const incompleteNotesNotified = new Set<string>();
|
|
123
27
|
const pendingResetNotices = new Set<string>();
|
|
28
|
+
// Announce only a fully committed reset (marker + matching boot + continuation), not a
|
|
29
|
+
// reset request or a partial boot repair.
|
|
124
30
|
const notifyCommittedResets = (ctx: ExtensionContext, addedWindowId?: string) => {
|
|
125
31
|
if (addedWindowId) pendingResetNotices.add(addedWindowId);
|
|
126
32
|
if (pendingResetNotices.size === 0) return;
|
|
127
33
|
const branch = ctx.sessionManager.getBranch();
|
|
128
34
|
for (const windowId of pendingResetNotices) {
|
|
129
|
-
|
|
130
|
-
|
|
35
|
+
const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
|
|
36
|
+
if (!marker || !resetTailCommitted(ctx, marker.id, windowId)) continue;
|
|
131
37
|
pendingResetNotices.delete(windowId);
|
|
132
38
|
ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
|
|
133
39
|
}
|
|
134
40
|
};
|
|
135
|
-
// Announce only a committed reset (marker + boot), not a reset request or boot repair.
|
|
136
41
|
pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
|
|
137
42
|
pi.on("agent_settled", (_event, ctx) => {
|
|
138
43
|
notifyCommittedResets(ctx);
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { registerNotesTools } from "./notes/tools.js";
|
|
|
4
4
|
import { deriveThresholds } from "./context/thresholds.js";
|
|
5
5
|
import { registerContext } from "./context/runtime.js";
|
|
6
6
|
import { mergePiContextSettings } from "./settings.js";
|
|
7
|
-
import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS,
|
|
7
|
+
import { NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, RESET_MARKER_TYPE, CONTINUATION_TYPE, MAX_NOTE_BYTES, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS, CONTINUATION, WARNING_PROMPT } from "./protocol.js";
|
|
8
8
|
import { assertVirtualPath } from "./notes/address.js";
|
|
9
9
|
export { historyFromSession } from "./history/history.js";
|
|
10
10
|
export { notesFromSession } from "./notes/session-replay.js";
|
|
@@ -32,4 +32,4 @@ export default function piContext(pi: ExtensionAPI): void {
|
|
|
32
32
|
registerPiContext(pi);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE,
|
|
35
|
+
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE, CONTINUATION, CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, CONTEXT_WINDOW_PROTOCOL_OPEN_TAG, CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG, GUIDANCE_OPEN_TAG, PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, deriveThresholds, mergePiContextSettings, assertVirtualPath };
|
package/src/notes/address.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { agentSlug, modelSlug, SLUG_PATTERN, type Scope } from "./paths.js";
|
|
|
3
3
|
|
|
4
4
|
export type NoteAddress = { scope: Scope; path: string; who?: string };
|
|
5
5
|
|
|
6
|
-
export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/,
|
|
6
|
+
export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
|
|
7
7
|
|
|
8
8
|
export function assertVirtualPath(value: unknown): string {
|
|
9
9
|
if (typeof value !== "string" || value.length === 0) throw new Error("path must be a non-empty virtual relative path");
|
package/src/notes/tools.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
|
|
|
10
10
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
11
11
|
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
12
12
|
}));
|
|
13
|
-
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project
|
|
13
|
+
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project notes, `@self/<vpath>` for your own, and `@model/<vpath>` for the current model's. `@self` and `@model` mean whoever is running now. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no fallback across prefixes. Paths reject `..`, absolute paths, and backslashes.";
|
|
14
14
|
|
|
15
15
|
function failure(error: unknown) {
|
|
16
16
|
if (error instanceof NoteError) {
|
|
@@ -73,7 +73,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
73
73
|
|
|
74
74
|
pi.registerTool(defineTool({
|
|
75
75
|
name: "notes_list", label: "Notes list",
|
|
76
|
-
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five
|
|
76
|
+
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five prefixes: this session, @project/, @human/, @self/, and @model/.`,
|
|
77
77
|
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
78
78
|
async execute(_id, params, _signal, _update, ctx) {
|
|
79
79
|
let rows: ReturnType<typeof listNotes>;
|
|
@@ -89,7 +89,7 @@ export function registerNotesTools(pi: ExtensionAPI) {
|
|
|
89
89
|
|
|
90
90
|
pi.registerTool(defineTool({
|
|
91
91
|
name: "notes_search", label: "Notes search",
|
|
92
|
-
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} Search merges the same five
|
|
92
|
+
description: `Case-sensitive literal substring search over note bodies; query is one string or several (OR), each matched line appears once. ${ADDRESS_DESCRIPTION} Search merges the same five prefixes as notes_list. Patterns glob over full address strings. Each file entry carries matches_total, its full match count before capping. Each match carries line, text, offset_chars (a code-point offset into the serialized note returned by notes_read, at the earliest query match), and truncated.`,
|
|
93
93
|
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
94
94
|
async execute(_id, params, _signal, _update, ctx) {
|
|
95
95
|
const queries = searchQueries(params.query);
|
package/src/protocol.ts
CHANGED
|
@@ -32,9 +32,8 @@ export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
|
|
|
32
32
|
* never sees — Codex's fallback buffer, relocated above the line.
|
|
33
33
|
*/
|
|
34
34
|
export const WARNING_RUNWAY_TOKENS = 12_288;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export const CONTINUATION = "Your memory was just erased. Pull only the details you need from history_* and notes_*, then get back to work.";
|
|
35
|
+
/** The single reset message: the only reset prose persisted, carried by the continuation entry. */
|
|
36
|
+
export const CONTINUATION = "Your memory was just erased. Your head is blank. Good news: your notes are still here, and history remains... searchable. Do try to keep up.";
|
|
38
37
|
|
|
39
38
|
/**
|
|
40
39
|
* Static protocol teaching adapted from Codex's token_budget.guidance_message to
|
|
@@ -49,9 +48,7 @@ Keep a running checkpoint while you work, not at the last minute — the next wi
|
|
|
49
48
|
|
|
50
49
|
Use get_context_remaining to see how much of the window is left. When it runs out, this window is gone — with no final turn at the limit — and you continue in a fresh one, recovering only through notes_* and history_*. Once your checkpoint is written, you can call wipe_memory yourself instead of waiting for the erase. Do not let a window die undocumented.
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Notes live in five homes, and the word after @ is always one of their reserved names — your own name and other people's names live at the second level (@agents/faye/, never @faye/). Bare names are this session; @project/<vpath> is this project's workspace; @human/<vpath> is the human's cross-project home; @self/<vpath> and @agents/<name>/<vpath> are agent homes; @model/<vpath> and @models/<name>/<vpath> are model homes. @self and @model are the only relative forms — the current agent, the current model — and listings never show them, only the resolved name. There is no cross-home fallback.
|
|
51
|
+
Note addresses take five prefixes: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own, as the current agent; @model/<vpath> is the current model's. @self and @model resolve to who is running now; listings always show resolved names. Nothing else is legal — any other @ prefix, or @ inside a vpath, is a hard error, with no fallback across prefixes.
|
|
55
52
|
Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
|
|
56
53
|
@project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
|
|
57
54
|
@human notes hold the human's durable preferences and standing rules, plus lessons that apply across projects — for every agent that serves this human, whoever is running. You write there as the human's scribe; what the human dictates carries origin: user. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
|