@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.
@@ -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 home, `@self/<vpath>` / `@agents/<name>/<vpath>` for agent homes, and `@model/<vpath>` / `@models/<name>/<vpath>` for model homes. `@self` and `@model` mean the current agent/model; the `<name>` forms name one absolutely. The word after `@` is always one of the reserved home names — names live at the second level, never `@faye/`. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes. Homes you do not own (`@agents/<other>/`, `@models/<other>/`) are read-only.";
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 reachable homes: this session, @project/, @human/, your @self home, and the current @model home; other agents and models appear only under an explicit glob (@agents/<name>/**, @models/<name>/**, or a glob in the name segment to scan a whole namespace).`,
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 reachable homes as notes_list; explicit globs reach other agents and models. 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.`,
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);
@@ -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
- export const RESET_SUMMARY = "You wake up. Your head is empty — no memories, the past a blank. The memory is gone for good. What outlived it: the notes you wrote, and the history that was recorded. They are not your memory — read them to rebuild what you need.";
36
- 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.";
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
- If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
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 (message.includes("context budget low") || message.includes("context budget critical")) {
130
- const customType = message.includes("context budget critical") ? WARNING_TYPE : GUIDANCE_TYPE;
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.notices.filter((message) => message.includes("context budget low"));
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().length, 0, "an uncommitted reminder never notifies");
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().length, 1, "the committed retry notifies once");
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().length, 1, "later turns cannot repeat the notice");
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.equal(captured.sent.length, 1, "repair emits one hidden boot without a model turn");
103
- assert.equal(noticesOf(ctx).length, 0, "repairing a boot is not a new reset");
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, 2, "/wipe-memory writes one hidden boot without triggering a model turn");
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, resetLine) {
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, false);
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
  });
@@ -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 the session home/);
209
- await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are the session home/);
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, 1, "/wipe-memory writes one hidden boot without a model turn");
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
+ });
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: durable reset windows, session history tools, and persistent notes.",
6
6
  "license": "MIT",