@astrosheep/pi-context 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +616 -370
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +19 -15
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +298 -242
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +318 -19
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +25 -17
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -109,22 +109,25 @@ function appendDrafts(sessionManager, entries) {
109
109
  }
110
110
  function turnEndFacts(overrides = {}) {
111
111
  return {
112
+ windowId: "pcw:test:window",
112
113
  aborted: false,
113
114
  overflow: false,
114
115
  failed: false,
115
116
  enabled: true,
116
117
  queued: false,
117
118
  automaticResetEnabled: true,
118
- thresholdDue: false,
119
+ hardReserveDue: false,
119
120
  ...overrides,
120
121
  };
121
122
  }
122
123
  function beforeSettleFacts(overrides = {}) {
123
124
  return {
125
+ windowId: "pcw:test:window",
124
126
  queued: false,
125
127
  enabled: true,
126
128
  automaticResetEnabled: true,
127
129
  aborted: false,
130
+ failed: false,
128
131
  ...overrides,
129
132
  };
130
133
  }
@@ -156,14 +159,15 @@ test("public reset boundary drafts one marker, one boot, and one continuation af
156
159
  assert.equal(markerDrafts.length, 1, "duplicate wipe requests in one turn dedupe");
157
160
  assert.equal(bootDrafts.length, 1);
158
161
  assert.equal(boundary.entries[0]?.type, "custom_message", "ordinary tool-batch entries precede the reset drafts");
159
- assert.equal(boundary.entries[1]?.type, "custom");
160
- assert.equal(boundary.entries[2]?.type, "custom_message");
162
+ assert.equal(boundary.entries[1]?.type, "compaction", "native retain-none checkpoint precedes the reset marker");
163
+ assert.equal(boundary.entries[2]?.type, "custom");
164
+ assert.equal(boundary.entries[3]?.type, "custom_message");
161
165
  const windowId = markerDrafts[0].data.windowId;
162
166
  assert.match(windowId, /^pcw:/);
163
167
  assert.equal(bootDrafts[0].details.windowId, windowId);
164
168
  const continuationDrafts = boundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.CONTINUATION_TYPE);
165
169
  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");
170
+ assert.equal(boundary.entries[4]?.type, "custom_message", "the continuation closes the ordered reset shape");
167
171
  appendDrafts(h.sessionManager, boundary.entries);
168
172
  const branch = h.sessionManager.getBranch();
169
173
  assert.deepEqual(branch.filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).map((entry) => entry.type === "custom" ? entry.data : undefined), [{ windowId }]);
@@ -189,12 +193,14 @@ test("off stops future automatic/manual reset requests while an existing marker
189
193
  assert.equal(afterOff.entries.length, 0, "off does not create another reset");
190
194
  await h.runCommand("pi-context", "on");
191
195
  await h.runCommand("wipe-memory");
192
- assert.equal(h.sent.length, 2, "/wipe-memory writes one hidden boot and one continuation without a model turn");
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);
196
+ assert.equal(h.sent.length, 1, "/wipe-memory sends one hidden close-out warning and starts a normal turn");
197
+ assert.equal(h.sent[0]?.triggerTurn, true);
198
+ assert.equal(h.sent[0]?.customType, internal.WARNING_TYPE);
199
+ const warning = h.sessionManager.getBranch().find((entry) => entry.type === "custom_message" && entry.customType === internal.WARNING_TYPE);
200
+ assert.ok(warning && warning.type === "custom_message");
201
+ assert.equal(warning.content, internal.WARNING_CONTENT);
196
202
  const markers = h.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
197
- assert.equal(markers.length, 2, "off does not resurrect history; re-enabled wipe-memory creates the explicit new marker");
203
+ assert.equal(markers.length, 1, "manual command waits for an agent boundary; it does not persist an immediate reset");
198
204
  });
199
205
  test("reset construction failure preserves incoming and budget drafts without continuation", async () => {
200
206
  const sessionManager = Manager.inMemory("/private/tmp/pi-context-reset-failure-test");
@@ -220,13 +226,13 @@ test("reset construction failure preserves incoming and budget drafts without co
220
226
  isEnabled: () => true,
221
227
  budget: {
222
228
  automaticResetEnabled: () => true,
223
- resetDue: () => false,
229
+ hardReserveDue: () => false,
224
230
  consumeTurnEnd: () => [budgetDraft],
225
231
  clear: () => { },
226
232
  },
227
233
  buildReset: () => { throw new Error("synthetic reset construction failure"); },
228
234
  });
229
- lifecycle.request();
235
+ lifecycle.request(`pcw:${sessionManager.getSessionId().slice(0, 8)}:root`);
230
236
  const incoming = { type: "custom_message", customType: "foreign/boundary", content: "foreign draft", display: false };
231
237
  const results = [];
232
238
  for (const handler of handlers.get("turn_end") ?? [])
@@ -237,6 +243,52 @@ test("reset construction failure preserves incoming and budget drafts without co
237
243
  assert.equal(notices.at(-1)?.type, "warning");
238
244
  assert.match(notices.at(-1)?.message ?? "", /could not build reset/);
239
245
  });
246
+ test("a stale async reset is discarded after a lifecycle switch without staging a notice", async () => {
247
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-stale-reset-test");
248
+ const handlers = new Map();
249
+ const api = {
250
+ on(name, handler) {
251
+ const list = handlers.get(name) ?? [];
252
+ list.push(handler);
253
+ handlers.set(name, list);
254
+ return () => { };
255
+ },
256
+ };
257
+ const ctx = {
258
+ sessionManager,
259
+ model: undefined,
260
+ signal: undefined,
261
+ hasPendingMessages: () => false,
262
+ ui: { notify() { } },
263
+ };
264
+ let resolveBuild;
265
+ const pendingBuild = new Promise((resolve) => { resolveBuild = resolve; });
266
+ let readyCount = 0;
267
+ const lifecycle = registerResetLifecycle(api, {
268
+ isEnabled: () => true,
269
+ budget: {
270
+ automaticResetEnabled: () => true,
271
+ hardReserveDue: () => false,
272
+ consumeTurnEnd: () => [],
273
+ clear: () => { },
274
+ },
275
+ buildReset: () => pendingBuild,
276
+ onResetReady: () => { readyCount++; },
277
+ });
278
+ const windowId = `pcw:${sessionManager.getSessionId().slice(0, 8)}:root`;
279
+ lifecycle.request(windowId);
280
+ const incoming = { type: "custom_message", customType: "foreign/boundary", content: "preserve me", display: false };
281
+ const turnEnd = handlers.get("turn_end")?.[0];
282
+ const sessionTree = handlers.get("session_tree")?.[0];
283
+ assert.ok(turnEnd && sessionTree);
284
+ const pending = turnEnd(fakeBoundaryEvent([incoming]), ctx);
285
+ await sessionTree({}, ctx);
286
+ resolveBuild([{ type: "custom_message", customType: internal.BOOT_TYPE, content: "stale", display: false }]);
287
+ const result = resultEntries([await pending]);
288
+ assert.deepEqual(result.entries, [incoming], "a switched lifecycle keeps incoming drafts but discards stale reset drafts");
289
+ assert.equal(result.continue, false);
290
+ assert.equal(readyCount, 0, "stale reset work cannot stage a success notice");
291
+ });
240
292
  test("a queued success clears an overflow failure before settle recovery can reset", async () => {
241
293
  const sessionManager = Manager.inMemory("/private/tmp/pi-context-queued-overflow-test");
242
294
  const handlers = new Map();
@@ -260,7 +312,7 @@ test("a queued success clears an overflow failure before settle recovery can res
260
312
  isEnabled: () => true,
261
313
  budget: {
262
314
  automaticResetEnabled: () => true,
263
- resetDue: () => false,
315
+ hardReserveDue: () => false,
264
316
  consumeTurnEnd: () => [],
265
317
  clear: () => { },
266
318
  },
@@ -288,55 +340,63 @@ test("a queued success clears an overflow failure before settle recovery can res
288
340
  assert.equal(await beforeSettle(settled, ctx), undefined, "the successful queued turn clears the stale recovery");
289
341
  assert.equal(resetCount, 0);
290
342
  });
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");
343
+ test("reset-control: close-out phases deduplicate, span turns, and upgrade to a tool commit", () => {
344
+ const windowId = "pcw:test:window";
345
+ const first = reduceResetControl(initialResetControl(), { type: "close_out", windowId, source: "manual" });
346
+ assert.equal(first.effect, "close-out-armed");
347
+ assert.deepEqual(first.state.request, { phase: "close-out", windowId, source: "manual" });
348
+ const duplicate = reduceResetControl(first.state, { type: "close_out", windowId, source: "manual" });
349
+ assert.equal(duplicate.effect, "already-pending");
350
+ const noteTurn = reduceResetControl(first.state, { type: "turn_end", facts: turnEndFacts() });
351
+ assert.equal(noteTurn.effect, "none");
352
+ assert.deepEqual(noteTurn.state.request, first.state.request, "note/tool turns do not consume close-out");
353
+ const tool = reduceResetControl(noteTurn.state, { type: "tool_request", windowId });
354
+ assert.deepEqual(tool.state.request, { phase: "tool-requested", windowId });
355
+ const commit = reduceResetControl(tool.state, { type: "turn_end", facts: turnEndFacts() });
356
+ assert.equal(commit.effect, "commit-boundary");
357
+ assert.deepEqual(commit.state, initialResetControl());
302
358
  });
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());
359
+ test("reset-control: fallback is normal-stop only; hard reserve remains safety", () => {
360
+ const windowId = "pcw:test:window";
361
+ const manual = reduceResetControl(initialResetControl(), { type: "close_out", windowId, source: "manual" }).state;
362
+ const fallback = reduceResetControl(manual, { type: "before_settle", facts: beforeSettleFacts() });
363
+ assert.equal(fallback.effect, "commit-boundary", "manual close-out works even when automatic resets are disabled");
364
+ assert.deepEqual(fallback.state, initialResetControl());
365
+ const automatic = reduceResetControl(initialResetControl(), { type: "close_out", windowId, source: "automatic" }).state;
366
+ const disabledAuto = reduceResetControl(automatic, { type: "before_settle", facts: beforeSettleFacts({ automaticResetEnabled: false }) });
367
+ assert.equal(disabledAuto.effect, "none");
368
+ assert.deepEqual(disabledAuto.state, initialResetControl());
369
+ const safety = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ hardReserveDue: true }) });
370
+ assert.equal(safety.effect, "commit-boundary");
371
+ for (const outcome of [{ failed: true }, { aborted: true }]) {
372
+ const notNormalStop = reduceResetControl(manual, { type: "before_settle", facts: beforeSettleFacts(outcome) });
373
+ assert.equal(notNormalStop.effect, "none");
374
+ assert.deepEqual(notNormalStop.state, initialResetControl());
375
+ }
376
+ assert.deepEqual(reduceResetControl(manual, { type: "settled" }).state, initialResetControl());
316
377
  });
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 }) });
378
+ test("reset-control: abort and lifecycle transitions clear every pending phase", () => {
379
+ const windowId = "pcw:test:window";
380
+ const pending = reduceResetControl(initialResetControl(), { type: "close_out", windowId, source: "manual" }).state;
381
+ const aborted = reduceResetControl(pending, { type: "turn_end", facts: turnEndFacts({ aborted: true }) });
320
382
  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());
383
+ assert.deepEqual(aborted.state, initialResetControl());
384
+ assert.deepEqual(reduceResetControl(pending, { type: "clear" }).state, initialResetControl());
385
+ assert.deepEqual(reduceResetControl(pending, { type: "settled" }).state, initialResetControl());
326
386
  });
327
387
  test("reset-control: overflow recovery is armed at turn_end and spent exactly once at settle", () => {
328
388
  const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) });
329
- assert.deepEqual(armed.state, { request: "none", overflow: "pending" });
389
+ assert.deepEqual(armed.state, { request: { phase: "none" }, overflow: "pending" });
330
390
  const recovered = reduceResetControl(armed.state, { type: "before_settle", facts: beforeSettleFacts() });
331
391
  assert.equal(recovered.effect, "recover-overflow", "the first settle commits the bounded recovery");
332
- assert.deepEqual(recovered.state, { request: "none", overflow: "spent" });
392
+ assert.deepEqual(recovered.state, { request: { phase: "none" }, overflow: "spent" });
333
393
  const repeated = reduceResetControl(recovered.state, { type: "before_settle", facts: beforeSettleFacts() });
334
394
  assert.equal(repeated.effect, "none", "a spent recovery is never retried");
335
395
  const rearmed = reduceResetControl(recovered.state, { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) });
336
- assert.deepEqual(rearmed.state, { request: "none", overflow: "pending-spent" });
396
+ assert.deepEqual(rearmed.state, { request: { phase: "none" }, overflow: "pending-spent" });
337
397
  const bounded = reduceResetControl(rearmed.state, { type: "before_settle", facts: beforeSettleFacts() });
338
398
  assert.equal(bounded.effect, "none", "a second failure chain stays bounded to the spent attempt");
339
- assert.deepEqual(bounded.state, { request: "none", overflow: "spent" });
399
+ assert.deepEqual(bounded.state, { request: { phase: "none" }, overflow: "spent" });
340
400
  });
341
401
  test("reset-control: a queued turn defers recovery and its success supersedes the failure", () => {
342
402
  const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
@@ -350,52 +410,40 @@ test("reset-control: disabled mode and explicit aborts disarm overflow without a
350
410
  const armed = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
351
411
  const disabled = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ enabled: false }) });
352
412
  assert.equal(disabled.effect, "none");
353
- assert.deepEqual(disabled.state, { request: "none", overflow: "idle" });
413
+ assert.deepEqual(disabled.state, { request: { phase: "none" }, overflow: "idle" });
354
414
  const automaticOff = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ automaticResetEnabled: false }) });
355
415
  assert.equal(automaticOff.effect, "none");
356
- assert.deepEqual(automaticOff.state, { request: "none", overflow: "idle" });
416
+ assert.deepEqual(automaticOff.state, { request: { phase: "none" }, overflow: "idle" });
357
417
  const aborted = reduceResetControl(armed, { type: "before_settle", facts: beforeSettleFacts({ aborted: true }) });
358
418
  assert.equal(aborted.effect, "none");
359
- assert.deepEqual(aborted.state, { request: "none", overflow: "idle" });
419
+ assert.deepEqual(aborted.state, { request: { phase: "none" }, overflow: "idle" });
360
420
  });
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 };
421
+ test("reset-control: lazy guards preserve overflow policy ordering", () => {
422
+ const calls = { queued: 0, automatic: 0, hardReserve: 0 };
423
+ const facts = {
424
+ windowId: "pcw:test:window",
425
+ aborted: false,
426
+ overflow: true,
427
+ failed: true,
428
+ enabled: true,
429
+ get queued() { calls.queued += 1; return false; },
430
+ get automaticResetEnabled() { calls.automatic += 1; return true; },
431
+ get hardReserveDue() { calls.hardReserve += 1; return true; },
374
432
  };
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");
433
+ reduceResetControl(initialResetControl(), { type: "turn_end", facts });
434
+ assert.deepEqual(calls, { queued: 1, automatic: 1, hardReserve: 0 }, "overflow checks never consult hard-reserve policy");
390
435
  const settleCalls = { queued: 0, enabled: 0, automatic: 0 };
391
436
  const settleFacts = {
437
+ windowId: "pcw:test:window",
392
438
  get queued() { settleCalls.queued += 1; return true; },
393
439
  get enabled() { settleCalls.enabled += 1; return true; },
394
440
  get automaticResetEnabled() { settleCalls.automatic += 1; return true; },
395
441
  aborted: false,
442
+ failed: false,
396
443
  };
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");
444
+ const pendingOverflow = reduceResetControl(initialResetControl(), { type: "turn_end", facts: turnEndFacts({ overflow: true, failed: true }) }).state;
445
+ reduceResetControl(pendingOverflow, { type: "before_settle", facts: settleFacts });
446
+ assert.deepEqual(settleCalls, { queued: 1, enabled: 0, automatic: 0 }, "queued settle checks no further overflow policy");
399
447
  });
400
448
  test("a committed reset places incoming and budget drafts before marker -> boot -> continuation", async () => {
401
449
  const sessionManager = Manager.inMemory("/private/tmp/pi-context-reset-ordering-test");
@@ -421,17 +469,18 @@ test("a committed reset places incoming and budget drafts before marker -> boot
421
469
  isEnabled: () => true,
422
470
  budget: {
423
471
  automaticResetEnabled: () => true,
424
- resetDue: () => false,
472
+ hardReserveDue: () => false,
425
473
  consumeTurnEnd: () => [budgetDraft],
426
474
  clear: () => { },
427
475
  },
428
476
  buildReset: () => [
477
+ { type: "compaction", summary: "", firstKeptEntryId: null },
429
478
  { type: "custom", customType: internal.RESET_MARKER_TYPE, data: { windowId } },
430
479
  { type: "custom_message", customType: internal.BOOT_TYPE, content: "boot", display: false, details: { windowId } },
431
480
  { type: "custom_message", customType: internal.CONTINUATION_TYPE, content: "continuation", display: false },
432
481
  ],
433
482
  });
434
- lifecycle.request();
483
+ lifecycle.request(`pcw:${sessionManager.getSessionId().slice(0, 8)}:root`);
435
484
  const incoming = { type: "custom_message", customType: "foreign/boundary", content: "incoming", display: false };
436
485
  const results = [];
437
486
  for (const handler of handlers.get("turn_end") ?? [])
@@ -439,12 +488,15 @@ test("a committed reset places incoming and budget drafts before marker -> boot
439
488
  const result = resultEntries(results);
440
489
  assert.equal(result.continue, true);
441
490
  const customTypes = (entries) => entries.map((entry) => {
442
- assert.ok(entry.type === "custom" || entry.type === "custom_message", "the boundary only carries named reset drafts here");
491
+ if (entry.type === "compaction")
492
+ return "native-compaction";
493
+ assert.ok(entry.type === "custom" || entry.type === "custom_message", "the boundary only carries expected reset drafts here");
443
494
  return entry.customType;
444
495
  });
445
496
  assert.deepEqual(customTypes(result.entries), [
446
497
  "foreign/boundary",
447
498
  internal.GUIDANCE_TYPE,
499
+ "native-compaction",
448
500
  internal.RESET_MARKER_TYPE,
449
501
  internal.BOOT_TYPE,
450
502
  internal.CONTINUATION_TYPE,
@@ -452,7 +504,12 @@ test("a committed reset places incoming and budget drafts before marker -> boot
452
504
  appendDrafts(sessionManager, result.entries);
453
505
  const branch = sessionManager.getBranch();
454
506
  const markerIndex = branch.findIndex((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
455
- assert.ok(markerIndex >= 0);
507
+ assert.ok(markerIndex > 0);
508
+ assert.equal(branch[markerIndex - 1]?.type, "compaction", "the checkpoint is directly before the marker");
509
+ const checkpoint = branch[markerIndex - 1];
510
+ assert.ok(checkpoint?.type === "compaction");
511
+ assert.equal(checkpoint.summary, "");
512
+ assert.equal(checkpoint.firstKeptEntryId, checkpoint.id, "Pi materializes null as retain-none");
456
513
  assert.deepEqual(customTypes(branch.slice(markerIndex)), [
457
514
  internal.RESET_MARKER_TYPE,
458
515
  internal.BOOT_TYPE,
@@ -29,30 +29,30 @@ Dependencies flow from the composition root and tool adapters to projections and
29
29
 
30
30
  ## State and persistence
31
31
 
32
- `turn_end` has one composer in `reset-lifecycle.ts`: it accepts the incoming drafts, drains the budget instance's staged guidance/warning drafts, and only then appends reset drafts. Reset requests are committed after the complete tool batch with `continue: true`, so Pi owns queue scheduling. Repeated `wipe_memory` requests in one batch deduplicate; a later window may still request another reset. Aborts and reset-construction failures preserve already-built drafts without manufacturing a continuation. See [reset lifecycle](reset-lifecycle.md).
32
+ `reset-lifecycle.ts` is the sole `turn_end` composer: it accepts incoming drafts, drains budget-owned guidance/warning drafts, and appends a reset boundary only for an explicit tool request or the hard-reserve safety path. Manual and budget close-outs stay armed across note/tool turns and fall back from `agent_before_settle` only after a successful stop and queued work are drained. Repeated requests for the same window deduplicate. See [reset lifecycle](reset-lifecycle.md).
33
33
 
34
- The durable boundary is one `pi-context/reset-marker` custom entry with `{ windowId: string }`, followed by one hidden `pi-context/boot` custom message with `details.windowId` equal to the marker identity. The marker is the only window boundary. `context/context-window.ts` owns the marker predicate, active-branch scan, root/current IDs, and per-window message lookup; `history/history.ts` consumes those identity primitives while projecting entries. The scan never uses a global entry tail. Native compaction and branch-summary entries remain history items in the current window, so the old compaction-entry identity is not a window identity.
34
+ A new reset is a native empty-summary compaction checkpoint configured to retain none, followed by one `pi-context/reset-marker` custom entry with `{ windowId: string }`, a hidden `pi-context/boot` whose `details.windowId` matches, and a hidden continuation. The checkpoint is the canonical-history cut; the marker is the durable logical window identity. Raw session entries remain available to history tools. Legacy marker-only branches keep a narrow projection fallback. `context/context-window.ts` owns marker/checkpoint validation, active-branch scan, root/current IDs, and per-window message lookup; `history/history.ts` consumes those identity primitives while projecting entries. The scan never uses a global entry tail.
35
35
 
36
36
  The runtime in `context/runtime.ts` performs final context projection by selecting the active boot through `details.windowId` and folding only the dropped system prefix through Pi's `getCurrentSystemMessage`. Later prompt patches and new messages stay in order. A missing boot aborts the hook with a safe head and notice rather than silently sending raw history. Startup/tree handling repairs only a genuinely incomplete marker tail: a missing boot with no later raw message, custom message, compaction, branch summary, or authoritative raw boot. If later work exists, boot creation is refused and `/wipe-memory` is the explicit recovery path; it does not parse or migrate the legacy reset-v2 protocol.
37
37
 
38
38
  Boot and reminder deduplication inspect the current branch-local window. Reloading JSONL therefore does not duplicate messages, while navigation to a sibling branch cannot inherit another branch's window state. A fork/clone receives a new session ID while copying its selected path, so startup must also verify that a root boot's `details.windowId` matches the new `rootWindowId(sessionId)` before treating it as present.
39
39
 
40
- Startup and idle manual resets use `pi.sendMessage(..., { triggerTurn: false })` for the boot. Pi appends it to the session and refreshes context without initiating a model request; during streaming the same call is deferred until the completed tool batch. This is session persistence, not a promise of immediate disk durability: Pi 0.87 defers a new session file until its first assistant message, and the extension API reports send failures through extension errors rather than an awaitable result. Running resets therefore use boundary drafts for ordering and continuation, not `sendMessage`. Drafts are validated together but disk writes are not transactional.
40
+ Manual `/wipe-memory` first waits for idle, then records the same hidden warning used by budget-triggered close-out and starts an ordinary model turn. The agent can checkpoint notes through multiple turns; an explicit `wipe_memory` commits at `turn_end`, while a successful stop without the tool falls back at `agent_before_settle`. Abort/error never counts as successful close-out. The final checkpoint, marker, boot, and continuation are emitted as ordered session-boundary drafts. Pi's retain-none compaction entry persists the canonical cut; the marker/boot preserve pi-context's logical identity and protocol.
41
41
 
42
- Boot is a fixed snapshot for its window, stored as an extension custom message and converted by Pi to a user-role model message. History's `developer` classification records extension authorship, not provider instruction priority. A system-role projection is technically possible through `context_with_system`, but would change the authority of the mixed protocol/MAP content and provider-specific prompt/cache behavior; it is not a prerequisite for persistence. Boot injection is silent, including startup, reload, and boot repair. Only an actual context reset produces `pi-context: memory cleared · <windowId>` through `ctx.ui.notify`, after both marker and boot are present in the session. Manual clear is checked immediately; running resets are checked at the next turn start or final settlement. Uncommitted reset drafts never announce success. Boot and continuation messages retain `display: false`.
42
+ Boot is a fixed snapshot for its window, stored as an extension custom message and converted by Pi to a user-role model message. History's `developer` classification records extension authorship, not provider instruction priority. A system-role projection is technically possible through `context_with_system`, but would change the authority of the mixed protocol/MAP content and provider-specific prompt/cache behavior; it is not a prerequisite for persistence. Boot injection is silent, including startup, reload, and boot repair. Only an actual context reset produces `pi-context: memory cleared · <windowId>` through `ctx.ui.notify`, after the verified checkpoint, marker, boot, and continuation are present in the session. Committed resets are checked at the next turn start or final settlement. Uncommitted reset drafts never announce success. Boot and continuation messages retain `display: false`.
43
43
 
44
44
  History reads reconstruct the selected session branch on demand without a cache, so branch navigation cannot expose history from a sibling.
45
45
 
46
- The boot notes index in `notes/notes-snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. A missing home (`ENOENT`) is normal. A real read failure omits only that home's index, preserves healthy homes, adds a model-facing `notes_list` recovery notice, and notifies the human once for that window. The window identity, reset/protocol text, and lifecycle boundary are still constructed through the normal runtime path; note reads never mutate files or create fallback state.
46
+ The boot notes index in `pi/notes/snapshot.ts` is a closed snapshot: the current session, project, human, agent, and model homes are each loaded at most once while constructing a boot. `context/prompts.ts` then renders that explicit snapshot without reading the filesystem or consulting the clock. MAP bodies and pocket metadata are derived from the same snapshot, so a boot cannot mix two filesystem reads. Note storage and home traversal use `node:fs/promises`; same-file operations queue by absolute physical filename across store instances in this process, without promising symlink/case-alias or cross-process locking. Known persisted metadata is camelCase, and a known legacy snake_case key refuses use until the root-coordinated manual migration; startup does not migrate notes. A missing home (`ENOENT`) is normal. A real read failure omits only that home's index, preserves healthy homes, adds a model-facing `notes_list` recovery notice, and notifies the human once for that window. Boot/reset construction captures session, agent, and model identity before awaiting the snapshot, then checks lifecycle generation, active window, enabled state, and abort status before sending or returning artifacts; stale completions cannot commit into a switched or shut-down session. Note reads never mutate files or create fallback state.
47
47
 
48
48
  `notes/session-replay.ts` accepts only supported operations, safe virtual paths, representable timestamps and results within the UTF-8 size limit. Invalid operations are ignored; they cannot replace a valid note. Notes remain in their filesystem-backed homes, unchanged by session branch navigation.
49
49
 
50
- The `/wipe-memory` command waits for idle, appends the marker and boot with Pi's public `appendEntry`/`sendMessage` APIs, and never calls a model. While enabled, `/compact` is cancelled with an actionable `/wipe-memory` notice. Disabling pi-context stops new automatic resets, but an existing marker still excludes earlier history and native compaction is still cancelled on that marked branch; a fresh root may use native Pi semantics. Threshold and warning accounting use active-window provider usage rather than pre-reset global usage. Budget policy and staged prompts are instance-owned, so concurrent sessions cannot share reserve/enablement or uncommitted drafts; default file-backed policy is cached per instance, while injected policy is resolved live on each decision and model/session transitions reset the diagnostic lifecycle.
50
+ The `/wipe-memory` command captures the current window before waiting for idle, refuses a duplicate if the wait crossed into a new window, then records a hidden warning and starts a model close-out turn. While enabled, `/compact` is cancelled with an actionable `/wipe-memory` notice. Disabling pi-context stops new automatic resets, but an existing native checkpoint/marker still excludes earlier history and native compaction is still cancelled on that marked branch; a fresh root may use native Pi semantics. Threshold and warning accounting use active-window provider usage rather than pre-reset global usage. Budget policy and staged prompts are instance-owned, so concurrent sessions cannot share reserve/enablement or uncommitted drafts; default file-backed policy is cached per instance, while injected policy is resolved live on each decision and model/session transitions reset the diagnostic lifecycle.
51
51
 
52
52
  ## Evidence and limits
53
53
 
54
54
  The retained integration tests use real SessionManager and SettingsManager instances. They check reset-window history retention, bounded history/notes reads with resumable cursors, notes-home isolation, and settings precedence. The suite is a representative regression set; the standalone coherence, pagination property, history, and threshold suites were removed during test reduction.
55
55
 
56
- Scripted SDK tests execute the real Pi agent loop with no model network request. They inspect provider contexts and durable entries for reset boundaries, mixed-tool completion, queued steering/follow-up delivery, overflow recovery, and settings authority. Smaller dream tests retain lock exclusion/ownership, jailed writes, CLI audit/report failure propagation, and read-only doctor behavior. Removing duplicate scenarios and edge-case matrices reduces coverage; the remaining tests do not establish every malformed-input case, branch interleaving, external-provider behavior, or filesystem failure mode.
56
+ Scripted SDK tests execute the real Pi agent loop with no model/network request. They inspect provider contexts and durable entries for manual/budget close-out, multi-turn notes, explicit and normal-stop commits, abort/error cleanup, same-window command deduplication, queued steering/follow-up delivery, retain-none checkpoints across two resets and disk reopen, bounded overflow recovery, and settings authority. Smaller dream tests retain lock exclusion/ownership, jailed writes, CLI audit/report failure propagation, and read-only doctor behavior. Removing duplicate scenarios and edge-case matrices reduces coverage; the remaining tests do not establish every malformed-input case, branch interleaving, external-provider behavior, or filesystem failure mode.
57
57
 
58
- Mixed tool batches finish before the marker/boot boundary. Queued steering/follow-up messages are delivered exactly once in the new window; they are neither dropped nor replayed. Runtime overflow recovery is bounded to one reset/retry per failure chain, while ordinary retryable provider errors remain Pi-owned. When either the source or destination branch has a reset marker, `/tree` navigation still succeeds but its generated summary is replaced by an empty summary plus a notice; raw history and branch selection remain available. If neither branch has a marker, Pi's native tree summary is retained.
58
+ Mixed tool batches finish before a direct tool reset boundary. Manual/budget close-outs let queued steering/follow-up work run once in the old window before fallback; direct tool resets let Pi schedule queued work across the new boundary. Scripted loop tests check that these messages are neither dropped nor replayed. Runtime overflow recovery is bounded to one reset/retry per failure chain, while ordinary retryable provider errors remain Pi-owned. When either the source or destination branch has a reset marker, `/tree` navigation still succeeds but its generated summary is replaced by an empty summary plus a notice; raw history and branch selection remain available. If neither branch has a marker, Pi's native tree summary is retained.