@astrosheep/pi-context 0.23.1 → 0.25.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 (78) hide show
  1. package/README.md +52 -5
  2. package/dist/build-info.json +4 -0
  3. package/dist/extension.js +1861 -0
  4. package/dist/src/context/budget.js +150 -0
  5. package/dist/src/context/context-window.js +97 -0
  6. package/dist/src/context/prompts.js +94 -0
  7. package/dist/src/context/reset-lifecycle.js +134 -0
  8. package/dist/src/context/runtime.js +236 -0
  9. package/dist/src/context/thresholds.js +62 -0
  10. package/dist/src/dream/cli.js +1 -1
  11. package/dist/src/dream/doctor.js +34 -6
  12. package/dist/src/dream/runner.js +1 -1
  13. package/dist/src/dream/settings.js +30 -0
  14. package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
  15. package/dist/src/{history.js → history/history.js} +8 -46
  16. package/dist/src/index.js +27 -94
  17. package/dist/src/notes/address.js +97 -16
  18. package/dist/src/notes/frontmatter.js +18 -3
  19. package/dist/src/notes/notes-snapshot.js +30 -0
  20. package/dist/src/notes/paths.js +64 -7
  21. package/dist/src/notes/session-replay.js +41 -0
  22. package/dist/src/notes/store.js +76 -22
  23. package/dist/src/notes/tools.js +7 -7
  24. package/dist/src/protocol.js +11 -9
  25. package/dist/src/settings.js +16 -0
  26. package/dist/src/tool-schema.js +1 -1
  27. package/dist/test/agent-loop.test.js +813 -213
  28. package/dist/test/boot.integration.test.js +167 -0
  29. package/dist/test/budget-settings.integration.test.js +126 -0
  30. package/dist/test/doctor.test.js +14 -36
  31. package/dist/test/dream.test.js +37 -380
  32. package/dist/test/helpers/extension.js +393 -0
  33. package/dist/test/history.integration.test.js +316 -0
  34. package/dist/test/notes.integration.test.js +273 -0
  35. package/dist/test/notes.test.js +40 -370
  36. package/dist/test/reset-lifecycle.test.js +248 -180
  37. package/docs/architecture.md +35 -18
  38. package/docs/reset-lifecycle.md +16 -14
  39. package/package.json +11 -10
  40. package/src/context/budget.ts +148 -0
  41. package/src/context/context-window.ts +103 -0
  42. package/src/context/prompts.ts +111 -0
  43. package/src/context/reset-lifecycle.ts +145 -0
  44. package/src/context/runtime.ts +246 -0
  45. package/src/context/thresholds.ts +78 -0
  46. package/src/dream/cli.ts +1 -1
  47. package/src/dream/doctor.ts +27 -6
  48. package/src/dream/runner.ts +1 -1
  49. package/src/dream/settings.ts +32 -0
  50. package/src/{history-tools.ts → history/history-tools.ts} +3 -3
  51. package/src/{history.ts → history/history.ts} +9 -48
  52. package/src/index.ts +27 -89
  53. package/src/notes/address.ts +82 -16
  54. package/src/notes/frontmatter.ts +20 -3
  55. package/src/notes/notes-snapshot.ts +40 -0
  56. package/src/notes/paths.ts +64 -7
  57. package/src/notes/session-replay.ts +53 -0
  58. package/src/notes/store.ts +78 -25
  59. package/src/notes/tools.ts +7 -7
  60. package/src/protocol.ts +11 -9
  61. package/src/settings.ts +20 -0
  62. package/src/tool-schema.ts +1 -2
  63. package/dist/src/budget.js +0 -65
  64. package/dist/src/notes/model.js +0 -101
  65. package/dist/src/prompts.js +0 -88
  66. package/dist/src/reset-lifecycle.js +0 -155
  67. package/dist/src/thresholds.js +0 -102
  68. package/dist/src/warning.js +0 -44
  69. package/dist/test/coherence.test.js +0 -371
  70. package/dist/test/history.test.js +0 -26
  71. package/dist/test/integration.test.js +0 -1775
  72. package/dist/test/pagination.property.test.js +0 -471
  73. package/src/budget.ts +0 -67
  74. package/src/notes/model.ts +0 -109
  75. package/src/prompts.ts +0 -91
  76. package/src/reset-lifecycle.ts +0 -173
  77. package/src/thresholds.ts +0 -110
  78. package/src/warning.ts +0 -46
@@ -1,196 +1,264 @@
1
1
  import assert from "node:assert/strict";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
2
5
  import test from "node:test";
3
- import { registerResetLifecycle } from "../src/reset-lifecycle.js";
6
+ import { SessionManager as Manager } from "@earendil-works/pi-coding-agent";
7
+ import piContext, { internal } from "../src/index.js";
8
+ import { registerResetLifecycle } from "../src/context/reset-lifecycle.js";
9
+ const previousNotesHome = process.env.PI_NOTES_HOME;
10
+ const testNotesHome = mkdtempSync(join(tmpdir(), "pi-context-lifecycle-notes-"));
11
+ process.env.PI_NOTES_HOME = testNotesHome;
12
+ test.after(() => {
13
+ if (previousNotesHome === undefined)
14
+ delete process.env.PI_NOTES_HOME;
15
+ else
16
+ process.env.PI_NOTES_HOME = previousNotesHome;
17
+ rmSync(testNotesHome, { recursive: true, force: true });
18
+ });
4
19
  function harness() {
20
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-lifecycle-test");
5
21
  const handlers = new Map();
6
- const messages = [];
22
+ const tools = new Map();
23
+ const commands = new Map();
24
+ const sent = [];
7
25
  const notices = [];
8
- const requests = [];
9
- let sessionId = "first", currentReset = "", enabled = true, idle = true, pending = false;
10
- let signal;
11
- let throwOnCompact = false;
12
- const ctx = {
13
- sessionManager: { getSessionId: () => sessionId },
14
- isIdle: () => idle,
15
- hasPendingMessages: () => pending,
16
- get signal() { return signal; },
17
- compact: (options) => {
18
- if (throwOnCompact)
19
- throw new Error("synchronous failure");
20
- requests.push(options);
26
+ const api = {
27
+ on(name, handler) {
28
+ const list = handlers.get(name) ?? [];
29
+ list.push(handler);
30
+ handlers.set(name, list);
31
+ return () => { };
32
+ },
33
+ registerTool(tool) { tools.set(tool.name, tool); },
34
+ registerCommand(name, command) { commands.set(name, command); },
35
+ registerFlag() { },
36
+ appendEntry(customType, data) { sessionManager.appendCustomEntry(customType, data); },
37
+ sendMessage(message, options) {
38
+ sent.push({ customType: message.customType, details: message.details, triggerTurn: options?.triggerTurn });
39
+ sessionManager.appendCustomMessageEntry(message.customType, message.content, message.display, message.details);
21
40
  },
22
- ui: { notify: (message) => notices.push(message) },
23
41
  };
24
- const lifecycle = registerResetLifecycle({
25
- on: (name, fn) => handlers.set(name, fn),
26
- sendMessage: (message) => messages.push(message.customType),
27
- }, {
28
- isEnabled: () => enabled,
29
- continuation: { customType: "continue", content: "resume", display: false },
30
- buildReset: () => ({ compaction: { summary: "reset", firstKeptEntryId: "marker", tokensBefore: 100, details: {} } }),
31
- isCurrentReset: (id) => id === currentReset,
32
- onReset: () => { },
33
- });
34
- const emit = (name, event = {}) => handlers.get(name)?.(event, ctx);
42
+ piContext(api);
43
+ const ctx = {
44
+ sessionManager,
45
+ cwd: "/private/tmp",
46
+ model: undefined,
47
+ isIdle: () => true,
48
+ hasPendingMessages: () => false,
49
+ signal: undefined,
50
+ getContextUsage: () => undefined,
51
+ compact: () => assert.fail("the reset path must not call ctx.compact()"),
52
+ abort: () => { },
53
+ isProjectTrusted: () => true,
54
+ ui: { notify: (message, type) => notices.push({ message, type }) },
55
+ };
56
+ const emit = async (name, event) => {
57
+ const results = [];
58
+ for (const handler of handlers.get(name) ?? [])
59
+ results.push(await handler(event, ctx));
60
+ return results;
61
+ };
62
+ const runCommand = async (name, args = "") => {
63
+ const command = commands.get(name);
64
+ assert.ok(command, `${name} command is registered`);
65
+ const commandCtx = Object.assign({}, ctx, {
66
+ waitForIdle: async () => { },
67
+ ui: { notify: (message, type) => notices.push({ message, type }) },
68
+ });
69
+ await command.handler(args, commandCtx);
70
+ };
71
+ const callTool = async (name) => {
72
+ const tool = tools.get(name);
73
+ assert.ok(tool, `${name} is registered`);
74
+ return tool.execute("call", {}, new AbortController().signal, () => { }, ctx);
75
+ };
76
+ return { sessionManager, handlers, sent, notices, emit, runCommand, callTool };
77
+ }
78
+ function resultEntries(results) {
79
+ let entries = [];
80
+ let shouldContinue = false;
81
+ for (const result of results) {
82
+ if (!result || typeof result !== "object")
83
+ continue;
84
+ const value = result;
85
+ if (value.entries)
86
+ entries = value.entries;
87
+ if (value.continue !== undefined)
88
+ shouldContinue = value.continue;
89
+ }
90
+ return { entries, continue: shouldContinue };
91
+ }
92
+ function appendDrafts(sessionManager, entries) {
93
+ for (const entry of entries) {
94
+ switch (entry.type) {
95
+ case "custom":
96
+ sessionManager.appendCustomEntry(entry.customType, entry.data);
97
+ break;
98
+ case "custom_message":
99
+ sessionManager.appendCustomMessageEntry(entry.customType, entry.content, entry.display, entry.details);
100
+ break;
101
+ case "context_edit":
102
+ sessionManager.appendContextEdit(entry.targetId, entry.replacement);
103
+ break;
104
+ case "compaction":
105
+ sessionManager.appendCompaction(entry.summary, entry.firstKeptEntryId, 0, entry.details, true, entry.usage);
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ function fakeBoundaryEvent(entries = []) {
35
111
  return {
36
- ctx, lifecycle, emit, messages, notices, requests,
37
- setIdle: (value) => { idle = value; },
38
- setPending: (value) => { pending = value; },
39
- setSignal: (value) => { signal = value; },
40
- setSession: (value) => { sessionId = value; },
41
- setThrow: () => { throwOnCompact = true; },
42
- disable: () => { enabled = false; lifecycle.clear(); },
43
- enable: () => { enabled = true; },
44
- before: (reason = "threshold") => emit("session_before_compact", { reason, signal: new AbortController().signal }),
45
- // Do not await this result until after the manually driven compact callbacks:
46
- // real Pi awaits the originating handler while the continuation can emit its
47
- // own nested agent_settled event.
48
- settle: () => { emit("agent_end"); idle = true; return emit("agent_settled"); },
49
- success: (id = "reset", willRetry = false) => {
50
- currentReset = id;
51
- emit("session_compact", { compactionEntry: { id }, willRetry });
52
- },
53
- complete: (index = 0) => requests[index].onComplete({}),
112
+ type: "turn_end",
113
+ entries,
114
+ continue: false,
115
+ context: { contextEntries: [], contextMessages: [], llmMessages: [], pendingMessages: [], canContinue: true },
116
+ outcome: "completed",
117
+ turnIndex: 0,
118
+ message: { role: "assistant", content: [], timestamp: Date.now() },
119
+ toolResults: [],
120
+ messageEntryId: "assistant-entry",
121
+ toolResultEntryIds: [],
54
122
  };
55
123
  }
56
- test("the originating settled handler waits for its continuation's nested settlement", async () => {
124
+ test("public reset boundary drafts one marker, one boot, and one continuation after a tool batch", async () => {
57
125
  const h = harness();
58
- assert.equal(h.lifecycle.request(), "rollover_requested");
59
- assert.equal(h.lifecycle.request(), "rollover_already_pending");
60
- const outer = h.settle();
61
- assert.equal(h.requests.length, 1);
62
- h.success();
63
- h.success();
64
- h.complete();
65
- h.complete();
66
- assert.deepEqual(h.messages, ["continue"], "one continuation starts after compaction completion");
67
- let released = false;
68
- void Promise.resolve(outer).then(() => { released = true; });
69
- await Promise.resolve();
70
- assert.equal(released, false, "sending the continuation does not release the original handler");
71
- await h.settle();
72
- await outer;
73
- assert.equal(released, true, "only the continuation's settled event releases its owner");
74
- assert.equal(h.requests.length, 1, "duplicate compact and settled callbacks do not restart reset work");
126
+ h.sessionManager.appendMessage({ role: "user", content: [{ type: "text", text: "before reset" }], timestamp: Date.now() });
127
+ const first = await h.callTool("wipe_memory");
128
+ const second = await h.callTool("wipe_memory");
129
+ assert.ok(first.content.length > 0 && second.content.length > 0, "both tool calls return normally");
130
+ const toolBatch = [{ type: "custom_message", customType: "foreign/tool-batch", content: "tool finished", display: false }];
131
+ const boundary = resultEntries(await h.emit("turn_end", fakeBoundaryEvent(toolBatch)));
132
+ assert.equal(boundary.continue, true, "the whole tool batch continues only after the boundary is committed");
133
+ const markerDrafts = boundary.entries.filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
134
+ const bootDrafts = boundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE);
135
+ assert.equal(markerDrafts.length, 1, "duplicate wipe requests in one turn dedupe");
136
+ assert.equal(bootDrafts.length, 1);
137
+ assert.equal(boundary.entries[0]?.type, "custom_message", "ordinary tool-batch entries precede the reset drafts");
138
+ assert.equal(boundary.entries[1]?.type, "custom");
139
+ assert.equal(boundary.entries[2]?.type, "custom_message");
140
+ const windowId = markerDrafts[0].data.windowId;
141
+ assert.match(windowId, /^pcw:/);
142
+ assert.equal(bootDrafts[0].details.windowId, windowId);
143
+ appendDrafts(h.sessionManager, boundary.entries);
144
+ const branch = h.sessionManager.getBranch();
145
+ assert.deepEqual(branch.filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).map((entry) => entry.type === "custom" ? entry.data : undefined), [{ windowId }]);
146
+ assert.equal(branch.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 1);
75
147
  });
76
- test("a reset requested by a continuation completes before its predecessor releases", async () => {
148
+ test("off stops future automatic/manual reset requests while an existing marker remains authoritative", async () => {
77
149
  const h = harness();
78
- h.lifecycle.request();
79
- const first = h.settle();
80
- h.success("first");
81
- h.complete();
82
- assert.deepEqual(h.messages, ["continue"]);
83
- // This models new_context being called during the first continuation run.
84
- assert.equal(h.lifecycle.request(), "rollover_requested");
85
- const second = h.settle();
86
- assert.equal(h.requests.length, 2, "the continuation's settled handler starts its requested reset");
87
- h.success("second");
88
- h.complete(1);
89
- assert.deepEqual(h.messages, ["continue", "continue"]);
90
- let firstReleased = false;
91
- void Promise.resolve(first).then(() => { firstReleased = true; });
92
- await Promise.resolve();
93
- assert.equal(firstReleased, false, "the predecessor remains owned while the second continuation runs");
94
- await h.settle();
95
- await second;
96
- await first;
97
- assert.equal(firstReleased, true);
98
- assert.equal(h.lifecycle.request(), "rollover_requested", "a later window can request another reset");
99
- });
100
- test("automatic compactions reset on the spot, with no continuation", () => {
101
- const h = harness();
102
- assert.ok(h.before().compaction, "the native attempt becomes our reset immediately");
103
- assert.deepEqual(h.messages, []);
104
- });
105
- test("failure, synchronous scheduling errors, and cancellation release their owners without retry", async () => {
106
- const failed = harness();
107
- failed.lifecycle.request();
108
- const outer = failed.settle();
109
- failed.requests[0].onError(new Error("Nothing to compact"));
110
- failed.requests[0].onError(new Error("duplicate callback"));
111
- failed.complete();
112
- await outer;
113
- assert.equal(failed.notices.length, 1);
114
- assert.deepEqual(failed.messages, []);
115
- assert.equal(failed.lifecycle.request(), "rollover_requested", "a later explicit request is possible");
116
- const synchronous = harness();
117
- synchronous.setThrow();
118
- synchronous.lifecycle.request();
119
- await synchronous.settle();
120
- assert.equal(synchronous.notices.length, 1);
121
- assert.equal(synchronous.lifecycle.request(), "rollover_requested");
122
- const aborted = harness();
123
- aborted.lifecycle.request();
124
- aborted.setSignal(AbortSignal.abort());
125
- await aborted.settle();
126
- assert.equal(aborted.requests.length, 0);
127
- assert.deepEqual(aborted.messages, []);
128
- });
129
- test("shutdown, tree invalidation, toggling off, and stale sessions release waiters safely", async () => {
130
- for (const boundary of ["session_shutdown", "session_start", "session_tree", "off", "session-change"]) {
131
- const h = harness();
132
- h.lifecycle.request();
133
- const outer = h.settle();
134
- h.success();
135
- h.complete();
136
- if (boundary === "off") {
137
- h.disable();
138
- h.enable();
139
- }
140
- else if (boundary === "session-change") {
141
- h.setSession("second");
142
- h.complete();
143
- h.emit("session_tree");
144
- }
145
- else
146
- h.emit(boundary);
147
- h.complete();
148
- h.requests[0].onError(new Error("late error"));
149
- await outer;
150
- assert.deepEqual(h.notices, [], boundary);
151
- assert.deepEqual(h.messages, ["continue"], boundary);
152
- if (boundary === "session_shutdown")
153
- h.emit("session_start");
154
- if (boundary === "session-change")
155
- h.emit("session_tree");
156
- assert.equal(h.lifecycle.request(), "rollover_requested", `${boundary}: a fresh request still works`);
157
- }
150
+ await h.callTool("wipe_memory");
151
+ const first = resultEntries(await h.emit("turn_end", fakeBoundaryEvent()));
152
+ assert.ok(first.entries.some((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE));
153
+ appendDrafts(h.sessionManager, first.entries);
154
+ await h.runCommand("pi-context", "off");
155
+ const before = await h.emit("session_before_compact", {
156
+ type: "session_before_compact",
157
+ reason: "manual",
158
+ willRetry: false,
159
+ branchEntries: h.sessionManager.getBranch(),
160
+ preparation: { tokensBefore: 100, firstKeptEntryId: null, keptMessages: [], droppedMessages: [] },
161
+ signal: new AbortController().signal,
162
+ });
163
+ assert.deepEqual(before.at(-1), { cancel: true }, "/compact is canceled when an existing marker would expose old canonical history");
164
+ const afterOff = resultEntries(await h.emit("turn_end", fakeBoundaryEvent()));
165
+ assert.equal(afterOff.entries.length, 0, "off does not create another reset");
166
+ await h.runCommand("pi-context", "on");
167
+ await h.runCommand("wipe-memory");
168
+ assert.equal(h.sent.length, 1, "/wipe-memory writes one hidden boot without a model turn");
169
+ assert.equal(h.sent[0]?.triggerTurn, false);
170
+ const markers = h.sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
171
+ assert.equal(markers.length, 2, "off does not resurrect history; re-enabled wipe-memory creates the explicit new marker");
158
172
  });
159
- test("queued or competing work is not duplicated and releases an unneeded continuation owner", async () => {
160
- const competing = harness();
161
- competing.lifecycle.request();
162
- competing.setIdle(false);
163
- assert.equal(competing.emit("agent_settled"), undefined, "another run owns the first settled event");
164
- competing.setIdle(true);
165
- const outer = competing.settle();
166
- competing.success();
167
- competing.setIdle(false);
168
- competing.complete();
169
- await outer;
170
- assert.deepEqual(competing.messages, [], "an active prompt owns continuation");
171
- const queued = harness();
172
- queued.lifecycle.request();
173
- const queuedOuter = queued.settle();
174
- queued.success();
175
- queued.setPending(true);
176
- queued.complete();
177
- await queuedOuter;
178
- assert.deepEqual(queued.messages, [], "queued user work is never duplicated");
173
+ test("reset construction failure preserves incoming and budget drafts without continuation", async () => {
174
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-reset-failure-test");
175
+ const handlers = new Map();
176
+ const notices = [];
177
+ const api = {
178
+ on(name, handler) {
179
+ const list = handlers.get(name) ?? [];
180
+ list.push(handler);
181
+ handlers.set(name, list);
182
+ return () => { };
183
+ },
184
+ };
185
+ const ctx = {
186
+ sessionManager,
187
+ model: undefined,
188
+ signal: undefined,
189
+ hasPendingMessages: () => false,
190
+ ui: { notify: (message, type) => notices.push({ message, type }) },
191
+ };
192
+ const budgetDraft = { type: "custom_message", customType: internal.GUIDANCE_TYPE, content: "budget draft", display: false };
193
+ const lifecycle = registerResetLifecycle(api, {
194
+ isEnabled: () => true,
195
+ budget: {
196
+ automaticResetEnabled: () => true,
197
+ resetDue: () => false,
198
+ consumeTurnEnd: () => [budgetDraft],
199
+ clear: () => { },
200
+ },
201
+ buildReset: () => { throw new Error("synthetic reset construction failure"); },
202
+ });
203
+ lifecycle.request();
204
+ const incoming = { type: "custom_message", customType: "foreign/boundary", content: "foreign draft", display: false };
205
+ const results = [];
206
+ for (const handler of handlers.get("turn_end") ?? [])
207
+ results.push(await handler(fakeBoundaryEvent([incoming]), ctx));
208
+ const result = resultEntries(results);
209
+ assert.deepEqual(result.entries, [incoming, budgetDraft], "already-built drafts survive reset construction failure");
210
+ assert.equal(result.continue, false, "a failed reset does not request continuation");
211
+ assert.equal(notices.at(-1)?.type, "warning");
212
+ assert.match(notices.at(-1)?.message ?? "", /could not build reset/);
179
213
  });
180
- test("foreign boundaries and native compactions do not manufacture a continuation", async () => {
181
- const h = harness();
182
- h.lifecycle.request();
183
- const outer = h.settle();
184
- h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
185
- h.emit("session_compact", { compactionEntry: { id: "foreign" }, willRetry: false });
186
- assert.equal(h.lifecycle.request(), "rollover_already_pending");
187
- h.complete();
188
- await outer;
189
- assert.deepEqual(h.messages, []);
190
- const native = harness();
191
- native.lifecycle.request();
192
- native.success("native", false);
193
- await native.settle();
194
- assert.equal(native.requests.length, 0);
195
- assert.deepEqual(native.messages, []);
214
+ test("a queued success clears an overflow failure before settle recovery can reset", async () => {
215
+ const sessionManager = Manager.inMemory("/private/tmp/pi-context-queued-overflow-test");
216
+ const handlers = new Map();
217
+ const api = {
218
+ on(name, handler) {
219
+ const list = handlers.get(name) ?? [];
220
+ list.push(handler);
221
+ handlers.set(name, list);
222
+ return () => { };
223
+ },
224
+ };
225
+ const ctx = {
226
+ sessionManager,
227
+ model: { contextWindow: 100_000, maxTokens: 4_096 },
228
+ signal: undefined,
229
+ hasPendingMessages: () => false,
230
+ ui: { notify() { } },
231
+ };
232
+ let resetCount = 0;
233
+ const lifecycle = registerResetLifecycle(api, {
234
+ isEnabled: () => true,
235
+ budget: {
236
+ automaticResetEnabled: () => true,
237
+ resetDue: () => false,
238
+ consumeTurnEnd: () => [],
239
+ clear: () => { },
240
+ },
241
+ buildReset: () => {
242
+ resetCount += 1;
243
+ return [];
244
+ },
245
+ });
246
+ const failed = fakeBoundaryEvent();
247
+ failed.message = { ...failed.message, stopReason: "error", errorMessage: "Prompt too long: context exceeds maximum context length" };
248
+ const turnEnd = handlers.get("turn_end")?.[0];
249
+ const beforeSettle = handlers.get("agent_before_settle")?.[0];
250
+ assert.ok(turnEnd && beforeSettle);
251
+ await turnEnd(failed, ctx);
252
+ const queued = {
253
+ ...failed,
254
+ type: "agent_before_settle",
255
+ outcome: "error",
256
+ context: { ...failed.context, pendingMessages: [{ role: "user", content: [{ type: "text", text: "queued success" }], timestamp: Date.now() }] },
257
+ };
258
+ assert.equal(await beforeSettle(queued, ctx), undefined, "a queued message defers overflow recovery");
259
+ const success = fakeBoundaryEvent();
260
+ await turnEnd(success, ctx);
261
+ const settled = { ...queued, context: { ...queued.context, pendingMessages: [] }, outcome: "completed" };
262
+ assert.equal(await beforeSettle(settled, ctx), undefined, "the successful queued turn clears the stale recovery");
263
+ assert.equal(resetCount, 0);
196
264
  });
@@ -6,36 +6,53 @@ pi-context uses Pi's session branch as the durable source of truth. It does not
6
6
 
7
7
  | Module | Responsibility | Boundary |
8
8
  | --- | --- | --- |
9
- | `index.ts` | Compose features, expose toggle/reset tool, construct reset boundary | Pi extension API |
10
- | `history.ts` | Project branch entries into windows/items; identify active window | `SessionReader`, read-only branch and session ID |
11
- | `notes.ts` | Replay note operations, validate paths/timestamps | `SessionReader`; no scheduling or writes |
12
- | `history-tools.ts`, `note-tools.ts` | Public schemas and tool results; append validated note operations | Pi tool API plus read projections |
13
- | `budget.ts` | Resolve settings, report usable budget, persist guidance once | Pi settings/context hooks |
14
- | `thresholds.ts` | Derive the reminder/reserve/warning lines from Pi's reserve plus the pi-context margins | Pi `SettingsManager`, read-only; session-scoped cache |
15
- | `warning.ts` | Steer the final checkpoint warning once per window | Pi context hook |
16
- | `prompts.ts` | Render static boot block, note index, reminder and warning | Read projections and protocol text |
17
- | `reset-lifecycle.ts` | Own reset requests, completion and continuation | Pi lifecycle hooks and injected boundary builder |
9
+ | `index.ts` | Thin public entrypoint: register the context runtime and tool adapters, and preserve public history/notes re-exports | Pi extension API |
10
+ | `context/runtime.ts` | Compose runtime hooks, toggle/reset commands, boot construction, marker/boot boundaries, and provider projection | Pi extension API plus context and notes modules |
11
+ | `history/history.ts` | Project branch entries into windows/items using the shared window identity | `SessionReader`, read-only branch and session ID |
12
+ | `context/context-window.ts` | Own durable window identity, select the active boot, project provider context, and account for active-window usage | `SessionReader` plus Pi context/system projection APIs |
13
+ | `notes/session-replay.ts` | Replay persisted note operations from session entries | `SessionReader`; no filesystem acquisition |
14
+ | `notes/address.ts` | Validate virtual note paths and resolve address/glob forms | Note address syntax; no session-branch selection |
15
+ | `notes/notes-snapshot.ts` | Acquire the five notes homes once into a closed boot snapshot and isolate filesystem-home failures | Filesystem-backed notes homes; no rendering or UI effects |
16
+ | `notes/store.ts` | Read and mutate the filesystem-backed homes; distinguish an absent home from a real read failure | Notes filesystem only; boot acquisition isolates one home at a time |
17
+ | `history/history-tools.ts` | Public history schemas and tool results over branch projections | Pi tool API plus read projections |
18
+ | `notes/tools.ts` | Filesystem note tool adapters; validate and perform note reads, writes, edits, listings, and searches | Pi tool API plus notes filesystem |
19
+ | `context/budget.ts` | Own the default-path per-extension-instance settings cache, read injected policy live, report usable budget, stage guidance/warning drafts, and resolve automatic reset decisions | Pi settings/context hooks plus protocol warning text |
20
+ | `context/thresholds.ts` | Read the selected public settings authority and derive reminder/reserve/warning thresholds from Pi's reserve plus the pi-context margins | Pi `SettingsManager`, read-only; no mutable cache or UI effects |
21
+ | `settings.ts` | Merge the global and project `pi-context` settings object per key | Parsed Pi settings scopes; no I/O or runtime state |
22
+ | `dream/settings.ts` | Validate and resolve the configured dreamer model from the shared settings merge | Pi `SettingsManager`; no runtime context state |
23
+ | `context/prompts.ts` | Render the static boot block, note index, and low-budget reminder from explicit data | Snapshot data and protocol text; no filesystem acquisition or UI effects |
24
+ | `context/reset-lifecycle.ts` | Own reset requests, turn-end batching, recovery and continuation | Pi lifecycle hooks and injected boundary builder |
18
25
  | `protocol.ts` | Persisted entry tags, protocol text and defaults | No imports or effects |
19
26
  | `tool-schema.ts`, `tool-output.ts` | Shared wire-schema primitives and JSON result encoding | No session state |
20
27
 
21
- Dependencies flow from the composition root and tool adapters to projections and protocol constants. Projections cannot send messages, compact, notify, or mutate the session. A runtime framework or generic event bus would add indirection without strengthening these boundaries.
28
+ Dependencies flow from the composition root and tool adapters to projections and protocol constants. The published Pi entry is `dist/extension.js`, built from `src/index.ts`; its public `pi-ai/utils/estimate` dependency is inlined so Pi's root-package aliases cannot misresolve the utility subpath. Host-owned package APIs remain external. Projections cannot send messages, compact, notify, or mutate the session. A runtime framework or generic event bus would add indirection without strengthening these boundaries.
22
29
 
23
30
  ## State and persistence
24
31
 
25
- Reset requests are a discriminated union: `idle`, `requested`, or `compacting` with an identified attempt. A request cannot simultaneously be pending and in flight. Each attempt records its originating session, whether it was explicit, and whether a matching boundary completed. Callback identity prevents an old attempt from consuming a newer one. See [reset lifecycle](reset-lifecycle.md).
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).
26
33
 
27
- Manual, threshold and overflow compactions all build the reset boundary on the spot, idle or streaming: `session_before_compact` returns the reset immediately, never cancels and never takes a model turn, and only an aborted signal cancels. The final checkpoint warning was already steered from the context hook (see [reset lifecycle](reset-lifecycle.md)), so the model had its chance to write a note; what crosses the line now is the wipe itself. `agent_settled` services only explicit `new_context` requests, whose `ctx.compact` route needs the completion callback.
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.
28
35
 
29
- Boot and reminder deduplication inspect messages in the current persisted window. Reloading the extension or the JSONL file therefore does not duplicate either message. Reminder reservation in memory covers Pi's deferred message write; navigation clears that reservation, while persisted branch-local messages remain authoritative. A sibling branch cannot suppress a reminder it never received.
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.
30
37
 
31
- Note replay 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. Reads reconstruct the current branch without a cache, so navigating a branch cannot expose notes from a sibling.
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.
32
39
 
33
- Reset IDs are opaque strings tagged with `reset-v2`; newly minted IDs use `pcw:<session>:<8 lowercase hex digits>`. Unsupported details fall back to Pi's compaction-entry identity for history lookup. Minting checks existing branch window IDs, which are independent of Pi entry IDs. A reset adds a marker via the public append API, then uses its real entry ID as `firstKeptEntryId`; old conversation remains searchable in the durable branch.
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.
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`.
43
+
44
+ History reads reconstruct the selected session branch on demand without a cache, so branch navigation cannot expose history from a sibling.
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.
47
+
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
+
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.
34
51
 
35
52
  ## Evidence and limits
36
53
 
37
- The integration suite uses real SessionManager and SettingsManager instances, including JSONL restoration, branch navigation, Unicode content, malformed note operations and settings precedence. Lifecycle event tests cover duplicate/stale callbacks, native scheduling, disabled state, abort and failed compaction.
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.
38
55
 
39
- Scripted SDK tests execute the real Pi agent loop with no model network request. They cover explicit reset, instant automatic reset, rejected compaction, steering and follow-up delivery before reset without replay, consecutive distinct windows, and cancellation followed by a new user prompt. They inspect actual provider contexts and durable entries. They do not establish reliability of an external provider or every possible interleaving between unrelated extensions.
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.
40
57
 
41
- Pi decides compaction eligibility before the boundary hook. A short uncompactable session therefore cannot be force-reset with the public API. Mixed tool batches and queued messages may finish before `agent_settled`; the extension preserves their delivery rather than clearing the queue. Native compaction owns its subsequent scheduling, while extension-requested compaction resumes from `onComplete` after Pi clears compaction state.
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.