@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
package/dist/src/notes/tools.js
CHANGED
|
@@ -8,7 +8,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
|
|
|
8
8
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
9
9
|
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.",
|
|
10
10
|
}));
|
|
11
|
-
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project, `@human/<vpath>` for the human's cross-project
|
|
11
|
+
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.";
|
|
12
12
|
function failure(error) {
|
|
13
13
|
if (error instanceof NoteError) {
|
|
14
14
|
const payload = { error: error.message };
|
|
@@ -79,7 +79,7 @@ export function registerNotesTools(pi) {
|
|
|
79
79
|
}));
|
|
80
80
|
pi.registerTool(defineTool({
|
|
81
81
|
name: "notes_list", label: "Notes list",
|
|
82
|
-
description: `List note files as rows carrying address, updated_at, and stale, most recently updated first. ${ADDRESS_DESCRIPTION} Listings merge your five
|
|
82
|
+
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/.`,
|
|
83
83
|
parameters: Type.Object({ pattern: nullableString(), cursor: cursor(), max_results: positiveInteger() }, { additionalProperties: false }),
|
|
84
84
|
async execute(_id, params, _signal, _update, ctx) {
|
|
85
85
|
let rows;
|
|
@@ -100,7 +100,7 @@ export function registerNotesTools(pi) {
|
|
|
100
100
|
}));
|
|
101
101
|
pi.registerTool(defineTool({
|
|
102
102
|
name: "notes_search", label: "Notes search",
|
|
103
|
-
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
|
|
103
|
+
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.`,
|
|
104
104
|
parameters: Type.Object({ query: searchQuery(), pattern: nullableString(), cursor: cursor(), max_matches_per_file: positiveInteger(), max_files: positiveInteger() }, { additionalProperties: false }),
|
|
105
105
|
async execute(_id, params, _signal, _update, ctx) {
|
|
106
106
|
const queries = searchQueries(params.query);
|
package/dist/src/protocol.js
CHANGED
|
@@ -32,8 +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
|
-
export const CONTINUATION = "Your memory was just erased.
|
|
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.";
|
|
37
37
|
/**
|
|
38
38
|
* Static protocol teaching adapted from Codex's token_budget.guidance_message to
|
|
39
39
|
* pi-context's tool names. It lives once per window in the persisted boot block;
|
|
@@ -47,9 +47,7 @@ Keep a running checkpoint while you work, not at the last minute — the next wi
|
|
|
47
47
|
|
|
48
48
|
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.
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
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.
|
|
50
|
+
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.
|
|
53
51
|
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.
|
|
54
52
|
@project notes hold facts about this project — architecture, conventions, workflows, deployment and environment details — for whoever works here next.
|
|
55
53
|
@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.
|
|
@@ -6,7 +6,7 @@ import test from "node:test";
|
|
|
6
6
|
import { createAssistantMessageEventStream, getCurrentSystemMessage } from "@earendil-works/pi-ai";
|
|
7
7
|
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import piContext, { createPiContext } from "../src/index.js";
|
|
9
|
-
import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_TYPE } from "../src/protocol.js";
|
|
9
|
+
import { BOOT_TYPE, CONTEXT_WINDOW_OPEN_TAG, CONTINUATION, CONTINUATION_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_TYPE, RESET_MARKER_TYPE, WARNING_TYPE } from "../src/protocol.js";
|
|
10
10
|
async function openFixture(options) {
|
|
11
11
|
const dir = options.cwd ?? mkdtempSync(join(tmpdir(), "pi-context-agent-loop-"));
|
|
12
12
|
const ownsDir = options.cwd === undefined;
|
|
@@ -59,6 +59,7 @@ async function openFixture(options) {
|
|
|
59
59
|
const streamSignals = [];
|
|
60
60
|
const events = [];
|
|
61
61
|
const notices = [];
|
|
62
|
+
let budgetNotices = 0;
|
|
62
63
|
let session;
|
|
63
64
|
const loader = new DefaultResourceLoader({
|
|
64
65
|
cwd: dir,
|
|
@@ -120,16 +121,14 @@ async function openFixture(options) {
|
|
|
120
121
|
};
|
|
121
122
|
await session.bindExtensions({
|
|
122
123
|
uiContext: {
|
|
123
|
-
notify(message) {
|
|
124
|
+
notify(message, type) {
|
|
124
125
|
if (message.startsWith("pi-context: memory cleared · ")) {
|
|
125
126
|
const windowId = message.split(" · ")[1];
|
|
126
127
|
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom" && entry.customType === RESET_MARKER_TYPE && entry.data?.windowId === windowId), "notification follows the reset marker commit");
|
|
127
128
|
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === BOOT_TYPE && entry.details?.windowId === windowId), "notification follows the reset boot commit");
|
|
128
129
|
}
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
assert.ok(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && entry.customType === customType), "budget notifications follow the reminder commit");
|
|
132
|
-
}
|
|
130
|
+
if (type === "warning" && sessionManager.getBranch().some((entry) => entry.type === "custom_message" && (entry.customType === GUIDANCE_TYPE || entry.customType === WARNING_TYPE)))
|
|
131
|
+
budgetNotices++;
|
|
133
132
|
notices.push(message);
|
|
134
133
|
},
|
|
135
134
|
},
|
|
@@ -145,6 +144,7 @@ async function openFixture(options) {
|
|
|
145
144
|
streamSignals,
|
|
146
145
|
events,
|
|
147
146
|
notices,
|
|
147
|
+
budgetNotices: () => budgetNotices,
|
|
148
148
|
close: () => {
|
|
149
149
|
session.dispose();
|
|
150
150
|
if (managesEnvironment) {
|
|
@@ -211,6 +211,7 @@ function assertFreshRequest(fixture, requestIndex, oldSentinel) {
|
|
|
211
211
|
const body = text(fixture.requests[requestIndex]);
|
|
212
212
|
assert.equal(body.includes(oldSentinel), false, "the new provider request excludes the old window transcript");
|
|
213
213
|
assert.ok(body.includes(CONTEXT_WINDOW_OPEN_TAG), "the new provider request includes the fresh context-window boot");
|
|
214
|
+
assert.equal(body.split(CONTINUATION).length - 1, 1, "the fresh window carries exactly one reset message");
|
|
214
215
|
}
|
|
215
216
|
test("real AgentSession: aborted low-budget requests notify only after a retry commits the reminder", async () => {
|
|
216
217
|
let fixture;
|
|
@@ -220,22 +221,22 @@ test("real AgentSession: aborted low-budget requests notify only after a retry c
|
|
|
220
221
|
});
|
|
221
222
|
try {
|
|
222
223
|
const reminders = () => fixture.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE);
|
|
223
|
-
const notices = () => fixture.
|
|
224
|
+
const notices = () => fixture.budgetNotices();
|
|
224
225
|
await fixture.session.prompt("Establish usage below the reminder line.");
|
|
225
226
|
await fixture.session.waitForIdle();
|
|
226
227
|
assert.equal(reminders().length, 0);
|
|
227
228
|
await fixture.session.prompt("Abort this low-budget request.");
|
|
228
229
|
await fixture.session.waitForIdle();
|
|
229
230
|
assert.equal(reminders().length, 0, "an aborted turn does not commit its reminder");
|
|
230
|
-
assert.equal(notices()
|
|
231
|
+
assert.equal(notices(), 0, "an uncommitted reminder never notifies");
|
|
231
232
|
await fixture.session.prompt("Retry successfully.");
|
|
232
233
|
await fixture.session.waitForIdle();
|
|
233
234
|
assert.equal(reminders().length, 1);
|
|
234
|
-
assert.equal(notices()
|
|
235
|
+
assert.equal(notices(), 1, "the committed retry notifies once");
|
|
235
236
|
await fixture.session.prompt("Continue in the same window.");
|
|
236
237
|
await fixture.session.waitForIdle();
|
|
237
238
|
assert.equal(reminders().length, 1);
|
|
238
|
-
assert.equal(notices()
|
|
239
|
+
assert.equal(notices(), 1, "later turns cannot repeat the notice");
|
|
239
240
|
}
|
|
240
241
|
finally {
|
|
241
242
|
fixture.close();
|
|
@@ -610,6 +611,7 @@ test("real AgentSession: concurrent trusted projects keep reserve and automatic
|
|
|
610
611
|
]);
|
|
611
612
|
await Promise.all([automatic.session.waitForIdle(), modelInvalidated.session.waitForIdle(), sessionInvalidated.session.waitForIdle()]);
|
|
612
613
|
assert.equal(resetMarkers(automatic).length, 1, "the low reserve and enabled project resets automatically");
|
|
614
|
+
assert.equal(automatic.sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 1, "the automatic reset persists exactly one continuation");
|
|
613
615
|
assert.equal(resetMarkers(modelInvalidated).length, 0, "the high reserve and disabled project does not reset");
|
|
614
616
|
assert.equal(resetMarkers(sessionInvalidated).length, 0, "the second high reserve and disabled session does not reset");
|
|
615
617
|
writeFileSync(join(cwdModel, ".pi", "settings.json"), JSON.stringify({ compaction: { enabled: true, reserveTokens: 20_000 } }));
|
|
@@ -58,6 +58,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
|
|
|
58
58
|
assert.deepEqual(rootBoot?.message.details, { windowId: `pcw:${sessionManager.getSessionId().slice(0, 8)}:root` });
|
|
59
59
|
const rootText = typeof rootBoot?.message.content === "string" ? rootBoot.message.content : "";
|
|
60
60
|
assert.ok(rootText.startsWith(internal.CONTEXT_WINDOW_OPEN_TAG), "root block omits the reset line");
|
|
61
|
+
assert.equal(rootText.includes(internal.CONTINUATION), false, "root startup carries no reset message");
|
|
61
62
|
assert.equal(rootText.includes("Previous context window id:"), false, "root block omits the previous-id line");
|
|
62
63
|
assert.match(rootText, new RegExp(`First context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
|
|
63
64
|
assert.match(rootText, new RegExp(`Current context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
|
|
@@ -84,7 +85,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
|
|
|
84
85
|
const continuation = entries.find((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE);
|
|
85
86
|
assert.ok(continuation && continuation.type === "custom_message" && continuation.display === false, "the resumed run is represented by one hidden continuation");
|
|
86
87
|
});
|
|
87
|
-
test("a marker tail with only metadata repairs its missing boot without moving the boundary", () => {
|
|
88
|
+
test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", () => {
|
|
88
89
|
const sessionManager = manager(true);
|
|
89
90
|
const windowId = "pcw:metadata-tail";
|
|
90
91
|
const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
|
|
@@ -96,11 +97,60 @@ test("a marker tail with only metadata repairs its missing boot without moving t
|
|
|
96
97
|
const branch = sessionManager.getBranch();
|
|
97
98
|
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
98
99
|
const bootEntries = branch.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE && entry.details && typeof entry.details === "object" && entry.details.windowId === windowId);
|
|
100
|
+
const continuationEntries = branch.filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE);
|
|
99
101
|
assert.equal(bootEntries.length, 1, "an incomplete marker tail gets one repaired boot");
|
|
102
|
+
assert.equal(continuationEntries.length, 1, "the same repair completes the missing continuation");
|
|
100
103
|
assert.ok(branch.findIndex((entry) => entry.id === modelChangeId) > markerIndex, "metadata remains after the marker");
|
|
101
104
|
assert.ok(branch.findIndex((entry) => entry.id === bootEntries[0]?.id) > markerIndex, "the repaired boot remains in the marked window");
|
|
102
|
-
assert.
|
|
103
|
-
assert.equal(
|
|
105
|
+
assert.ok(branch.findIndex((entry) => entry.id === continuationEntries[0]?.id) > branch.findIndex((entry) => entry.id === bootEntries[0]?.id), "the continuation follows its boot");
|
|
106
|
+
assert.equal(captured.sent.length, 2, "repair emits only the missing boot and continuation, without a model turn");
|
|
107
|
+
assert.equal(captured.sent[0]?.message.customType, internal.BOOT_TYPE);
|
|
108
|
+
assert.equal(captured.sent[1]?.message.customType, CONTINUATION_TYPE);
|
|
109
|
+
assert.equal(noticesOf(ctx).length, 0, "repairing a reset tail is not a new reset");
|
|
110
|
+
});
|
|
111
|
+
test("a bare marker tail repairs the full ordered reset shape", () => {
|
|
112
|
+
const sessionManager = manager(true);
|
|
113
|
+
const windowId = "pcw:bare-marker";
|
|
114
|
+
const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
|
|
115
|
+
const captured = makeExtension(sessionManager);
|
|
116
|
+
const ctx = context(sessionManager);
|
|
117
|
+
runHandlers(captured, "session_start", { reason: "startup" }, ctx);
|
|
118
|
+
assert.equal(captured.sent.length, 2, "a bare marker emits a boot and a continuation");
|
|
119
|
+
const branch = sessionManager.getBranch();
|
|
120
|
+
const markerIndex = branch.findIndex((entry) => entry.id === markerId);
|
|
121
|
+
const bootIndex = branch.findIndex((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE && entry.details?.windowId === windowId);
|
|
122
|
+
const continuationIndex = branch.findIndex((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE);
|
|
123
|
+
assert.ok(bootIndex > markerIndex, "the repaired boot follows its marker");
|
|
124
|
+
assert.ok(continuationIndex > bootIndex, "the repaired continuation follows its boot");
|
|
125
|
+
});
|
|
126
|
+
test("a marker tail that already carries a boot repairs only its missing continuation, once", () => {
|
|
127
|
+
const sessionManager = manager(true);
|
|
128
|
+
const windowId = "pcw:missing-continuation";
|
|
129
|
+
const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
|
|
130
|
+
sessionManager.appendModelChange("openai", "scripted-model");
|
|
131
|
+
sessionManager.appendCustomMessageEntry(internal.BOOT_TYPE, "already-persisted boot", false, { windowId });
|
|
132
|
+
const captured = makeExtension(sessionManager);
|
|
133
|
+
const ctx = context(sessionManager);
|
|
134
|
+
runHandlers(captured, "session_start", { reason: "startup" }, ctx);
|
|
135
|
+
assert.equal(captured.sent.length, 1, "only the missing continuation is emitted");
|
|
136
|
+
assert.equal(captured.sent[0]?.message.customType, CONTINUATION_TYPE);
|
|
137
|
+
assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 1, "the existing boot is never duplicated");
|
|
138
|
+
assert.ok(sessionManager.getBranch().findIndex((entry) => entry.id === markerId) >= 0);
|
|
139
|
+
runHandlers(captured, "session_start", { reason: "reload" }, ctx);
|
|
140
|
+
assert.equal(captured.sent.length, 1, "a complete reset tail is idempotent");
|
|
141
|
+
assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 1);
|
|
142
|
+
});
|
|
143
|
+
test("a marker tail followed by real conversation is never repaired", () => {
|
|
144
|
+
const sessionManager = manager(true);
|
|
145
|
+
const windowId = "pcw:unsafe-tail";
|
|
146
|
+
sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
|
|
147
|
+
appendText(sessionManager, "user", "post-marker work that must not be pushed behind a late boot");
|
|
148
|
+
const captured = makeExtension(sessionManager);
|
|
149
|
+
const ctx = context(sessionManager);
|
|
150
|
+
runHandlers(captured, "session_start", { reason: "startup" }, ctx);
|
|
151
|
+
assert.equal(captured.sent.length, 0, "an unsafe marker tail emits nothing");
|
|
152
|
+
assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 0, "no boot is appended after real work");
|
|
153
|
+
assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 0, "no continuation is appended after real work");
|
|
104
154
|
});
|
|
105
155
|
test("off preserves an existing marker window and still cancels native compaction", async () => {
|
|
106
156
|
const sessionManager = manager();
|
|
@@ -152,8 +202,10 @@ test("pi-context command toggles future work, /wipe-memory is the manual path, a
|
|
|
152
202
|
notices = await runCommand(captured, "wipe-memory", "", low);
|
|
153
203
|
assert.equal(notices.length, 1, "manual clear emits exactly one notification");
|
|
154
204
|
assert.match(notices[0]?.message ?? "", /memory cleared/);
|
|
155
|
-
assert.equal(captured.sent.length,
|
|
205
|
+
assert.equal(captured.sent.length, 3, "/wipe-memory writes one hidden boot and one continuation without triggering a model turn");
|
|
156
206
|
assert.equal(captured.sent[1]?.options?.triggerTurn, false);
|
|
207
|
+
assert.equal(captured.sent[1]?.message.customType, internal.BOOT_TYPE);
|
|
208
|
+
assert.equal(captured.sent[2]?.message.customType, CONTINUATION_TYPE);
|
|
157
209
|
assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 1);
|
|
158
210
|
const markerContext = await runManualCompact(captured, low);
|
|
159
211
|
assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while a marker is active");
|
|
@@ -130,14 +130,13 @@ export function makeExtension(sessionManager, settingsManager) {
|
|
|
130
130
|
(settingsManager ? createPiContext({ settingsManager }) : piContext)(api);
|
|
131
131
|
return captured;
|
|
132
132
|
}
|
|
133
|
-
export function explicitBoot(ctx, currentWindowId, previousWindowId
|
|
133
|
+
export function explicitBoot(ctx, currentWindowId, previousWindowId) {
|
|
134
134
|
return renderBootBlock({
|
|
135
135
|
agentName: agentSlug(ctx),
|
|
136
136
|
modelName: modelSlug(ctx),
|
|
137
137
|
firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
|
|
138
138
|
currentWindowId,
|
|
139
139
|
previousWindowId,
|
|
140
|
-
resetLine,
|
|
141
140
|
notes: loadNotesSnapshot(ctx),
|
|
142
141
|
});
|
|
143
142
|
}
|
|
@@ -130,7 +130,6 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
130
130
|
firstWindowId: "pcw:test:root",
|
|
131
131
|
currentWindowId: "pcw:test:next",
|
|
132
132
|
previousWindowId: "pcw:test:root",
|
|
133
|
-
resetLine: true,
|
|
134
133
|
notes: snapshot,
|
|
135
134
|
};
|
|
136
135
|
const rendered = renderBootBlock(renderData);
|
|
@@ -150,7 +149,6 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
150
149
|
modelName: "default",
|
|
151
150
|
firstWindowId: "pcw:test:root",
|
|
152
151
|
currentWindowId: "pcw:test:next",
|
|
153
|
-
resetLine: true,
|
|
154
152
|
notes: oneFailed,
|
|
155
153
|
});
|
|
156
154
|
assert.ok(oneFailedText.includes("PROJECT_MAP_BODY") && oneFailedText.includes("notes_list can retry after recovery"), "healthy homes and the recovery notice survive one failure");
|
|
@@ -165,7 +163,6 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
165
163
|
firstWindowId: "pcw:test:root",
|
|
166
164
|
currentWindowId: "pcw:test:next",
|
|
167
165
|
previousWindowId: "pcw:test:root",
|
|
168
|
-
resetLine: true,
|
|
169
166
|
notes: allFailed,
|
|
170
167
|
});
|
|
171
168
|
assert.ok(allFailedText.includes("pcw:test:root") && allFailedText.includes("pcw:test:next"), "identity survives an all-home failure");
|
|
@@ -176,7 +173,7 @@ test("boot note acquisition is one closed snapshot and isolates one or all faile
|
|
|
176
173
|
});
|
|
177
174
|
test("the boot block gives awake agents the notes-home file layout", () => {
|
|
178
175
|
const session = manager();
|
|
179
|
-
const rendered = explicitBoot(context(session), "pcw:test:root", undefined
|
|
176
|
+
const rendered = explicitBoot(context(session), "pcw:test:root", undefined);
|
|
180
177
|
assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
|
|
181
178
|
assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@human\/<vpath>/);
|
|
182
179
|
});
|
package/dist/test/notes.test.js
CHANGED
|
@@ -205,8 +205,8 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
|
|
|
205
205
|
assert.ok(existsSync(physicalPath("human", "same.md", ctx)), "@human writes to the human home");
|
|
206
206
|
assert.match(resultRead(await call(captured, "notes_read", { address: "same.md" }, ctx)).content, /session$/);
|
|
207
207
|
assert.equal(resultJson(await call(captured, "notes_read", { address: "@project/missing.md" }, ctx)).error, "note not found");
|
|
208
|
-
await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@human\/.*bare names are
|
|
209
|
-
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are
|
|
208
|
+
await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@human\/.*bare names are this session/);
|
|
209
|
+
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are this session/);
|
|
210
210
|
assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
|
|
211
211
|
});
|
|
212
212
|
const FRONTMATTER = (body) => `---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00.000+00:00\nupdated_at: 2026-01-01T00:00:00.000+00:00\nlast_accessed: 2026-01-01T00:00:00.000+00:00\naccess_count: 0\n---\n\n${body}`;
|
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import { SessionManager as Manager } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import piContext, { internal } from "../src/index.js";
|
|
8
|
-
import { registerResetLifecycle } from "../src/context/reset-lifecycle.js";
|
|
8
|
+
import { initialResetControl, reduceResetControl, registerResetLifecycle, } from "../src/context/reset-lifecycle.js";
|
|
9
9
|
const previousNotesHome = process.env.PI_NOTES_HOME;
|
|
10
10
|
const testNotesHome = mkdtempSync(join(tmpdir(), "pi-context-lifecycle-notes-"));
|
|
11
11
|
process.env.PI_NOTES_HOME = testNotesHome;
|
|
@@ -107,6 +107,27 @@ function appendDrafts(sessionManager, entries) {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
|
+
function turnEndFacts(overrides = {}) {
|
|
111
|
+
return {
|
|
112
|
+
aborted: false,
|
|
113
|
+
overflow: false,
|
|
114
|
+
failed: false,
|
|
115
|
+
enabled: true,
|
|
116
|
+
queued: false,
|
|
117
|
+
automaticResetEnabled: true,
|
|
118
|
+
thresholdDue: false,
|
|
119
|
+
...overrides,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function beforeSettleFacts(overrides = {}) {
|
|
123
|
+
return {
|
|
124
|
+
queued: false,
|
|
125
|
+
enabled: true,
|
|
126
|
+
automaticResetEnabled: true,
|
|
127
|
+
aborted: false,
|
|
128
|
+
...overrides,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
110
131
|
function fakeBoundaryEvent(entries = []) {
|
|
111
132
|
return {
|
|
112
133
|
type: "turn_end",
|
|
@@ -140,6 +161,9 @@ test("public reset boundary drafts one marker, one boot, and one continuation af
|
|
|
140
161
|
const windowId = markerDrafts[0].data.windowId;
|
|
141
162
|
assert.match(windowId, /^pcw:/);
|
|
142
163
|
assert.equal(bootDrafts[0].details.windowId, windowId);
|
|
164
|
+
const continuationDrafts = boundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.CONTINUATION_TYPE);
|
|
165
|
+
assert.equal(continuationDrafts.length, 1, "the boundary persists exactly one reset message");
|
|
166
|
+
assert.equal(boundary.entries[3]?.type, "custom_message", "the continuation closes the ordered reset shape");
|
|
143
167
|
appendDrafts(h.sessionManager, boundary.entries);
|
|
144
168
|
const branch = h.sessionManager.getBranch();
|
|
145
169
|
assert.deepEqual(branch.filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).map((entry) => entry.type === "custom" ? entry.data : undefined), [{ windowId }]);
|
|
@@ -165,8 +189,10 @@ test("off stops future automatic/manual reset requests while an existing marker
|
|
|
165
189
|
assert.equal(afterOff.entries.length, 0, "off does not create another reset");
|
|
166
190
|
await h.runCommand("pi-context", "on");
|
|
167
191
|
await h.runCommand("wipe-memory");
|
|
168
|
-
assert.equal(h.sent.length,
|
|
192
|
+
assert.equal(h.sent.length, 2, "/wipe-memory writes one hidden boot and one continuation without a model turn");
|
|
169
193
|
assert.equal(h.sent[0]?.triggerTurn, false);
|
|
194
|
+
assert.equal(h.sent[0]?.customType, internal.BOOT_TYPE);
|
|
195
|
+
assert.equal(h.sent[1]?.customType, internal.CONTINUATION_TYPE);
|
|
170
196
|
const markers = h.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
|
|
171
197
|
assert.equal(markers.length, 2, "off does not resurrect history; re-enabled wipe-memory creates the explicit new marker");
|
|
172
198
|
});
|
|
@@ -262,3 +288,174 @@ test("a queued success clears an overflow failure before settle recovery can res
|
|
|
262
288
|
assert.equal(await beforeSettle(settled, ctx), undefined, "the successful queued turn clears the stale recovery");
|
|
263
289
|
assert.equal(resetCount, 0);
|
|
264
290
|
});
|
|
291
|
+
test("reset-control: an explicit request deduplicates and is consumed at the turn boundary", () => {
|
|
292
|
+
const idle = Object.freeze({ ...initialResetControl() });
|
|
293
|
+
const first = reduceResetControl(idle, { type: "request" });
|
|
294
|
+
assert.equal(first.effect, "requested");
|
|
295
|
+
assert.deepEqual(first.state, { request: "explicit", overflow: "idle" });
|
|
296
|
+
const again = reduceResetControl(first.state, { type: "request" });
|
|
297
|
+
assert.equal(again.effect, "already-requested");
|
|
298
|
+
assert.deepEqual(again.state, first.state, "a duplicate request does not change state");
|
|
299
|
+
const committed = reduceResetControl(again.state, { type: "turn_end", facts: turnEndFacts() });
|
|
300
|
+
assert.equal(committed.effect, "commit-boundary");
|
|
301
|
+
assert.deepEqual(committed.state, initialResetControl(), "the request is consumed whether or not it commits");
|
|
302
|
+
});
|
|
303
|
+
test("reset-control: turn_end commits explicit and threshold resets, and skips disabled or failed turns", () => {
|
|
304
|
+
const explicit = reduceResetControl({ request: "explicit", overflow: "idle" }, { type: "turn_end", facts: turnEndFacts() });
|
|
305
|
+
assert.equal(explicit.effect, "commit-boundary");
|
|
306
|
+
const threshold = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ thresholdDue: true }) });
|
|
307
|
+
assert.equal(threshold.effect, "commit-boundary");
|
|
308
|
+
const neither = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts() });
|
|
309
|
+
assert.equal(neither.effect, "none");
|
|
310
|
+
const disabled = reduceResetControl({ request: "explicit", overflow: "idle" }, { type: "turn_end", facts: turnEndFacts({ enabled: false, thresholdDue: true }) });
|
|
311
|
+
assert.equal(disabled.effect, "none");
|
|
312
|
+
assert.deepEqual(disabled.state, initialResetControl(), "a disabled turn drops the explicit request without committing");
|
|
313
|
+
const failed = reduceResetControl({ request: "explicit", overflow: "idle" }, { type: "turn_end", facts: turnEndFacts({ failed: true, thresholdDue: true }) });
|
|
314
|
+
assert.equal(failed.effect, "none");
|
|
315
|
+
assert.deepEqual(failed.state, initialResetControl());
|
|
316
|
+
});
|
|
317
|
+
test("reset-control: aborted turns clear the boundary, settlement keeps only an explicit request", () => {
|
|
318
|
+
const both = { request: "explicit", overflow: "pending" };
|
|
319
|
+
const aborted = reduceResetControl(both, { type: "turn_end", facts: turnEndFacts({ aborted: true }) });
|
|
320
|
+
assert.equal(aborted.effect, "none");
|
|
321
|
+
assert.deepEqual(aborted.state, initialResetControl(), "an abort manufactures no continuation");
|
|
322
|
+
const settled = reduceResetControl(both, { type: "settled" });
|
|
323
|
+
assert.deepEqual(settled.state, { request: "explicit", overflow: "idle" }, "settlement ends the failure chain only");
|
|
324
|
+
const cleared = reduceResetControl(both, { type: "clear" });
|
|
325
|
+
assert.deepEqual(cleared.state, initialResetControl());
|
|
326
|
+
});
|
|
327
|
+
test("reset-control: overflow recovery is armed at turn_end and spent exactly once at settle", () => {
|
|
328
|
+
const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) });
|
|
329
|
+
assert.deepEqual(armed.state, { request: "none", overflow: "pending" });
|
|
330
|
+
const recovered = reduceResetControl(armed.state, { type: "before_settle", facts: beforeSettleFacts() });
|
|
331
|
+
assert.equal(recovered.effect, "recover-overflow", "the first settle commits the bounded recovery");
|
|
332
|
+
assert.deepEqual(recovered.state, { request: "none", overflow: "spent" });
|
|
333
|
+
const repeated = reduceResetControl(recovered.state, { type: "before_settle", facts: beforeSettleFacts() });
|
|
334
|
+
assert.equal(repeated.effect, "none", "a spent recovery is never retried");
|
|
335
|
+
const rearmed = reduceResetControl(recovered.state, { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) });
|
|
336
|
+
assert.deepEqual(rearmed.state, { request: "none", overflow: "pending-spent" });
|
|
337
|
+
const bounded = reduceResetControl(rearmed.state, { type: "before_settle", facts: beforeSettleFacts() });
|
|
338
|
+
assert.equal(bounded.effect, "none", "a second failure chain stays bounded to the spent attempt");
|
|
339
|
+
assert.deepEqual(bounded.state, { request: "none", overflow: "spent" });
|
|
340
|
+
});
|
|
341
|
+
test("reset-control: a queued turn defers recovery and its success supersedes the failure", () => {
|
|
342
|
+
const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
|
|
343
|
+
const deferred = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ queued: true }) });
|
|
344
|
+
assert.equal(deferred.effect, "none");
|
|
345
|
+
assert.deepEqual(deferred.state, armed, "a queued message leaves the overflow chain armed");
|
|
346
|
+
const success = reduceResetControl(armed, { type: "turn_end", facts: turnEndFacts() });
|
|
347
|
+
assert.deepEqual(success.state, initialResetControl(), "a successful queued turn clears the stale failure");
|
|
348
|
+
});
|
|
349
|
+
test("reset-control: disabled mode and explicit aborts disarm overflow without a recovery", () => {
|
|
350
|
+
const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
|
|
351
|
+
const disabled = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ enabled: false }) });
|
|
352
|
+
assert.equal(disabled.effect, "none");
|
|
353
|
+
assert.deepEqual(disabled.state, { request: "none", overflow: "idle" });
|
|
354
|
+
const automaticOff = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ automaticResetEnabled: false }) });
|
|
355
|
+
assert.equal(automaticOff.effect, "none");
|
|
356
|
+
assert.deepEqual(automaticOff.state, { request: "none", overflow: "idle" });
|
|
357
|
+
const aborted = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ aborted: true }) });
|
|
358
|
+
assert.equal(aborted.effect, "none");
|
|
359
|
+
assert.deepEqual(aborted.state, { request: "none", overflow: "idle" });
|
|
360
|
+
});
|
|
361
|
+
test("reset-control: policy guards are consulted only in the branches that need them", () => {
|
|
362
|
+
const tracked = (overrides) => {
|
|
363
|
+
const calls = { queued: 0, automatic: 0, threshold: 0 };
|
|
364
|
+
const facts = {
|
|
365
|
+
aborted: overrides.aborted ?? false,
|
|
366
|
+
overflow: overrides.overflow ?? false,
|
|
367
|
+
failed: overrides.failed ?? false,
|
|
368
|
+
enabled: overrides.enabled ?? true,
|
|
369
|
+
get queued() { calls.queued += 1; return overrides.queuedResult ?? false; },
|
|
370
|
+
get automaticResetEnabled() { calls.automatic += 1; return overrides.automaticResult ?? true; },
|
|
371
|
+
get thresholdDue() { calls.threshold += 1; return overrides.thresholdResult ?? true; },
|
|
372
|
+
};
|
|
373
|
+
return { facts, calls };
|
|
374
|
+
};
|
|
375
|
+
const aborted = tracked({ aborted: true });
|
|
376
|
+
reduceResetControl(initialResetControl(), { type: "turn_end", facts: aborted.facts });
|
|
377
|
+
assert.deepEqual(aborted.calls, { queued: 0, automatic: 0, threshold: 0 }, "an abort consults no policy guard");
|
|
378
|
+
const failed = tracked({ failed: true });
|
|
379
|
+
reduceResetControl(initialResetControl(), { type: "turn_end", facts: failed.facts });
|
|
380
|
+
assert.deepEqual(failed.calls, { queued: 0, automatic: 0, threshold: 0 }, "a failed turn consults no threshold guard");
|
|
381
|
+
const disabled = tracked({ enabled: false });
|
|
382
|
+
reduceResetControl(initialResetControl(), { type: "turn_end", facts: disabled.facts });
|
|
383
|
+
assert.deepEqual(disabled.calls, { queued: 0, automatic: 0, threshold: 0 }, "a disabled turn consults no threshold guard");
|
|
384
|
+
const overflow = tracked({ overflow: true, queuedResult: false });
|
|
385
|
+
reduceResetControl(initialResetControl(), { type: "turn_end", facts: overflow.facts });
|
|
386
|
+
assert.deepEqual(overflow.calls, { queued: 1, automatic: 1, threshold: 0 }, "an overflow turn consults only the overflow guards");
|
|
387
|
+
const complete = tracked({ thresholdResult: false });
|
|
388
|
+
reduceResetControl(initialResetControl(), { type: "turn_end", facts: complete.facts });
|
|
389
|
+
assert.deepEqual(complete.calls, { queued: 0, automatic: 0, threshold: 1 }, "a completed turn consults only the threshold guard");
|
|
390
|
+
const settleCalls = { queued: 0, enabled: 0, automatic: 0 };
|
|
391
|
+
const settleFacts = {
|
|
392
|
+
get queued() { settleCalls.queued += 1; return true; },
|
|
393
|
+
get enabled() { settleCalls.enabled += 1; return true; },
|
|
394
|
+
get automaticResetEnabled() { settleCalls.automatic += 1; return true; },
|
|
395
|
+
aborted: false,
|
|
396
|
+
};
|
|
397
|
+
reduceResetControl({ request: "none", overflow: "pending" }, { type: "before_settle", facts: settleFacts });
|
|
398
|
+
assert.deepEqual(settleCalls, { queued: 1, enabled: 0, automatic: 0 }, "a queued settle consults only the pending-message guard");
|
|
399
|
+
});
|
|
400
|
+
test("a committed reset places incoming and budget drafts before marker -> boot -> continuation", async () => {
|
|
401
|
+
const sessionManager = Manager.inMemory("/private/tmp/pi-context-reset-ordering-test");
|
|
402
|
+
const handlers = new Map();
|
|
403
|
+
const api = {
|
|
404
|
+
on(name, handler) {
|
|
405
|
+
const list = handlers.get(name) ?? [];
|
|
406
|
+
list.push(handler);
|
|
407
|
+
handlers.set(name, list);
|
|
408
|
+
return () => { };
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
const ctx = {
|
|
412
|
+
sessionManager,
|
|
413
|
+
model: undefined,
|
|
414
|
+
signal: undefined,
|
|
415
|
+
hasPendingMessages: () => false,
|
|
416
|
+
ui: { notify() { } },
|
|
417
|
+
};
|
|
418
|
+
const windowId = "pcw:ordering:test";
|
|
419
|
+
const budgetDraft = { type: "custom_message", customType: internal.GUIDANCE_TYPE, content: "budget draft", display: false };
|
|
420
|
+
const lifecycle = registerResetLifecycle(api, {
|
|
421
|
+
isEnabled: () => true,
|
|
422
|
+
budget: {
|
|
423
|
+
automaticResetEnabled: () => true,
|
|
424
|
+
resetDue: () => false,
|
|
425
|
+
consumeTurnEnd: () => [budgetDraft],
|
|
426
|
+
clear: () => { },
|
|
427
|
+
},
|
|
428
|
+
buildReset: () => [
|
|
429
|
+
{ type: "custom", customType: internal.RESET_MARKER_TYPE, data: { windowId } },
|
|
430
|
+
{ type: "custom_message", customType: internal.BOOT_TYPE, content: "boot", display: false, details: { windowId } },
|
|
431
|
+
{ type: "custom_message", customType: internal.CONTINUATION_TYPE, content: "continuation", display: false },
|
|
432
|
+
],
|
|
433
|
+
});
|
|
434
|
+
lifecycle.request();
|
|
435
|
+
const incoming = { type: "custom_message", customType: "foreign/boundary", content: "incoming", display: false };
|
|
436
|
+
const results = [];
|
|
437
|
+
for (const handler of handlers.get("turn_end") ?? [])
|
|
438
|
+
results.push(await handler(fakeBoundaryEvent([incoming]), ctx));
|
|
439
|
+
const result = resultEntries(results);
|
|
440
|
+
assert.equal(result.continue, true);
|
|
441
|
+
const customTypes = (entries) => entries.map((entry) => {
|
|
442
|
+
assert.ok(entry.type === "custom" || entry.type === "custom_message", "the boundary only carries named reset drafts here");
|
|
443
|
+
return entry.customType;
|
|
444
|
+
});
|
|
445
|
+
assert.deepEqual(customTypes(result.entries), [
|
|
446
|
+
"foreign/boundary",
|
|
447
|
+
internal.GUIDANCE_TYPE,
|
|
448
|
+
internal.RESET_MARKER_TYPE,
|
|
449
|
+
internal.BOOT_TYPE,
|
|
450
|
+
internal.CONTINUATION_TYPE,
|
|
451
|
+
], "ordinary and budget drafts precede the closed reset shape");
|
|
452
|
+
appendDrafts(sessionManager, result.entries);
|
|
453
|
+
const branch = sessionManager.getBranch();
|
|
454
|
+
const markerIndex = branch.findIndex((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
|
|
455
|
+
assert.ok(markerIndex >= 0);
|
|
456
|
+
assert.deepEqual(customTypes(branch.slice(markerIndex)), [
|
|
457
|
+
internal.RESET_MARKER_TYPE,
|
|
458
|
+
internal.BOOT_TYPE,
|
|
459
|
+
internal.CONTINUATION_TYPE,
|
|
460
|
+
], "the persisted reset shape remains marker -> boot -> continuation");
|
|
461
|
+
});
|
package/docs/reset-lifecycle.md
CHANGED
|
@@ -13,6 +13,63 @@
|
|
|
13
13
|
| Startup / tree / partial append | Repair only a marker followed by an otherwise empty metadata tail; refuse hidden/absent boots once later work exists, leaving `/wipe-memory` as the explicit recovery path. Do not interpret legacy reset-v2 details. |
|
|
14
14
|
| `/pi-context off` | Stop new automatic resets, but retain the boundary of an existing marker. Marked branches still cancel native compaction; a fresh root may use Pi's native semantics. |
|
|
15
15
|
|
|
16
|
+
## Reset-control state machine
|
|
17
|
+
|
|
18
|
+
`registerResetLifecycle` is the effect adapter. `reduceResetControl` in `src/context/reset-lifecycle.ts` is the pure transition surface: given the current state and one lifecycle event it returns the next state and one named effect, performing no writes, policy resolution, or UI work of its own. The adapter captures Pi's events, supplies the guards only in the branches that consult them, then performs the marker/boot/continuation writes, continuation requests, and notices.
|
|
19
|
+
|
|
20
|
+
State is a single value with two explicitly typed axes. There are no independently combinable lifecycle booleans.
|
|
21
|
+
|
|
22
|
+
| Axis | Value | Meaning |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `request` | `none` | no explicit wipe request pending |
|
|
25
|
+
| | `explicit` | one explicit request is pending and will commit at the next `turn_end` |
|
|
26
|
+
| `overflow` | `idle` | no overflow failure pending; a future recovery is available |
|
|
27
|
+
| | `pending` | active-provider overflow failure pending; recovery still available |
|
|
28
|
+
| | `pending-spent` | overflow failure pending, but its one recovery was already spent |
|
|
29
|
+
| | `spent` | recovery spent and the failure is no longer pending |
|
|
30
|
+
|
|
31
|
+
All eight combinations of the two axes are reachable and each has a defined transition.
|
|
32
|
+
|
|
33
|
+
| Event | Guard | Next state | Effect |
|
|
34
|
+
| --- | --- | --- | --- |
|
|
35
|
+
| `request` | `request=none` | `request=explicit`, overflow unchanged | `requested` |
|
|
36
|
+
| `request` | `request=explicit` | unchanged | `already-requested` |
|
|
37
|
+
| `turn_end` | turn aborted | `(request=none, overflow=idle)` | `none` |
|
|
38
|
+
| `turn_end` | overflow-like and `!queued && enabled && automaticResetEnabled` | `request=none`; overflow `pending`, or `pending-spent` if already spent | `none` |
|
|
39
|
+
| `turn_end` | overflow-like and (`queued \|\| !enabled \|\| !automaticResetEnabled`) | `request=none`; overflow `idle`, or `spent` if already spent | `none` |
|
|
40
|
+
| `turn_end` | completed, `enabled`, and (`explicit` requested or `thresholdDue`) | `(request=none, overflow=idle)` | `commit-boundary` |
|
|
41
|
+
| `turn_end` | completed, `enabled`, but neither trigger | `(request=none, overflow=idle)` | `none` |
|
|
42
|
+
| `turn_end` | non-overflow `failed` | `request=none`; overflow unchanged | `none` |
|
|
43
|
+
| `turn_end` | `!enabled` | `request=none`; completed clears overflow, error keeps it | `none` |
|
|
44
|
+
| `before_settle` | `overflow=pending`, not queued, `enabled`, `automaticResetEnabled`, not aborted | `overflow=spent` | `recover-overflow` |
|
|
45
|
+
| `before_settle` | `overflow=pending-spent` | `overflow=spent` | `none` |
|
|
46
|
+
| `before_settle` | `overflow` not pending, or queued | unchanged | `none` |
|
|
47
|
+
| `before_settle` | pending but `!enabled`, `!automaticResetEnabled`, or aborted | disarms to `idle` (`spent` if already spent) | `none` |
|
|
48
|
+
| `settled` | — | `overflow=idle`; `request` unchanged | `none` |
|
|
49
|
+
| `abort` / `clear` | — | `(request=none, overflow=idle)` | `none` |
|
|
50
|
+
|
|
51
|
+
### Invariants
|
|
52
|
+
|
|
53
|
+
1. At most one explicit request is pending at a time; duplicate requests in one tool batch dedupe and never queue a second boundary.
|
|
54
|
+
2. Every `turn_end` consumes a pending explicit request, whether or not that turn commits a reset.
|
|
55
|
+
3. A committed reset is exactly `marker -> boot -> continuation`, and ordinary and budget drafts are ordered before the marker. Budget staging is drained once per turn and discarded on abort or disabled mode.
|
|
56
|
+
4. Reset drafts are built once per commit. If construction throws, already-built incoming and budget drafts survive and no continuation is requested.
|
|
57
|
+
5. Overflow recovery is one attempt per failure chain: a failure arms `pending` only when nothing is queued, the extension is enabled, and automatic reset is enabled; a settle spends it to `spent`; a later failure becomes `pending-spent` and cannot recover again until settlement ends the chain.
|
|
58
|
+
6. Aborted turns never manufacture a continuation and clear both axes. Non-overflow errors keep the armed overflow chain for the settle boundary.
|
|
59
|
+
7. A successful non-overflow turn clears the overflow chain first, so a queued success supersedes a stale overflow failure before settle recovery can act.
|
|
60
|
+
8. `settled` ends the failure chain but preserves a pending explicit request; `clear` (session start, tree navigation, shutdown, `/pi-context off`, `/wipe-memory`) resets both axes.
|
|
61
|
+
9. Disabled mode never commits a reset and never arms or spends overflow recovery; an existing persisted marker stays authoritative for native-compaction cancellation.
|
|
62
|
+
10. Policy guards (`queued`, `automaticResetEnabled`, `thresholdDue`) are consulted only in the branch that needs them, preserving the adapter's original resolution order.
|
|
63
|
+
|
|
64
|
+
### Critical sequences
|
|
65
|
+
|
|
66
|
+
- **Normal reset.** A completed, enabled turn whose explicit request is pending or whose usage is due returns `commit-boundary`. The adapter drains budget drafts, keeps incoming drafts in order, appends `marker -> boot -> continuation`, and requests continuation. A second request in the same batch returns `already-requested`; the next turn commits it alone.
|
|
67
|
+
- **Overflow recovery.** An overflow-like `turn_end` with no queued message, enabled mode, and automatic reset arms `pending`; a `before_settle` with no queued message returns `recover-overflow` and spends the attempt to `spent`. A repeated settle is a no-op, and a later overflow failure re-arms only `pending-spent`.
|
|
68
|
+
- **Abort.** An aborted `turn_end` returns only the drafts already collected for that boundary, clears both axes, and never appends reset drafts.
|
|
69
|
+
- **Queued success.** A queued message defers `before_settle`; the queued turn's successful `turn_end` clears the stale overflow chain, so no recovery fires.
|
|
70
|
+
- **Process interruption.** If Pi stops between the marker and the later reset messages, the marker remains the authoritative boundary. On the next start, startup repair may append only the missing boot and continuation when the tail is otherwise repairable; it never moves or reinterprets the boundary.
|
|
71
|
+
- **Startup tail repair.** On `session_start` / `session_tree`, `ensureBoot` asks `repairResetTail` to inspect the marker tail and emit only the missing artifacts in the closed order: the boot, then the continuation. A boot/continuation sequence that is already complete, or a tail containing real conversation, a foreign message, a later marker, or a misordered/duplicate artifact, refuses repair and leaves `/wipe-memory` as the explicit recovery path.
|
|
72
|
+
|
|
16
73
|
The budget owner stages the early guidance and final checkpoint warning from active-window usage. The warning is visible in the current provider request, while both drafts are committed only by the lifecycle composer and are discarded on abort, settlement without a `turn_end`, transition, or window mismatch. After the warning, the model either writes its note and calls `wipe_memory`, or runtime recovery requests the same marker/boot boundary. Guidance and warning drafts precede reset drafts so stale reminders cannot be queued into the new window; durable entries remain the authority for redelivery. Their UI notices are emitted at the next turn start or settlement only after the matching reminder is committed in the active window, so aborted requests and retries cannot repeat an uncommitted reminder's notification.
|
|
17
74
|
|
|
18
75
|
The turn-end commit is the scheduling boundary: Pi receives the finished tool batch and then the marker/boot drafts as one append operation. Pi owns queue scheduling and deduplication of the next request; the extension does not run a parallel compaction state machine or use a compaction completion callback.
|
package/package.json
CHANGED