@astrosheep/pi-context 0.25.0 → 0.25.2
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 +229 -139
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +6 -6
- 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 +12 -10
- 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/budget.ts +9 -9
- 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,46 @@
|
|
|
1
|
+
import { loadNotesSnapshot } from "../notes/notes-snapshot.js";
|
|
2
|
+
import { agentSlug, modelSlug } from "../notes/paths.js";
|
|
3
|
+
import { BOOT_TYPE } from "../protocol.js";
|
|
4
|
+
import { renderBootBlock } from "./prompts.js";
|
|
5
|
+
import { currentReset, isWindowBoot, rootWindowId } from "./context-window.js";
|
|
6
|
+
import { repairResetTail } from "./reset-artifacts.js";
|
|
7
|
+
/** Render the boot block from the live context; acquisition stays with loadNotesSnapshot. */
|
|
8
|
+
function bootContent(ctx, currentId, previousId, notes) {
|
|
9
|
+
return renderBootBlock({
|
|
10
|
+
agentName: agentSlug(ctx),
|
|
11
|
+
modelName: modelSlug(ctx),
|
|
12
|
+
firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
|
|
13
|
+
currentWindowId: currentId,
|
|
14
|
+
previousWindowId: previousId,
|
|
15
|
+
notes,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Acquire one notes snapshot and build the boot custom message for a window. The caller
|
|
20
|
+
* supplies `previousId` only when a reset boundary needs the prior window identity.
|
|
21
|
+
*/
|
|
22
|
+
export function buildBootMessage(ctx, windowId, previousId, notifyIncompleteNotes) {
|
|
23
|
+
const notes = loadNotesSnapshot(ctx);
|
|
24
|
+
notifyIncompleteNotes?.(ctx, windowId, notes);
|
|
25
|
+
return { customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, notes), display: false, details: { windowId } };
|
|
26
|
+
}
|
|
27
|
+
/** Persist one hidden boot message without triggering a model turn. */
|
|
28
|
+
export function sendBoot(pi, boot) {
|
|
29
|
+
pi.sendMessage({ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details }, { triggerTurn: false });
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Boot entry point for `session_start` / `session_tree`. Ordinary startup ensures one root
|
|
33
|
+
* boot; a reset marker instead asks reset-artifact repair to complete its persisted tail.
|
|
34
|
+
* Boot idempotence lives here: an already-projected root boot or a complete reset tail emits nothing.
|
|
35
|
+
*/
|
|
36
|
+
export function ensureBoot(pi, ctx, notifyIncompleteNotes) {
|
|
37
|
+
const reset = currentReset(ctx);
|
|
38
|
+
if (reset) {
|
|
39
|
+
repairResetTail(pi, ctx, reset, notifyIncompleteNotes);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const windowId = rootWindowId(ctx.sessionManager.getSessionId());
|
|
43
|
+
if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId)))
|
|
44
|
+
return;
|
|
45
|
+
sendBoot(pi, buildBootMessage(ctx, windowId, undefined, notifyIncompleteNotes));
|
|
46
|
+
}
|
|
@@ -40,6 +40,7 @@ export function registerBudget(pi, isEnabled, settingsManager) {
|
|
|
40
40
|
return usage !== undefined && usage.tokens !== null && usage.contextWindow - usage.tokens <= thresholdsFor(ctx).reserve;
|
|
41
41
|
};
|
|
42
42
|
const invalidateThresholds = () => { cachedPolicy = undefined; };
|
|
43
|
+
const formatRemaining = (remaining) => `${Math.max(0, Math.ceil(remaining / 1000))}k`;
|
|
43
44
|
let pendingGuidance;
|
|
44
45
|
let pendingWarning;
|
|
45
46
|
let pendingNotices = [];
|
|
@@ -49,8 +50,8 @@ export function registerBudget(pi, isEnabled, settingsManager) {
|
|
|
49
50
|
if (notice.windowId !== windowId || !hasWindowMessage(ctx, notice.customType))
|
|
50
51
|
continue;
|
|
51
52
|
ctx.ui.notify(notice.customType === WARNING_TYPE
|
|
52
|
-
?
|
|
53
|
-
:
|
|
53
|
+
? `pi-context: Context almost full — ${formatRemaining(notice.remaining)} remaining`
|
|
54
|
+
: `pi-context: Context running low — ${formatRemaining(notice.remaining)} remaining`, "warning");
|
|
54
55
|
}
|
|
55
56
|
pendingNotices = [];
|
|
56
57
|
};
|
|
@@ -72,7 +73,7 @@ export function registerBudget(pi, isEnabled, settingsManager) {
|
|
|
72
73
|
clearStaged();
|
|
73
74
|
const windowId = currentWindowId(ctx);
|
|
74
75
|
const drafts = staged.filter((draft) => draft !== undefined && draft.windowId === windowId);
|
|
75
|
-
pendingNotices = drafts.map(({ windowId, customType }) => ({ windowId, customType }));
|
|
76
|
+
pendingNotices = drafts.map(({ windowId, customType, remaining }) => ({ windowId, customType, remaining }));
|
|
76
77
|
return drafts.map((draft) => ({
|
|
77
78
|
type: "custom_message",
|
|
78
79
|
customType: draft.customType,
|
|
@@ -88,7 +89,6 @@ export function registerBudget(pi, isEnabled, settingsManager) {
|
|
|
88
89
|
// lifecycle point that must discard an uncommitted draft before the next prompt.
|
|
89
90
|
// UI notices follow committed reminders. Aborted requests can retry their drafts
|
|
90
91
|
// without showing the same low-budget notification twice.
|
|
91
|
-
pi.on("turn_start", (_event, ctx) => notifyCommittedReminders(ctx));
|
|
92
92
|
pi.on("agent_settled", (_event, ctx) => {
|
|
93
93
|
notifyCommittedReminders(ctx);
|
|
94
94
|
clearStaged();
|
|
@@ -109,7 +109,7 @@ export function registerBudget(pi, isEnabled, settingsManager) {
|
|
|
109
109
|
// A not-yet-committed shallow reminder is superseded by the final warning.
|
|
110
110
|
pendingGuidance = undefined;
|
|
111
111
|
const content = `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`;
|
|
112
|
-
pendingWarning = { windowId, content };
|
|
112
|
+
pendingWarning = { windowId, content, remaining };
|
|
113
113
|
const warningMessage = {
|
|
114
114
|
role: "custom",
|
|
115
115
|
customType: WARNING_TYPE,
|
|
@@ -125,7 +125,7 @@ export function registerBudget(pi, isEnabled, settingsManager) {
|
|
|
125
125
|
// Persist at turn_end, before any reset drafts. A queued sendMessage could
|
|
126
126
|
// otherwise cross the marker and leak the old window's reminder forward.
|
|
127
127
|
const left = Math.max(0, remaining - warning);
|
|
128
|
-
pendingGuidance = { windowId, content: tokenBudgetGuidance(left) };
|
|
128
|
+
pendingGuidance = { windowId, content: tokenBudgetGuidance(left), remaining };
|
|
129
129
|
}
|
|
130
130
|
return undefined;
|
|
131
131
|
});
|
|
@@ -38,6 +38,17 @@ export function hasWindowMessage(ctx, customType) {
|
|
|
38
38
|
export function currentWindowId(ctx) {
|
|
39
39
|
return currentReset(ctx)?.data.windowId ?? rootWindowId(ctx.sessionManager.getSessionId());
|
|
40
40
|
}
|
|
41
|
+
/** The durable window active just before the marker: the last earlier marker, else the root window. */
|
|
42
|
+
export function previousWindowId(ctx, markerId) {
|
|
43
|
+
let previousId = rootWindowId(ctx.sessionManager.getSessionId());
|
|
44
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
45
|
+
if (entry.id === markerId)
|
|
46
|
+
break;
|
|
47
|
+
if (isWindowMarker(entry))
|
|
48
|
+
previousId = entry.data.windowId;
|
|
49
|
+
}
|
|
50
|
+
return previousId;
|
|
51
|
+
}
|
|
41
52
|
function hasWindowId(details, windowId) {
|
|
42
53
|
return typeof details === "object" && details !== null &&
|
|
43
54
|
typeof details.windowId === "string" &&
|
|
@@ -47,6 +58,10 @@ function hasWindowId(details, windowId) {
|
|
|
47
58
|
export function isWindowBoot(message, windowId) {
|
|
48
59
|
return message.role === "custom" && message.customType === BOOT_TYPE && (windowId === undefined || hasWindowId(message.details, windowId));
|
|
49
60
|
}
|
|
61
|
+
/** Match a persisted boot entry by raw identity, even when a later edit hides it from projection. */
|
|
62
|
+
export function isWindowBootEntry(entry, windowId) {
|
|
63
|
+
return entry.type === "custom_message" && entry.customType === BOOT_TYPE && hasWindowId(entry.details, windowId);
|
|
64
|
+
}
|
|
50
65
|
/**
|
|
51
66
|
* The durable marker selects a boot message by identity, never by wall-clock time.
|
|
52
67
|
* The boot is the first conversation message of the window. Folding only its prefix
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT,
|
|
1
|
+
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_AGENT_LIMIT, POCKET_HUMAN_LIMIT, POCKET_MODEL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "../protocol.js";
|
|
2
2
|
/** Codex-style <context_window> identity block: the resolved agent and model names plus first/current/previous window ids. */
|
|
3
3
|
function identityBlock(agentName, modelName, firstWindowId, currentWindowId, previousWindowId) {
|
|
4
4
|
const lines = [
|
|
@@ -24,8 +24,7 @@ function notesUnavailableNotice(snapshot) {
|
|
|
24
24
|
if (snapshot.unavailable.length === 0)
|
|
25
25
|
return undefined;
|
|
26
26
|
const homes = snapshot.unavailable.map((home) => home.label).join(", ");
|
|
27
|
-
|
|
28
|
-
return `Notes index incomplete: ${homes} ${noun} unavailable during boot; notes_list can retry after recovery.`;
|
|
27
|
+
return `Notes index incomplete: index for ${homes} unavailable during boot; notes_list can retry after recovery.`;
|
|
29
28
|
}
|
|
30
29
|
/**
|
|
31
30
|
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the human, project,
|
|
@@ -58,7 +57,7 @@ function notesIndex(snapshot) {
|
|
|
58
57
|
...rowsFor(snapshot, "model").filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_MODEL_LIMIT),
|
|
59
58
|
];
|
|
60
59
|
if (recentNotes.length > 0) {
|
|
61
|
-
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by
|
|
60
|
+
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by prefix, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from @project, ${POCKET_HUMAN_LIMIT} from @human, ${POCKET_AGENT_LIMIT} from @self, ${POCKET_MODEL_LIMIT} from @model). A note's content never appears here, so its name has to say what the note is about:`];
|
|
62
61
|
for (const row of recentNotes) {
|
|
63
62
|
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, snapshot.openedAt)})`);
|
|
64
63
|
}
|
|
@@ -67,12 +66,10 @@ function notesIndex(snapshot) {
|
|
|
67
66
|
return sections.join("\n\n");
|
|
68
67
|
}
|
|
69
68
|
function notesHomeBlock() {
|
|
70
|
-
return "
|
|
69
|
+
return "Note addresses: bare <vpath> is this session; @project/<vpath> is this project; @human/<vpath> is the human's cross-project notes; @self/<vpath> is your own (current agent); @model/<vpath> is the current model's. @self and @model resolve to who is running now. Any other @ prefix, or @ inside a vpath, is a hard error; there is no fallback across prefixes. Anything not matching these is a plain file — use the file tools.";
|
|
71
70
|
}
|
|
72
71
|
export function renderBootBlock(data) {
|
|
73
72
|
const parts = [];
|
|
74
|
-
if (data.resetLine)
|
|
75
|
-
parts.push(RESET_SUMMARY);
|
|
76
73
|
parts.push(identityBlock(data.agentName, data.modelName, data.firstWindowId, data.currentWindowId, data.previousWindowId));
|
|
77
74
|
parts.push(notesHomeBlock());
|
|
78
75
|
const incomplete = notesUnavailableNotice(data.notes);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { BOOT_TYPE, CONTINUATION, CONTINUATION_TYPE, RESET_MARKER_TYPE } from "../protocol.js";
|
|
3
|
+
import { currentWindowId, isWindowBootEntry, isWindowMarker, previousWindowId } from "./context-window.js";
|
|
4
|
+
import { buildBootMessage, sendBoot } from "./boot.js";
|
|
5
|
+
/** Match the hidden continuation entry that carries the one reset message. */
|
|
6
|
+
export function isWindowContinuationEntry(entry) {
|
|
7
|
+
return entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE;
|
|
8
|
+
}
|
|
9
|
+
/** The single continuation sender: the only reset prose persisted for a window. */
|
|
10
|
+
export function sendContinuation(pi) {
|
|
11
|
+
pi.sendMessage({ customType: CONTINUATION_TYPE, content: CONTINUATION, display: false }, { triggerTurn: false });
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The closed, ordered reset shape: marker, matching boot, continuation. This is the one
|
|
15
|
+
* source of the reset message and the one place that mints the new window identity.
|
|
16
|
+
*/
|
|
17
|
+
export function buildResetDrafts(ctx, notifyIncompleteNotes) {
|
|
18
|
+
const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
19
|
+
const usedWindowIds = new Set(ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId));
|
|
20
|
+
let windowId;
|
|
21
|
+
do {
|
|
22
|
+
windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
|
|
23
|
+
} while (usedWindowIds.has(windowId));
|
|
24
|
+
const boot = buildBootMessage(ctx, windowId, currentWindowId(ctx), notifyIncompleteNotes);
|
|
25
|
+
return [
|
|
26
|
+
{ type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
|
|
27
|
+
{ type: "custom_message", customType: BOOT_TYPE, content: boot.content, display: false, details: { windowId } },
|
|
28
|
+
{ type: "custom_message", customType: CONTINUATION_TYPE, content: CONTINUATION, display: false },
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
/** Persist the marker and send both hidden reset messages; returns the new window id. */
|
|
32
|
+
export function persistManualReset(pi, ctx, notifyIncompleteNotes) {
|
|
33
|
+
const [marker, boot, continuation] = buildResetDrafts(ctx, notifyIncompleteNotes);
|
|
34
|
+
pi.appendEntry(marker.customType, marker.data);
|
|
35
|
+
pi.sendMessage({ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details }, { triggerTurn: false });
|
|
36
|
+
pi.sendMessage({ customType: continuation.customType, content: continuation.content, display: continuation.display }, { triggerTurn: false });
|
|
37
|
+
return boot.details.windowId;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Inspect the persisted tail of a reset marker. It reports which reset messages are present
|
|
41
|
+
* only while the tail stays repairable: metadata may follow the marker, but real conversation,
|
|
42
|
+
* a foreign message, a later marker, or a misordered/duplicate reset artifact refuses repair.
|
|
43
|
+
*/
|
|
44
|
+
export function inspectResetTail(ctx, markerId, windowId) {
|
|
45
|
+
const branch = ctx.sessionManager.getBranch();
|
|
46
|
+
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
47
|
+
if (markerIndex < 0)
|
|
48
|
+
return undefined;
|
|
49
|
+
let boot = false;
|
|
50
|
+
let continuation = false;
|
|
51
|
+
for (const entry of branch.slice(markerIndex + 1)) {
|
|
52
|
+
if (isWindowBootEntry(entry, windowId)) {
|
|
53
|
+
// The boot is unique and must precede the continuation; a repeat or a late
|
|
54
|
+
// boot would move the provider boundary or misorder the reset shape.
|
|
55
|
+
if (boot || continuation)
|
|
56
|
+
return undefined;
|
|
57
|
+
boot = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (isWindowContinuationEntry(entry)) {
|
|
61
|
+
if (continuation || !boot)
|
|
62
|
+
return undefined;
|
|
63
|
+
continuation = true;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (isWindowMarker(entry) || entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary") {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { boot, continuation };
|
|
71
|
+
}
|
|
72
|
+
/** True once the marker's tail already carries its boot and continuation in a valid order. */
|
|
73
|
+
export function resetTailCommitted(ctx, markerId, windowId) {
|
|
74
|
+
const tail = inspectResetTail(ctx, markerId, windowId);
|
|
75
|
+
return tail?.boot === true && tail.continuation === true;
|
|
76
|
+
}
|
|
77
|
+
/** Emit only the reset artifacts an incomplete tail is missing, in the closed order. */
|
|
78
|
+
export function repairResetTail(pi, ctx, marker, notifyIncompleteNotes) {
|
|
79
|
+
const tail = inspectResetTail(ctx, marker.id, marker.data.windowId);
|
|
80
|
+
if (!tail || (tail.boot && tail.continuation))
|
|
81
|
+
return;
|
|
82
|
+
if (!tail.boot)
|
|
83
|
+
sendBoot(pi, buildBootMessage(ctx, marker.data.windowId, previousWindowId(ctx, marker.id), notifyIncompleteNotes));
|
|
84
|
+
if (!tail.continuation)
|
|
85
|
+
sendContinuation(pi);
|
|
86
|
+
}
|
|
@@ -9,22 +9,85 @@ function isOverflowLike(message, ctx) {
|
|
|
9
9
|
return isContextOverflow(message, ctx.model?.contextWindow) ||
|
|
10
10
|
(ctx.model !== undefined && isRecoverableLength(message, ctx.model.maxTokens));
|
|
11
11
|
}
|
|
12
|
+
export function initialResetControl() {
|
|
13
|
+
return { request: "none", overflow: "idle" };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Pure reset-control transition. It performs no writes, no policy resolution of its own, and
|
|
17
|
+
* no UI work; every fact it reads is supplied by the caller. Callers can therefore drive the
|
|
18
|
+
* full transition table without a live Pi session.
|
|
19
|
+
*/
|
|
20
|
+
export function reduceResetControl(state, event) {
|
|
21
|
+
switch (event.type) {
|
|
22
|
+
case "request": {
|
|
23
|
+
if (state.request === "explicit")
|
|
24
|
+
return { state, effect: "already-requested" };
|
|
25
|
+
return { state: { ...state, request: "explicit" }, effect: "requested" };
|
|
26
|
+
}
|
|
27
|
+
case "turn_end": {
|
|
28
|
+
const facts = event.facts;
|
|
29
|
+
const requested = state.request === "explicit";
|
|
30
|
+
// The explicit request is consumed by the turn boundary whether or not it commits.
|
|
31
|
+
const request = "none";
|
|
32
|
+
if (facts.aborted) {
|
|
33
|
+
// An aborted turn drops the whole boundary: no explicit request, no overflow chain.
|
|
34
|
+
return { state: { request, overflow: "idle" }, effect: "none" };
|
|
35
|
+
}
|
|
36
|
+
if (facts.overflow) {
|
|
37
|
+
// A failure that Pi may recover natively: arm (or re-arm) the bounded settle path.
|
|
38
|
+
// A queued message, disabled mode, or disabled automatic reset leaves it disarmed.
|
|
39
|
+
const pending = !facts.queued && facts.enabled && facts.automaticResetEnabled;
|
|
40
|
+
const spent = state.overflow === "pending-spent" || state.overflow === "spent";
|
|
41
|
+
const overflow = pending
|
|
42
|
+
? (spent ? "pending-spent" : "pending")
|
|
43
|
+
: (spent ? "spent" : "idle");
|
|
44
|
+
return { state: { request, overflow }, effect: "none" };
|
|
45
|
+
}
|
|
46
|
+
// Any non-overflow completed turn supersedes an older overflow failure. A non-overflow
|
|
47
|
+
// error leaves the armed overflow chain untouched for the settle boundary.
|
|
48
|
+
const overflow = facts.failed ? state.overflow : "idle";
|
|
49
|
+
if (!facts.enabled || facts.failed)
|
|
50
|
+
return { state: { request, overflow }, effect: "none" };
|
|
51
|
+
// Explicit and threshold resets both commit at turn_end, after incoming and budget drafts.
|
|
52
|
+
const commit = requested || facts.thresholdDue;
|
|
53
|
+
return { state: { request, overflow }, effect: commit ? "commit-boundary" : "none" };
|
|
54
|
+
}
|
|
55
|
+
case "before_settle": {
|
|
56
|
+
const facts = event.facts;
|
|
57
|
+
if (state.overflow !== "pending" && state.overflow !== "pending-spent")
|
|
58
|
+
return { state, effect: "none" };
|
|
59
|
+
// Let a queued turn run first; its own turn_end settles or clears this chain.
|
|
60
|
+
if (facts.queued)
|
|
61
|
+
return { state, effect: "none" };
|
|
62
|
+
const spent = state.overflow === "pending-spent";
|
|
63
|
+
if (!facts.enabled || !facts.automaticResetEnabled || facts.aborted) {
|
|
64
|
+
return { state: { ...state, overflow: spent ? "spent" : "idle" }, effect: "none" };
|
|
65
|
+
}
|
|
66
|
+
// The recovery is one-use per failure chain. Committing it spends the attempt.
|
|
67
|
+
return { state: { ...state, overflow: "spent" }, effect: spent ? "none" : "recover-overflow" };
|
|
68
|
+
}
|
|
69
|
+
case "settled":
|
|
70
|
+
// Settlement ends the failure chain but leaves a pending explicit request armed.
|
|
71
|
+
return { state: { ...state, overflow: "idle" }, effect: "none" };
|
|
72
|
+
case "abort":
|
|
73
|
+
case "clear":
|
|
74
|
+
return { state: initialResetControl(), effect: "none" };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
12
77
|
/**
|
|
13
78
|
* Own reset requests at Pi 0.87 boundaries. Persisted windows are custom entries, not
|
|
14
79
|
* compaction summaries: turn_end commits explicit/threshold resets after a complete tool
|
|
15
80
|
* batch, while agent_before_settle commits the one bounded overflow recovery after Pi's
|
|
16
81
|
* native recovery attempt has been cancelled.
|
|
82
|
+
*
|
|
83
|
+
* This function is the effect adapter: it captures Pi events, translates them into pure
|
|
84
|
+
* reset-control transitions, and performs the resulting marker/boot/continuation writes and
|
|
85
|
+
* notifications. The decision of what to do lives entirely in `reduceResetControl`.
|
|
17
86
|
*/
|
|
18
87
|
export function registerResetLifecycle(pi, options) {
|
|
19
|
-
let
|
|
20
|
-
let
|
|
21
|
-
|
|
22
|
-
let active = true;
|
|
23
|
-
const clear = () => {
|
|
24
|
-
explicitRequested = false;
|
|
25
|
-
overflowPending = false;
|
|
26
|
-
overflowRecoveryUsed = false;
|
|
27
|
-
};
|
|
88
|
+
let sessionActive = true;
|
|
89
|
+
let control = initialResetControl();
|
|
90
|
+
const clear = () => { control = reduceResetControl(control, { type: "clear" }).state; };
|
|
28
91
|
const resetBoundaryResult = (entries, ctx) => {
|
|
29
92
|
try {
|
|
30
93
|
return { entries: [...entries, ...options.buildReset(ctx)], continue: true };
|
|
@@ -38,63 +101,50 @@ export function registerResetLifecycle(pi, options) {
|
|
|
38
101
|
}
|
|
39
102
|
};
|
|
40
103
|
pi.on("turn_end", (event, ctx) => {
|
|
41
|
-
if (!
|
|
104
|
+
if (!sessionActive)
|
|
42
105
|
return undefined;
|
|
43
|
-
const requested = explicitRequested;
|
|
44
|
-
explicitRequested = false;
|
|
45
106
|
const aborted = isAbort(event.message, event.outcome, ctx);
|
|
46
107
|
const stagedBudgetEntries = options.budget.consumeTurnEnd(ctx);
|
|
47
108
|
// Lifecycle owns whether drafts are acceptable for this turn. Budget only
|
|
48
109
|
// drains its instance-local staging, so aborts and disabled mode cannot commit it.
|
|
49
110
|
const budgetEntries = options.isEnabled() && !aborted ? stagedBudgetEntries : [];
|
|
50
111
|
const entries = [...(event.entries ?? []), ...budgetEntries];
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (event.outcome !== "error") {
|
|
68
|
-
overflowPending = false;
|
|
69
|
-
overflowRecoveryUsed = false;
|
|
70
|
-
}
|
|
71
|
-
if (!options.isEnabled() || event.outcome === "error")
|
|
72
|
-
return entries.length > 0 ? { entries } : undefined;
|
|
73
|
-
// The completed response may be the first event whose persisted usage crosses the
|
|
74
|
-
// reserve, so a final assistant response does not defer the reset until another prompt.
|
|
75
|
-
const autoThreshold = options.budget.resetDue(ctx);
|
|
76
|
-
if (!requested && !autoThreshold)
|
|
77
|
-
return entries.length > 0 ? { entries } : undefined;
|
|
78
|
-
return resetBoundaryResult(entries, ctx);
|
|
112
|
+
const decision = reduceResetControl(control, {
|
|
113
|
+
type: "turn_end",
|
|
114
|
+
facts: {
|
|
115
|
+
aborted,
|
|
116
|
+
overflow: aborted ? false : isOverflowLike(event.message, ctx),
|
|
117
|
+
failed: event.outcome === "error",
|
|
118
|
+
enabled: options.isEnabled(),
|
|
119
|
+
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
120
|
+
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
121
|
+
get thresholdDue() { return options.budget.resetDue(ctx); },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
control = decision.state;
|
|
125
|
+
if (decision.effect === "commit-boundary")
|
|
126
|
+
return resetBoundaryResult(entries, ctx);
|
|
127
|
+
return entries.length > 0 ? { entries } : undefined;
|
|
79
128
|
});
|
|
80
129
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
81
|
-
if (!
|
|
82
|
-
return undefined;
|
|
83
|
-
// Pi invokes this boundary before settlement even when an agent_end handler has
|
|
84
|
-
// queued user input. Let that turn run first; its successful turn_end clears the
|
|
85
|
-
// stale failure, while another failure leaves the bounded recovery armed.
|
|
86
|
-
if (event.context.pendingMessages.length > 0 || ctx.hasPendingMessages())
|
|
87
|
-
return undefined;
|
|
88
|
-
overflowPending = false;
|
|
89
|
-
if (!options.isEnabled() || !options.budget.automaticResetEnabled(ctx) || event.outcome === "aborted" || ctx.signal?.aborted)
|
|
130
|
+
if (!sessionActive)
|
|
90
131
|
return undefined;
|
|
91
|
-
|
|
132
|
+
const decision = reduceResetControl(control, {
|
|
133
|
+
type: "before_settle",
|
|
134
|
+
facts: {
|
|
135
|
+
get queued() { return event.context.pendingMessages.length > 0 || ctx.hasPendingMessages(); },
|
|
136
|
+
get enabled() { return options.isEnabled(); },
|
|
137
|
+
get automaticResetEnabled() { return options.budget.automaticResetEnabled(ctx); },
|
|
138
|
+
get aborted() { return event.outcome === "aborted" || ctx.signal?.aborted === true; },
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
control = decision.state;
|
|
142
|
+
if (decision.effect !== "recover-overflow")
|
|
92
143
|
return undefined;
|
|
93
|
-
overflowRecoveryUsed = true;
|
|
94
144
|
return resetBoundaryResult(event.entries, ctx);
|
|
95
145
|
});
|
|
96
146
|
pi.on("session_before_compact", (event, ctx) => {
|
|
97
|
-
if (!
|
|
147
|
+
if (!sessionActive)
|
|
98
148
|
return undefined;
|
|
99
149
|
if (event.signal.aborted)
|
|
100
150
|
return { cancel: true };
|
|
@@ -116,18 +166,16 @@ export function registerResetLifecycle(pi, options) {
|
|
|
116
166
|
pi.on("agent_settled", () => {
|
|
117
167
|
// A failed recovery chain is bounded to one reset/retry. Once Pi settles, a later
|
|
118
168
|
// user prompt starts a new chain; successful continuations clear this earlier.
|
|
119
|
-
|
|
120
|
-
overflowRecoveryUsed = false;
|
|
169
|
+
control = reduceResetControl(control, { type: "settled" }).state;
|
|
121
170
|
});
|
|
122
|
-
pi.on("session_start", () => { clear();
|
|
171
|
+
pi.on("session_start", () => { clear(); sessionActive = true; });
|
|
123
172
|
pi.on("session_tree", clear);
|
|
124
|
-
pi.on("session_shutdown", () => { clear(); options.budget.clear();
|
|
173
|
+
pi.on("session_shutdown", () => { clear(); options.budget.clear(); sessionActive = false; });
|
|
125
174
|
return {
|
|
126
175
|
request() {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
return "rollover_requested";
|
|
176
|
+
const decision = reduceResetControl(control, { type: "request" });
|
|
177
|
+
control = decision.state;
|
|
178
|
+
return decision.effect === "already-requested" ? "rollover_already_pending" : "rollover_requested";
|
|
131
179
|
},
|
|
132
180
|
clear,
|
|
133
181
|
};
|
|
@@ -1,102 +1,16 @@
|
|
|
1
1
|
import { getCurrentSystemMessage, Type } from "@earendil-works/pi-ai";
|
|
2
2
|
import { VERSION, defineTool } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
3
|
import { registerBudget } from "./budget.js";
|
|
5
4
|
import { output } from "../tool-output.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { loadNotesSnapshot } 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 } from "./boot.js";
|
|
12
10
|
// The bundle captures its identity; direct source loads must not claim a built hash.
|
|
13
11
|
const buildLabel = typeof __PI_CONTEXT_BUILD__ === "undefined"
|
|
14
12
|
? "unbundled source (build unknown)"
|
|
15
13
|
: `${__PI_CONTEXT_BUILD__.version} · build ${__PI_CONTEXT_BUILD__.sourceHash.slice(0, 12)}`;
|
|
16
|
-
function bootContent(ctx, currentId, previousId, resetLine, notes) {
|
|
17
|
-
return renderBootBlock({
|
|
18
|
-
agentName: agentSlug(ctx),
|
|
19
|
-
modelName: modelSlug(ctx),
|
|
20
|
-
firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
|
|
21
|
-
currentWindowId: currentId,
|
|
22
|
-
previousWindowId: previousId,
|
|
23
|
-
resetLine,
|
|
24
|
-
notes,
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
function buildResetDrafts(ctx, notifyIncompleteNotes) {
|
|
28
|
-
const sessionPrefix = ctx.sessionManager.getSessionId().slice(0, 8);
|
|
29
|
-
const usedWindowIds = new Set(ctx.sessionManager.getBranch().filter(isWindowMarker).map((entry) => entry.data.windowId));
|
|
30
|
-
let windowId;
|
|
31
|
-
do {
|
|
32
|
-
windowId = `pcw:${sessionPrefix}:${randomUUID().slice(0, 8)}`;
|
|
33
|
-
} while (usedWindowIds.has(windowId));
|
|
34
|
-
const notes = loadNotesSnapshot(ctx);
|
|
35
|
-
notifyIncompleteNotes?.(ctx, windowId, notes);
|
|
36
|
-
return [
|
|
37
|
-
{ type: "custom", customType: RESET_MARKER_TYPE, data: { windowId } },
|
|
38
|
-
{
|
|
39
|
-
type: "custom_message",
|
|
40
|
-
customType: BOOT_TYPE,
|
|
41
|
-
content: bootContent(ctx, windowId, currentWindowId(ctx), true, notes),
|
|
42
|
-
display: false,
|
|
43
|
-
details: { windowId },
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
type: "custom_message",
|
|
47
|
-
customType: CONTINUATION_TYPE,
|
|
48
|
-
content: CONTINUATION,
|
|
49
|
-
display: false,
|
|
50
|
-
},
|
|
51
|
-
];
|
|
52
|
-
}
|
|
53
|
-
function ensureBoot(pi, ctx, notifyIncompleteNotes) {
|
|
54
|
-
const reset = currentReset(ctx);
|
|
55
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
56
|
-
const windowId = reset?.data?.windowId ?? rootWindowId(sessionId);
|
|
57
|
-
if (ctx.sessionManager.buildSessionProjection().messages.some((message) => isWindowBoot(message, windowId)))
|
|
58
|
-
return;
|
|
59
|
-
if (reset && !resetBootMayBeRepaired(ctx, reset.id, windowId))
|
|
60
|
-
return;
|
|
61
|
-
let previousId = reset ? rootWindowId(sessionId) : undefined;
|
|
62
|
-
if (reset) {
|
|
63
|
-
for (const entry of ctx.sessionManager.getBranch()) {
|
|
64
|
-
if (entry.id === reset.id)
|
|
65
|
-
break;
|
|
66
|
-
if (isWindowMarker(entry))
|
|
67
|
-
previousId = entry.data.windowId;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
const notes = loadNotesSnapshot(ctx);
|
|
71
|
-
notifyIncompleteNotes?.(ctx, windowId, notes);
|
|
72
|
-
pi.sendMessage({ customType: BOOT_TYPE, content: bootContent(ctx, windowId, previousId, reset !== undefined, notes), display: false, details: { windowId } }, { triggerTurn: false });
|
|
73
|
-
}
|
|
74
|
-
function persistManualReset(pi, ctx, notifyIncompleteNotes) {
|
|
75
|
-
const [marker, boot] = buildResetDrafts(ctx, notifyIncompleteNotes);
|
|
76
|
-
pi.appendEntry(marker.customType, marker.data);
|
|
77
|
-
pi.sendMessage({ customType: boot.customType, content: boot.content, display: boot.display, details: boot.details }, { triggerTurn: false });
|
|
78
|
-
return boot.details.windowId;
|
|
79
|
-
}
|
|
80
|
-
function resetBootMayBeRepaired(ctx, markerId, windowId) {
|
|
81
|
-
const branch = ctx.sessionManager.getBranch();
|
|
82
|
-
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
83
|
-
if (markerIndex < 0)
|
|
84
|
-
return false;
|
|
85
|
-
const afterMarker = branch.slice(markerIndex + 1);
|
|
86
|
-
// A raw boot is authoritative even when a later context_edit hides it from the
|
|
87
|
-
// projection. Appending another boot at the tail would move the boundary.
|
|
88
|
-
if (afterMarker.some((entry) => isWindowBootEntry(entry, windowId)))
|
|
89
|
-
return false;
|
|
90
|
-
// Only a genuinely incomplete marker tail can be repaired. Once conversation or
|
|
91
|
-
// a context-bearing custom message follows it, refusing is safer than guessing.
|
|
92
|
-
return !afterMarker.some((entry) => entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary");
|
|
93
|
-
}
|
|
94
|
-
function isWindowBootEntry(entry, windowId) {
|
|
95
|
-
return entry.type === "custom_message" && entry.customType === BOOT_TYPE &&
|
|
96
|
-
typeof entry.details === "object" && entry.details !== null &&
|
|
97
|
-
typeof entry.details.windowId === "string" &&
|
|
98
|
-
entry.details.windowId === windowId;
|
|
99
|
-
}
|
|
100
14
|
function branchHasWindowMarker(ctx, fromId) {
|
|
101
15
|
return ctx.sessionManager.getBranch(fromId).some((entry) => isWindowMarker(entry));
|
|
102
16
|
}
|
|
@@ -106,6 +20,8 @@ export function registerContext(pi, settingsManager) {
|
|
|
106
20
|
let missingBootNotice;
|
|
107
21
|
const incompleteNotesNotified = new Set();
|
|
108
22
|
const pendingResetNotices = new Set();
|
|
23
|
+
// Announce only a fully committed reset (marker + matching boot + continuation), not a
|
|
24
|
+
// reset request or a partial boot repair.
|
|
109
25
|
const notifyCommittedResets = (ctx, addedWindowId) => {
|
|
110
26
|
if (addedWindowId)
|
|
111
27
|
pendingResetNotices.add(addedWindowId);
|
|
@@ -113,14 +29,13 @@ export function registerContext(pi, settingsManager) {
|
|
|
113
29
|
return;
|
|
114
30
|
const branch = ctx.sessionManager.getBranch();
|
|
115
31
|
for (const windowId of pendingResetNotices) {
|
|
116
|
-
|
|
117
|
-
|
|
32
|
+
const marker = branch.find((entry) => isWindowMarker(entry) && entry.data.windowId === windowId);
|
|
33
|
+
if (!marker || !resetTailCommitted(ctx, marker.id, windowId))
|
|
118
34
|
continue;
|
|
119
35
|
pendingResetNotices.delete(windowId);
|
|
120
36
|
ctx.ui.notify(`pi-context: memory cleared · ${windowId}`, "info");
|
|
121
37
|
}
|
|
122
38
|
};
|
|
123
|
-
// Announce only a committed reset (marker + boot), not a reset request or boot repair.
|
|
124
39
|
pi.on("turn_start", (_event, ctx) => notifyCommittedResets(ctx));
|
|
125
40
|
pi.on("agent_settled", (_event, ctx) => {
|
|
126
41
|
notifyCommittedResets(ctx);
|
package/dist/src/index.js
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";
|
|
@@ -28,4 +28,4 @@ export function createPiContext(options = {}) {
|
|
|
28
28
|
export default function piContext(pi) {
|
|
29
29
|
registerPiContext(pi);
|
|
30
30
|
}
|
|
31
|
-
export const internal = { MAX_NOTE_BYTES, NOTE_TYPE, BOOT_TYPE, GUIDANCE_TYPE, WARNING_TYPE, CONTINUATION_TYPE, WARNING_PROMPT, WARNING_RUNWAY_TOKENS, RESET_MARKER_TYPE,
|
|
31
|
+
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 };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agentSlug, modelSlug, SLUG_PATTERN } from "./paths.js";
|
|
2
|
-
export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/,
|
|
2
|
+
export const ADDRESS_FORMS = "legal prefixes are @project/, @human/, @self/, and @model/; bare names are this session";
|
|
3
3
|
export function assertVirtualPath(value) {
|
|
4
4
|
if (typeof value !== "string" || value.length === 0)
|
|
5
5
|
throw new Error("path must be a non-empty virtual relative path");
|