@astrosheep/pi-context 0.25.2 → 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 +613 -367
  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 +16 -12
  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 +309 -10
  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 +19 -11
  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
@@ -7,11 +7,14 @@
7
7
  * HERMETIC: every test points PI_NOTES_HOME at its own temp root; no real ~/.agents is touched.
8
8
  */
9
9
  import assert from "node:assert/strict";
10
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import test from "node:test";
13
- import { physicalPath } from "../src/notes/paths.js";
14
- import { call, context, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
14
+ import { parseNote } from "../src/notes/frontmatter.js";
15
+ import { projectKey } from "../src/notes/paths.js";
16
+ import { listNotes, physicalPath, scopeDir } from "./helpers/notes.js";
17
+ import { call, context, explicitBoot, installExtensionTestEnvironment, makeExtension, manager, resultJson, resultRead } from "./helpers/extension.js";
15
18
  const testEnvironment = installExtensionTestEnvironment("pi-context-notes");
16
19
  test.beforeEach(() => testEnvironment.beforeEach());
17
20
  test.afterEach(() => testEnvironment.afterEach());
@@ -22,7 +25,7 @@ function freshRoot() {
22
25
  function setUpdatedAt(scope, path, ctx, timestamp) {
23
26
  const file = physicalPath(scope, path, ctx);
24
27
  const raw = readFileSync(file, "utf8");
25
- writeFileSync(file, raw.replace(/^updated_at: .*$/m, `updated_at: ${new Date(timestamp).toISOString()}`));
28
+ writeFileSync(file, raw.replace(/^updatedAt: .*$/m, `updatedAt: ${new Date(timestamp).toISOString()}`));
26
29
  }
27
30
  function assertNoPublicScope(value, label) {
28
31
  if (Array.isArray(value)) {
@@ -64,21 +67,126 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
64
67
  const raw = readFileSync(file, "utf8");
65
68
  assert.match(raw, /^---\n/, "the file opens with frontmatter");
66
69
  assert.match(raw, /\n---\n\nhello$/, "frontmatter is followed by a blank line and the exact body");
67
- for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["access_count", "0"]]) {
70
+ for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["accessCount", "0"]]) {
68
71
  assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
69
72
  }
70
73
  assert.equal(/^scope:/m.test(raw), false, "scope is derived from the file home, never persisted");
71
- for (const key of ["created_at", "updated_at", "last_accessed"]) {
74
+ for (const key of ["createdAt", "updatedAt", "lastAccessed"]) {
72
75
  assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
73
76
  }
77
+ assert.equal(parseNote(raw).meta.project, projectKey(ctx.cwd), "a newly-created session note records its existing project key");
74
78
  assert.equal(result.address, "a/b.md");
75
79
  assert.equal(result.written, true);
80
+ assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "ownership is stored in note frontmatter, not a sidecar");
76
81
  // A leading YAML block in user content is stripped from the body.
77
82
  await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: human\nnonsense: true\n---\nreal body" }, ctx);
78
83
  const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
79
84
  assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
80
85
  assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
81
86
  });
87
+ test("session-note project ownership is per note, persistent across sessions, and not reassigned by cwd", async () => {
88
+ freshRoot();
89
+ const cwdA = join(testEnvironment.cwd, "project-a");
90
+ const cwdB = join(testEnvironment.cwd, "project-b");
91
+ mkdirSync(cwdA, { recursive: true });
92
+ mkdirSync(cwdB, { recursive: true });
93
+ const projectA = projectKey(cwdA);
94
+ const projectB = projectKey(cwdB);
95
+ assert.notEqual(projectA, projectB);
96
+ const firstSession = manager();
97
+ const firstCaptured = makeExtension(firstSession);
98
+ const firstCtx = context(firstSession, undefined, undefined, true, cwdA);
99
+ await call(firstCaptured, "notes_write", { path: "first.md", content: "first project session" }, firstCtx);
100
+ const firstFile = physicalPath("session", "first.md", firstCtx);
101
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA);
102
+ const secondSession = manager();
103
+ const secondCaptured = makeExtension(secondSession);
104
+ const secondCtx = context(secondSession, undefined, undefined, true, cwdA);
105
+ await call(secondCaptured, "notes_write", { path: "second.md", content: "same project, another session" }, secondCtx);
106
+ const secondFile = physicalPath("session", "second.md", secondCtx);
107
+ assert.equal(parseNote(readFileSync(secondFile, "utf8")).meta.project, projectA, "another session in the same project carries the matching key");
108
+ const thirdSession = manager();
109
+ const thirdCaptured = makeExtension(thirdSession);
110
+ const thirdCtx = context(thirdSession, undefined, undefined, true, cwdB);
111
+ await call(thirdCaptured, "notes_write", { path: "third.md", content: "different project" }, thirdCtx);
112
+ const thirdFile = physicalPath("session", "third.md", thirdCtx);
113
+ assert.equal(parseNote(readFileSync(thirdFile, "utf8")).meta.project, projectB);
114
+ const projectASessions = [firstFile, secondFile, thirdFile].filter((file) => parseNote(readFileSync(file, "utf8")).meta.project === projectA);
115
+ assert.deepEqual(projectASessions.sort(), [firstFile, secondFile].sort(), "exact frontmatter project matching recognizes only sessions from the same project");
116
+ const movedContext = context(firstSession, undefined, undefined, true, cwdB);
117
+ await call(firstCaptured, "notes_write", { path: "first.md", content: "overwritten from another cwd" }, movedContext);
118
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "overwriting an existing note does not silently reassign it");
119
+ await call(firstCaptured, "notes_edit", { path: "first.md", edits: [{ oldText: "overwritten", newText: "edited" }] }, movedContext);
120
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "editing an existing note preserves its original project key");
121
+ await call(firstCaptured, "notes_read", { path: "first.md" }, movedContext);
122
+ assert.equal(parseNote(readFileSync(firstFile, "utf8")).meta.project, projectA, "reading preserves existing project ownership");
123
+ await call(firstCaptured, "notes_write", { path: "new-from-project-b.md", content: "new note", scope: "session" }, movedContext);
124
+ assert.equal(parseNote(readFileSync(physicalPath("session", "new-from-project-b.md", movedContext), "utf8")).meta.project, projectB, "only a newly-created session note uses the current project key");
125
+ await call(firstCaptured, "notes_write", { path: "project-note.md", content: "project home note", scope: "project" }, movedContext);
126
+ assert.equal(parseNote(readFileSync(physicalPath("project", "project-note.md", movedContext), "utf8")).meta.project, undefined, "project-home notes do not receive session ownership metadata");
127
+ assert.equal((await listNotes(movedContext, { scope: "session" })).length, 2, "project ownership remains frontmatter, not a separate note");
128
+ });
129
+ test("linked git worktrees share the main checkout's project key", () => {
130
+ freshRoot();
131
+ // realpath keeps the fixture's gitdir pointer and the asserted paths on one spelling
132
+ // (macOS temp roots live under the /var -> /private/var symlink).
133
+ const root = realpathSync(testEnvironment.cwd);
134
+ const main = join(root, "repo");
135
+ mkdirSync(main, { recursive: true });
136
+ const git = (args) => {
137
+ execFileSync("git", ["-c", "user.email=test@test", "-c", "user.name=test", ...args], { cwd: main, stdio: "ignore" });
138
+ };
139
+ git(["init", "-q"]);
140
+ git(["commit", "-q", "--allow-empty", "-m", "init"]);
141
+ const worktree = join(root, "wt");
142
+ git(["worktree", "add", "-q", "--detach", worktree]);
143
+ assert.equal(projectKey(worktree), projectKey(main), "a linked worktree resolves to the main checkout's key");
144
+ assert.equal(projectKey(join(worktree, "gone", "deeper")), projectKey(main), "a nonexistent subdirectory still resolves through its worktree");
145
+ assert.equal(scopeDir("project", context(manager(), undefined, undefined, true, worktree)), scopeDir("project", context(manager(), undefined, undefined, true, main)), "@project uses the same physical home from both checkouts");
146
+ });
147
+ test("legacy metadata is refused for explicit manual migration; invalid project ownership stays unknown", async () => {
148
+ freshRoot();
149
+ const session = manager();
150
+ const captured = makeExtension(session);
151
+ const ctx = context(session);
152
+ const legacyFile = physicalPath("session", "legacy.md", ctx);
153
+ mkdirSync(scopeDir("session", ctx), { recursive: true });
154
+ writeFileSync(legacyFile, `---
155
+ origin: self
156
+ status: active
157
+ stale: false
158
+ created_at: 2026-01-01T00:00:00.000+00:00
159
+ updated_at: 2026-01-01T00:00:00.000+00:00
160
+ last_accessed: 2026-01-01T00:00:00.000+00:00
161
+ access_count: 0
162
+ ---
163
+
164
+ legacy body`);
165
+ assert.throws(() => parseNote(readFileSync(legacyFile, "utf8")), /legacy note metadata .*requires manual migration/);
166
+ const legacyBytes = readFileSync(legacyFile, "utf8");
167
+ await assert.rejects(() => listNotes(ctx, { scope: "session" }), /requires manual migration/);
168
+ await assert.rejects(() => call(captured, "notes_read", { path: "legacy.md" }, ctx), /requires manual migration/);
169
+ await assert.rejects(() => call(captured, "notes_write", { path: "legacy.md", content: "legacy overwritten" }, ctx), /requires manual migration/);
170
+ assert.equal(readFileSync(legacyFile, "utf8"), legacyBytes, "refusal preserves the unmigrated file byte-for-byte");
171
+ const invalidFile = physicalPath("session", "invalid.md", ctx);
172
+ writeFileSync(invalidFile, `---
173
+ origin: self
174
+ status: active
175
+ stale: false
176
+ createdAt: 2026-01-01T00:00:00.000+00:00
177
+ updatedAt: 2026-01-01T00:00:00.000+00:00
178
+ lastAccessed: 2026-01-01T00:00:00.000+00:00
179
+ accessCount: 0
180
+ project: 17
181
+ ---
182
+
183
+ invalid owner`);
184
+ assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17);
185
+ assert.notEqual(parseNote(readFileSync(invalidFile, "utf8")).meta.project, projectKey(ctx.cwd), "invalid ownership does not match the current project key");
186
+ await call(captured, "notes_write", { path: "invalid.md", content: "still invalid" }, ctx);
187
+ assert.equal(parseNote(readFileSync(invalidFile, "utf8")).meta.project, 17, "an invalid value remains unknown and is not replaced with cwd-derived ownership");
188
+ assert.equal(existsSync(join(scopeDir("session", ctx), ".session.json")), false, "new notes use no ownership sidecar");
189
+ });
82
190
  test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
83
191
  freshRoot();
84
192
  const session = manager();
@@ -209,7 +317,29 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
209
317
  await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@human\/.*bare names are this session/);
210
318
  assert.equal(existsSync(join(root, "human", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
211
319
  });
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}`;
320
+ test("Pi adapter resolves agent and switched model identity on each notes call and boot", async () => {
321
+ const root = freshRoot();
322
+ const session = manager();
323
+ const captured = makeExtension(session);
324
+ const ctx = context(session, undefined, undefined, true, testEnvironment.cwd, true, "provider/First.Model");
325
+ await withAgent("Test Agent", async () => {
326
+ await call(captured, "notes_write", { address: "@self/private.md", content: "agent note" }, ctx);
327
+ await call(captured, "notes_write", { address: "@model/private.md", content: "first model note" }, ctx);
328
+ ctx.model = context(session, undefined, undefined, true, testEnvironment.cwd, true, "other/Second.Model").model;
329
+ assert.equal(resultJson(await call(captured, "notes_read", { address: "@model/private.md" }, ctx)).error, "note not found", "switched model does not fall back to previous home");
330
+ await call(captured, "notes_write", { address: "@model/private.md", content: "second model note" }, ctx);
331
+ const listed = resultJson(await call(captured, "notes_list", {}, ctx));
332
+ assert.deepEqual(listed.files.map((row) => row.address).sort(), ["@agents/test-agent/private.md", "@models/second-model/private.md"]);
333
+ const boot = await explicitBoot(ctx, "test-window", undefined);
334
+ assert.ok(boot.includes("@models/second-model/private.md"));
335
+ assert.equal(boot.includes("@models/first-model/private.md"), false);
336
+ assert.match(resultRead(await call(captured, "notes_read", { address: "@models/first-model/private.md" }, ctx)).content, /first model note$/);
337
+ const refused = resultJson(await call(captured, "notes_edit", { address: "@models/first-model/private.md", stale: true }, ctx));
338
+ assert.match(refused.error, /not your home/);
339
+ assert.match(readFileSync(join(root, "models/first-model/private.md"), "utf8"), /first model note$/);
340
+ });
341
+ });
342
+ const FRONTMATTER = (body) => `---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00.000+00:00\nupdatedAt: 2026-01-01T00:00:00.000+00:00\nlastAccessed: 2026-01-01T00:00:00.000+00:00\naccessCount: 0\n---\n\n${body}`;
213
343
  async function withAgent(name, run) {
214
344
  const previous = process.env.PI_NOTES_AGENT;
215
345
  if (name === undefined)
@@ -0,0 +1 @@
1
+ export {};
@@ -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,