@astrosheep/pi-context 0.25.2 → 0.26.1

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 (120) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +635 -381
  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 +51 -28
  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 +276 -236
  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/text-match.d.ts +5 -0
  60. package/dist/src/text-match.js +15 -0
  61. package/dist/src/tool-output.d.ts +97 -0
  62. package/dist/src/tool-output.js +1 -15
  63. package/dist/src/tool-schema.d.ts +17 -0
  64. package/dist/test/agent-loop.test.d.ts +1 -0
  65. package/dist/test/agent-loop.test.js +387 -20
  66. package/dist/test/boot.integration.test.d.ts +1 -0
  67. package/dist/test/boot.integration.test.js +59 -35
  68. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  69. package/dist/test/budget-settings.integration.test.js +28 -12
  70. package/dist/test/doctor.test.d.ts +1 -0
  71. package/dist/test/doctor.test.js +10 -2
  72. package/dist/test/dream-skill.test.d.ts +1 -0
  73. package/dist/test/dream-skill.test.js +69 -0
  74. package/dist/test/dream.test.d.ts +1 -0
  75. package/dist/test/helpers/extension-test-environment.d.ts +1 -0
  76. package/dist/test/helpers/extension-test-environment.js +9 -0
  77. package/dist/test/helpers/extension.d.ts +104 -0
  78. package/dist/test/helpers/extension.js +7 -81
  79. package/dist/test/helpers/notes.d.ts +6 -0
  80. package/dist/test/helpers/notes.js +13 -0
  81. package/dist/test/history.integration.test.d.ts +1 -0
  82. package/dist/test/history.integration.test.js +17 -19
  83. package/dist/test/notes-library.test.d.ts +1 -0
  84. package/dist/test/notes-library.test.js +128 -0
  85. package/dist/test/notes.integration.test.d.ts +1 -0
  86. package/dist/test/notes.integration.test.js +51 -55
  87. package/dist/test/notes.test.d.ts +1 -0
  88. package/dist/test/notes.test.js +162 -34
  89. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  90. package/dist/test/reset-lifecycle.test.js +142 -85
  91. package/docs/architecture.md +8 -8
  92. package/docs/reset-lifecycle.md +63 -79
  93. package/package.json +35 -2
  94. package/playbook.md +33 -32
  95. package/skills/dream/SKILL.md +12 -0
  96. package/src/context/boot.ts +44 -25
  97. package/src/context/budget.ts +52 -27
  98. package/src/context/context-window.ts +16 -1
  99. package/src/context/prompts.ts +2 -2
  100. package/src/context/reset-artifacts.ts +26 -24
  101. package/src/context/reset-lifecycle.ts +117 -111
  102. package/src/context/runtime.ts +50 -22
  103. package/src/context/thresholds.ts +1 -1
  104. package/src/dream/cli.ts +1 -1
  105. package/src/dream/doctor.ts +5 -2
  106. package/src/index.ts +4 -4
  107. package/src/notes/address.ts +9 -15
  108. package/src/notes/constants.ts +3 -0
  109. package/src/notes/context.ts +40 -0
  110. package/src/notes/frontmatter.ts +18 -12
  111. package/src/notes/index.ts +22 -0
  112. package/src/notes/paths.ts +64 -78
  113. package/src/notes/store.ts +289 -238
  114. package/src/pi/notes/adapter.ts +44 -0
  115. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  116. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  117. package/src/{notes → pi/notes}/tools.ts +25 -23
  118. package/src/protocol.ts +5 -6
  119. package/src/text-match.ts +13 -0
  120. package/src/tool-output.ts +2 -14
@@ -1,13 +1,12 @@
1
1
  import assert from "node:assert/strict";
2
+ import { rmSync, writeFileSync } from "node:fs";
2
3
  import test from "node:test";
3
4
  import { historyFromSession, internal } from "../src/index.js";
4
- import { listNotes } from "../src/notes/store.js";
5
- import { CONTINUATION_TYPE } from "../src/protocol.js";
6
- import { appendText, call, commitTurnEndBoundary, context, installExtensionTestEnvironment, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
7
- const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
8
- test.beforeEach(() => testEnvironment.beforeEach());
9
- test.afterEach(() => testEnvironment.afterEach());
10
- test.after(() => testEnvironment.dispose());
5
+ import { listNotes } from "./helpers/notes.js";
6
+ import { CONTINUATION_TYPE, WARNING_TYPE } from "../src/protocol.js";
7
+ import { appendText, call, commitTurnEndBoundary, context, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
8
+ import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
9
+ const testEnvironment = installExtensionTestHooks("pi-context-integration");
11
10
  test("custom reset marker removes old provider context while history remains searchable", async () => {
12
11
  const sessionManager = manager();
13
12
  const captured = makeExtension(sessionManager);
@@ -21,7 +20,12 @@ test("custom reset marker removes old provider context while history remains sea
21
20
  const branch = sessionManager.getBranch();
22
21
  const marker = branch.find((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
23
22
  assert.ok(marker && marker.type === "custom");
24
- assert.equal(marker.parentId, toolResultId, "marker follows the completed tool result");
23
+ const checkpoint = branch.find((entry) => entry.type === "compaction");
24
+ assert.ok(checkpoint && checkpoint.type === "compaction", "reset persists a native retain-none checkpoint");
25
+ assert.equal(checkpoint.parentId, toolResultId, "checkpoint follows the completed tool result");
26
+ assert.equal(checkpoint.summary, "");
27
+ assert.equal(checkpoint.firstKeptEntryId, checkpoint.id);
28
+ assert.equal(marker.parentId, checkpoint.id, "marker follows the native checkpoint");
25
29
  assert.deepEqual(Object.keys(marker.data), ["windowId"]);
26
30
  const projected = await runContextWithSystemHook(captured, ctx, sessionManager.buildSessionContext().messages);
27
31
  const providerText = JSON.stringify(projected?.messages ?? []);
@@ -44,16 +48,16 @@ test("the root boot and reset boot carry durable window identity", async () => {
44
48
  const ctx = context(sessionManager);
45
49
  appendText(sessionManager, "user", "task before reset");
46
50
  appendText(sessionManager, "assistant", "working");
47
- await call(captured, "notes_write", { path: "decisions.md", content: "use terra" }, ctx);
51
+ await call(captured, "notes_write", { address: "decisions.md", content: "use terra" }, ctx);
48
52
  // Root window: session_start persists the boot block without triggering a turn.
49
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
53
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
50
54
  assert.equal(captured.sent.length, 1);
51
55
  const rootBoot = captured.sent[0];
52
56
  assert.equal(rootBoot?.message.customType, internal.BOOT_TYPE);
53
57
  assert.equal(rootBoot?.message.display, false, "boot block stays out of the TUI");
54
58
  assert.equal(rootBoot?.options?.triggerTurn, false);
55
59
  assert.equal(noticesOf(ctx).length, 0, "initial boot is silent");
56
- runHandlers(captured, "session_start", { reason: "reload" }, ctx);
60
+ await runHandlers(captured, "session_start", { reason: "reload" }, ctx);
57
61
  assert.equal(noticesOf(ctx).length, 0, "reload is silent");
58
62
  assert.deepEqual(rootBoot?.message.details, { windowId: `pcw:${sessionManager.getSessionId().slice(0, 8)}:root` });
59
63
  const rootText = typeof rootBoot?.message.content === "string" ? rootBoot.message.content : "";
@@ -63,7 +67,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
63
67
  assert.match(rootText, new RegExp(`First context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
64
68
  assert.match(rootText, new RegExp(`Current context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
65
69
  assert.ok(rootText.includes("decisions.md"));
66
- const decisionsMeta = listNotes(ctx, { scope: "session" }).find((row) => row.path === "decisions.md")?.meta;
70
+ const decisionsMeta = (await listNotes(ctx, { scope: "session" })).find((row) => row.path === "decisions.md")?.meta;
67
71
  assert.ok(decisionsMeta);
68
72
  assert.match(rootText, /updated \d+s ago\)/, "boot note metadata carries a relative update time");
69
73
  assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
@@ -72,8 +76,8 @@ test("the root boot and reset boot carry durable window identity", async () => {
72
76
  assert.equal(noticesOf(ctx).length, 0, "requesting a reset does not announce success");
73
77
  const boundary = await commitTurnEndBoundary(captured, sessionManager, ctx);
74
78
  assert.equal(boundary.continue, true);
75
- runHandlers(captured, "turn_start", {}, ctx);
76
- runHandlers(captured, "agent_settled", {}, ctx);
79
+ await runHandlers(captured, "turn_start", {}, ctx);
80
+ await runHandlers(captured, "agent_settled", {}, ctx);
77
81
  assert.equal(noticesOf(ctx).filter((notice) => notice.message.includes("memory cleared")).length, 1, "the committed reset is announced once");
78
82
  const entries = sessionManager.getBranch();
79
83
  const marker = entries.find((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
@@ -85,7 +89,28 @@ test("the root boot and reset boot carry durable window identity", async () => {
85
89
  const continuation = entries.find((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE);
86
90
  assert.ok(continuation && continuation.type === "custom_message" && continuation.display === false, "the resumed run is represented by one hidden continuation");
87
91
  });
88
- test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", () => {
92
+ test("an aborted async boot repair emits no boot, continuation, or incomplete-notes notice", async () => {
93
+ const notesHome = process.env.PI_NOTES_HOME;
94
+ assert.ok(notesHome);
95
+ rmSync(notesHome, { recursive: true, force: true });
96
+ writeFileSync(notesHome, "not a directory");
97
+ const sessionManager = manager();
98
+ const windowId = "pcw:aborted-boot-repair";
99
+ sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
100
+ const captured = makeExtension(sessionManager);
101
+ const ctx = context(sessionManager);
102
+ const controller = new AbortController();
103
+ ctx.signal = controller.signal;
104
+ const sessionStart = captured.handlers.get("session_start")?.[0];
105
+ assert.ok(sessionStart);
106
+ const pending = sessionStart({ reason: "startup" }, ctx);
107
+ controller.abort();
108
+ await pending;
109
+ assert.equal(captured.sent.length, 0, "aborted snapshot work cannot append the reset boot or continuation");
110
+ assert.equal(noticesOf(ctx).some((notice) => notice.message.includes("notes index incomplete")), false, "aborted snapshot work cannot notify about missing homes");
111
+ assert.equal(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && [internal.BOOT_TYPE, CONTINUATION_TYPE].includes(entry.customType)), false);
112
+ });
113
+ test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", async () => {
89
114
  const sessionManager = manager(true);
90
115
  const windowId = "pcw:metadata-tail";
91
116
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
@@ -93,7 +118,7 @@ test("a marker tail with only metadata repairs its missing boot and continuation
93
118
  sessionManager.appendThinkingLevelChange("low");
94
119
  const captured = makeExtension(sessionManager);
95
120
  const ctx = context(sessionManager);
96
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
121
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
97
122
  const branch = sessionManager.getBranch();
98
123
  const markerIndex = branch.findIndex((entry) => entry.id === markerId);
99
124
  const bootEntries = branch.filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE && entry.details && typeof entry.details === "object" && entry.details.windowId === windowId);
@@ -108,13 +133,13 @@ test("a marker tail with only metadata repairs its missing boot and continuation
108
133
  assert.equal(captured.sent[1]?.message.customType, CONTINUATION_TYPE);
109
134
  assert.equal(noticesOf(ctx).length, 0, "repairing a reset tail is not a new reset");
110
135
  });
111
- test("a bare marker tail repairs the full ordered reset shape", () => {
136
+ test("a bare marker tail repairs the full ordered reset shape", async () => {
112
137
  const sessionManager = manager(true);
113
138
  const windowId = "pcw:bare-marker";
114
139
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
115
140
  const captured = makeExtension(sessionManager);
116
141
  const ctx = context(sessionManager);
117
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
142
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
118
143
  assert.equal(captured.sent.length, 2, "a bare marker emits a boot and a continuation");
119
144
  const branch = sessionManager.getBranch();
120
145
  const markerIndex = branch.findIndex((entry) => entry.id === markerId);
@@ -123,7 +148,7 @@ test("a bare marker tail repairs the full ordered reset shape", () => {
123
148
  assert.ok(bootIndex > markerIndex, "the repaired boot follows its marker");
124
149
  assert.ok(continuationIndex > bootIndex, "the repaired continuation follows its boot");
125
150
  });
126
- test("a marker tail that already carries a boot repairs only its missing continuation, once", () => {
151
+ test("a marker tail that already carries a boot repairs only its missing continuation, once", async () => {
127
152
  const sessionManager = manager(true);
128
153
  const windowId = "pcw:missing-continuation";
129
154
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
@@ -131,23 +156,23 @@ test("a marker tail that already carries a boot repairs only its missing continu
131
156
  sessionManager.appendCustomMessageEntry(internal.BOOT_TYPE, "already-persisted boot", false, { windowId });
132
157
  const captured = makeExtension(sessionManager);
133
158
  const ctx = context(sessionManager);
134
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
159
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
135
160
  assert.equal(captured.sent.length, 1, "only the missing continuation is emitted");
136
161
  assert.equal(captured.sent[0]?.message.customType, CONTINUATION_TYPE);
137
162
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 1, "the existing boot is never duplicated");
138
163
  assert.ok(sessionManager.getBranch().findIndex((entry) => entry.id === markerId) >= 0);
139
- runHandlers(captured, "session_start", { reason: "reload" }, ctx);
164
+ await runHandlers(captured, "session_start", { reason: "reload" }, ctx);
140
165
  assert.equal(captured.sent.length, 1, "a complete reset tail is idempotent");
141
166
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 1);
142
167
  });
143
- test("a marker tail followed by real conversation is never repaired", () => {
168
+ test("a marker tail followed by real conversation is never repaired", async () => {
144
169
  const sessionManager = manager(true);
145
170
  const windowId = "pcw:unsafe-tail";
146
171
  sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
147
172
  appendText(sessionManager, "user", "post-marker work that must not be pushed behind a late boot");
148
173
  const captured = makeExtension(sessionManager);
149
174
  const ctx = context(sessionManager);
150
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
175
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
151
176
  assert.equal(captured.sent.length, 0, "an unsafe marker tail emits nothing");
152
177
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 0, "no boot is appended after real work");
153
178
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 0, "no continuation is appended after real work");
@@ -180,37 +205,36 @@ test("wipe_memory uses one turn boundary and never calls ctx.compact", async ()
180
205
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 1);
181
206
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE && entry.details && typeof entry.details === "object" && "windowId" in entry.details).length, 1);
182
207
  });
183
- test("pi-context command toggles future work, /wipe-memory is the manual path, and active markers cancel /compact", async () => {
208
+ test("pi-context command toggles future work, /wipe-memory starts close-out, and /compact remains disabled", async () => {
184
209
  const sessionManager = manager();
185
210
  appendText(sessionManager, "user", "hello");
186
211
  const captured = makeExtension(sessionManager);
187
212
  const low = context(sessionManager, undefined, { tokens: 170_000, contextWindow: 200_000, percent: 85 });
188
213
  // On by default: session_start persists the root boot block.
189
- runHandlers(captured, "session_start", { reason: "startup" }, low);
214
+ await runHandlers(captured, "session_start", { reason: "startup" }, low);
190
215
  assert.equal(captured.sent.length, 1);
191
216
  assert.equal(captured.sent[0]?.message.customType, internal.BOOT_TYPE);
192
217
  let notices = await runCommand(captured, "pi-context", "off", low);
193
218
  assert.match(notices[0]?.message ?? "", /off/);
194
219
  assert.equal(await runContextHook(captured, low), undefined, "no guidance while off");
195
220
  assert.equal(sentOf(captured, internal.GUIDANCE_TYPE).length, 0, "no guidance persisted while off");
196
- runHandlers(captured, "session_start", { reason: "startup" }, low);
221
+ await runHandlers(captured, "session_start", { reason: "startup" }, low);
197
222
  assert.equal(captured.sent.length, 1, "no new boot block is persisted while off");
198
223
  const offResult = resultJson(await call(captured, "wipe_memory", {}, low));
199
224
  assert.match(offResult.error ?? "", /off/, "wipe_memory refuses while off");
200
225
  notices = await runCommand(captured, "pi-context", "on", low);
201
226
  assert.match(notices[0]?.message ?? "", /on/);
202
227
  notices = await runCommand(captured, "wipe-memory", "", low);
203
- assert.equal(notices.length, 1, "manual clear emits exactly one notification");
204
- assert.match(notices[0]?.message ?? "", /memory cleared/);
205
- assert.equal(captured.sent.length, 3, "/wipe-memory writes one hidden boot and one continuation without triggering a model turn");
206
- assert.equal(captured.sent[1]?.options?.triggerTurn, false);
207
- assert.equal(captured.sent[1]?.message.customType, internal.BOOT_TYPE);
208
- assert.equal(captured.sent[2]?.message.customType, CONTINUATION_TYPE);
209
- assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 1);
228
+ assert.equal(notices.length, 0, "the command begins a model close-out rather than announcing a reset request");
229
+ assert.equal(captured.sent.length, 2, "the command persists one hidden warning after the startup boot");
230
+ assert.equal(captured.sent[1]?.options?.triggerTurn, true, "the shared warning starts an ordinary agent turn");
231
+ assert.equal(captured.sent[1]?.message.customType, WARNING_TYPE);
232
+ assert.equal(captured.sent[1]?.message.display, false);
233
+ assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 0, "no reset is claimed until close-out completes");
210
234
  const markerContext = await runManualCompact(captured, low);
211
- assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while a marker is active");
235
+ assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while context windows are enabled");
212
236
  const projected = await runContextWithSystemHook(captured, low, sessionManager.buildSessionContext().messages);
213
- assert.equal(JSON.stringify(projected?.messages ?? []).includes("hello"), false, "off keeps the existing wipe in force");
237
+ assert.equal(JSON.stringify(projected?.messages ?? []).includes("hello"), true, "the old window remains active until the close-out commits");
214
238
  notices = await runCommand(captured, "pi-context", "maybe", low);
215
239
  assert.equal(notices[0]?.type, "error", "unknown argument rejected");
216
240
  // Bare command reports current state without changing it.
@@ -0,0 +1 @@
1
+ export {};
@@ -1,11 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
  import { internal } from "../src/index.js";
4
- import { appendText, call, commitTurnEndBoundary, context, installExtensionTestEnvironment, makeExtension, manager, noticesOf, resultJson, runContextHook, runHandlers, sentOf, settingsFixture, } from "./helpers/extension.js";
5
- const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
6
- test.beforeEach(() => testEnvironment.beforeEach());
7
- test.afterEach(() => testEnvironment.afterEach());
8
- test.after(() => testEnvironment.dispose());
4
+ import { appendText, call, commitTurnEndBoundary, context, makeExtension, manager, noticesOf, resultJson, runContextHook, runHandlers, sentOf, settingsFixture, } from "./helpers/extension.js";
5
+ import { installExtensionTestHooks } from "./helpers/extension-test-environment.js";
6
+ const testEnvironment = installExtensionTestHooks("pi-context-integration");
9
7
  test("low-budget guidance and warning persist at turn_end, once per active window", async () => {
10
8
  const sessionManager = manager();
11
9
  const captured = makeExtension(sessionManager);
@@ -24,6 +22,23 @@ test("low-budget guidance and warning persist at turn_end, once per active windo
24
22
  assert.equal(warningBoundary.entries.filter((entry) => entry.type === "custom_message" && entry.customType === internal.WARNING_TYPE).length, 1);
25
23
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.WARNING_TYPE).length, 1);
26
24
  });
25
+ test("early guidance stays silent and the committed final warning notifies once", async () => {
26
+ const sm = manager();
27
+ const captured = makeExtension(sm);
28
+ const early = context(sm, undefined, { tokens: 165_000, percent: 82.5, contextWindow: 200_000 });
29
+ assert.equal(await runContextHook(captured, early), undefined, "early guidance is persisted at turn_end, not injected immediately");
30
+ await commitTurnEndBoundary(captured, sm, early);
31
+ await runHandlers(captured, "agent_settled", {}, early);
32
+ assert.equal(noticesOf(early).filter((notice) => notice.message.includes("Context running low") || notice.message.includes("Context almost full")).length, 0, "early model guidance has no UI toast");
33
+ assert.equal(sm.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.GUIDANCE_TYPE).length, 1, "early guidance remains durable for the model");
34
+ const final = context(sm, undefined, { tokens: 181_000, percent: 90.5, contextWindow: 200_000 });
35
+ const warning = await runContextHook(captured, final);
36
+ assert.equal(warning?.messages[0]?.customType, internal.WARNING_TYPE);
37
+ await commitTurnEndBoundary(captured, sm, final);
38
+ await runHandlers(captured, "turn_start", {}, final);
39
+ await runHandlers(captured, "agent_settled", {}, final);
40
+ assert.equal(noticesOf(final).filter((notice) => notice.message === "pi-context: Context almost full; close out the current memory window.").length, 1, "only the committed final warning notifies, exactly once");
41
+ });
27
42
  test("the visible countdown ends at the warning line, clamps at zero, and preserves unknown usage", async () => {
28
43
  const fixture = settingsFixture({
29
44
  reserveTokens: 16_384,
@@ -31,7 +46,7 @@ test("the visible countdown ends at the warning line, clamps at zero, and preser
31
46
  });
32
47
  const sm = manager();
33
48
  const captured = makeExtension(sm);
34
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
49
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
35
50
  const readBudget = async (tokens, trusted = true) => {
36
51
  const ctx = context(sm, undefined, { tokens, contextWindow: 200_000, percent: tokens === null ? null : tokens / 2000 }, true, fixture.cwd, trusted);
37
52
  return resultJson(await call(captured, "get_context_remaining", {}, ctx)).remaining_tokens;
@@ -44,7 +59,7 @@ test("the visible countdown ends at the warning line, clamps at zero, and preser
44
59
  const absent = context(sm, undefined, undefined, true, fixture.cwd);
45
60
  assert.equal(resultJson(await call(captured, "get_context_remaining", {}, absent)).remaining_tokens, null);
46
61
  const untrusted = context(sm, undefined, { tokens: 72_563, contextWindow: 200_000, percent: 36.2815 }, true, fixture.cwd, false);
47
- runHandlers(captured, "session_start", {}, untrusted);
62
+ await runHandlers(captured, "session_start", {}, untrusted);
48
63
  assert.equal(await readBudget(72_563, false), 98_765, "session start reloads the global reserve when the project is untrusted");
49
64
  });
50
65
  test("absent pi-context key or margins reproduce the default reminder threshold at Pi's default reserve", async () => {
@@ -59,7 +74,7 @@ test("absent pi-context key or margins reproduce the default reminder threshold
59
74
  const captured = makeExtension(sm);
60
75
  // Thresholds are resolved once per session and cached; branch navigation clears the
61
76
  // cache without emitting a boot block, so the next read uses this fixture.
62
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
77
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
63
78
  const window = 200_000;
64
79
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
65
80
  const first = at(40_961);
@@ -81,7 +96,7 @@ test("project pi-context reminder margin and reserve override global per key", a
81
96
  // Project reserve wins: reminder = 50000 + 40000 (project margin).
82
97
  const sm = manager();
83
98
  const captured = makeExtension(sm);
84
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
99
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
85
100
  const window = 300_000;
86
101
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
87
102
  assert.equal(await runContextHook(captured, at(90_001)), undefined, "nothing injected above the project-derived reminder");
@@ -96,12 +111,13 @@ test("an invalid reminder margin degrades to its default with one warning and ne
96
111
  const sm = manager();
97
112
  const captured = makeExtension(sm);
98
113
  const ctx = context(sm, undefined, undefined, true, fixture.cwd);
99
- assert.doesNotThrow(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
114
+ await assert.doesNotReject(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
100
115
  const notices = noticesOf(ctx).filter((notice) => notice.type === "warning");
101
116
  assert.equal(notices.length, 1, "one warning for the offending key");
102
117
  assert.equal(notices[0]?.type, "warning");
103
118
  assert.match(notices[0]?.message ?? "", /reminderMarginTokens/);
104
- assert.match(notices[0]?.message ?? "", /24576/);
119
+ assert.match(notices[0]?.message ?? "", /default reminder margin/);
120
+ assert.equal(/\d+\s*k?\s*(remaining|tokens)/i.test(notices[0]?.message ?? ""), false, "UI notices do not expose token counts");
105
121
  const window = 200_000;
106
122
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
107
123
  // The degraded reminder is Pi's default reserve + default margin = 40960.
@@ -121,6 +137,6 @@ test("the warning supersedes the early reminder when usage jumps across both thr
121
137
  assert.deepEqual(warningResult?.messages.map((message) => message.customType), [internal.WARNING_TYPE]);
122
138
  await commitTurnEndBoundary(captured, sm, ctx);
123
139
  const reloaded = makeExtension(sm);
124
- runHandlers(reloaded, "context", {}, ctx);
140
+ await runHandlers(reloaded, "context", {}, ctx);
125
141
  assert.equal(reloaded.sent.length, 0, "persisted warning also suppresses a late reminder after reload");
126
142
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -1,11 +1,19 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { main } from "../src/dream/cli.js";
3
3
  import { doctor } from "../src/dream/doctor.js";
4
- import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import test from "node:test";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
- const note = "---\norigin: self\nstatus: active\nstale: false\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nlast_accessed: 2026-01-01T00:00:00Z\naccess_count: 0\n---\n\n";
8
+ const note = "---\norigin: self\nstatus: active\nstale: false\ncreatedAt: 2026-01-01T00:00:00Z\nupdatedAt: 2026-01-01T00:00:00Z\nlastAccessed: 2026-01-01T00:00:00Z\naccessCount: 0\n---\n\n";
9
+ test("doctor identifies legacy metadata as requiring manual migration", (t) => {
10
+ const root = mkdtempSync(join(tmpdir(), "dream-doctor-legacy-"));
11
+ t.after(() => rmSync(root, { recursive: true, force: true }));
12
+ mkdirSync(join(root, "human"));
13
+ writeFileSync(join(root, "human/legacy.md"), note.replace("createdAt", "created_at"));
14
+ const issues = doctor(root);
15
+ assert.ok(issues.some((issue) => issue.includes("legacy metadata key created_at; manually migrate to createdAt")));
16
+ });
9
17
  test("doctor validates without repairing files or running the dreamer", async () => {
10
18
  const root = mkdtempSync(join(tmpdir(), "dream-doctor-test-"));
11
19
  mkdirSync(join(root, "human"));
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,69 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import test from "node:test";
5
+ import { tmpdir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { loadSkillsFromDir } from "@earendil-works/pi-coding-agent";
9
+ import { loadPlaybook } from "../src/dream/runner.js";
10
+ function packageRoot() {
11
+ let dir = dirname(fileURLToPath(import.meta.url));
12
+ while (!existsSync(join(dir, "package.json"))) {
13
+ const parent = dirname(dir);
14
+ if (parent === dir)
15
+ throw new Error("could not locate package root");
16
+ dir = parent;
17
+ }
18
+ return dir;
19
+ }
20
+ function discoverDreamSkill(skillsDir) {
21
+ const result = loadSkillsFromDir({ dir: skillsDir, source: "path" });
22
+ assert.deepEqual(result.diagnostics, []);
23
+ const skill = result.skills.find((candidate) => candidate.name === "dream");
24
+ assert.ok(skill, "Pi's public skill loader discovers dream");
25
+ assert.ok(skill.description.length > 0);
26
+ return skill;
27
+ }
28
+ test("dream skill is publicly discoverable and resolves the CLI's shared playbook", () => {
29
+ const root = packageRoot();
30
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
31
+ assert.deepEqual(manifest.pi.skills, ["./skills"]);
32
+ assert.deepEqual(manifest.pi.extensions, ["./dist/extension.js"]);
33
+ assert.equal(manifest.bin.dream, "dist/src/dream/cli.js");
34
+ assert.ok(manifest.files.includes("skills"));
35
+ const skill = discoverDreamSkill(join(root, "skills"));
36
+ const skillPlaybook = resolve(skill.baseDir, "../../playbook.md");
37
+ const packagePlaybook = resolve(root, "playbook.md");
38
+ assert.equal(skillPlaybook, packagePlaybook);
39
+ const playbook = readFileSync(packagePlaybook, "utf8");
40
+ assert.equal(loadPlaybook(skillPlaybook), playbook);
41
+ });
42
+ test("npm tarball keeps the skill's relative playbook path portable after extraction", () => {
43
+ const root = packageRoot();
44
+ const fixture = mkdtempSync(join(tmpdir(), "dream-skill-pack-"));
45
+ try {
46
+ const packed = JSON.parse(execFileSync("npm", ["pack", "--offline", "--ignore-scripts", "--json", "--pack-destination", fixture], {
47
+ cwd: root,
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ }))[0];
51
+ const packedPaths = packed.files.map((file) => file.path);
52
+ assert.ok(packedPaths.includes("skills/dream/SKILL.md"));
53
+ assert.ok(packedPaths.includes("playbook.md"));
54
+ assert.ok(packedPaths.includes("dist/extension.js"));
55
+ assert.ok(packedPaths.includes("dist/src/dream/cli.js"));
56
+ const extracted = join(fixture, "extracted");
57
+ execFileSync("mkdir", ["-p", extracted]);
58
+ execFileSync("tar", ["-xzf", join(fixture, packed.filename), "-C", extracted]);
59
+ const installedRoot = join(extracted, "package");
60
+ const installedSkill = discoverDreamSkill(join(installedRoot, "skills"));
61
+ const referencedPlaybook = resolve(installedSkill.baseDir, "../../playbook.md");
62
+ assert.equal(referencedPlaybook, resolve(installedRoot, "playbook.md"));
63
+ assert.equal(loadPlaybook(referencedPlaybook), readFileSync(referencedPlaybook, "utf8"));
64
+ assert.equal(loadPlaybook(referencedPlaybook), readFileSync(join(root, "playbook.md"), "utf8"));
65
+ }
66
+ finally {
67
+ rmSync(fixture, { recursive: true, force: true });
68
+ }
69
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function installExtensionTestHooks(prefix: string): import("./extension.js").ExtensionTestEnvironment;
@@ -0,0 +1,9 @@
1
+ import test from "node:test";
2
+ import { installExtensionTestEnvironment } from "./extension.js";
3
+ export function installExtensionTestHooks(prefix) {
4
+ const environment = installExtensionTestEnvironment(prefix);
5
+ test.beforeEach(() => environment.beforeEach());
6
+ test.afterEach(() => environment.afterEach());
7
+ test.after(() => environment.dispose());
8
+ return environment;
9
+ }
@@ -0,0 +1,104 @@
1
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
2
+ import { type ContextUsage, type ExtensionAPI, type ExtensionContext, type RegisteredCommand, SessionManager, SettingsManager, type SessionBoundaryDraft, type ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ export type ExtensionTestEnvironment = {
4
+ readonly cwd: string;
5
+ readonly agentDir: string;
6
+ beforeEach(): void;
7
+ afterEach(): void;
8
+ newNotesRoot(): string;
9
+ dispose(): void;
10
+ };
11
+ /**
12
+ * Install the process-level settings and notes roots needed by one test file.
13
+ * The caller owns the lifecycle hooks; this helper never registers tests or hooks.
14
+ */
15
+ export declare function installExtensionTestEnvironment(prefix?: string): ExtensionTestEnvironment;
16
+ export type Notice = {
17
+ message: string;
18
+ type?: "info" | "warning" | "error";
19
+ };
20
+ export declare function writeJson(path: string, value: unknown): void;
21
+ export type SettingsFixture = {
22
+ cwd: string;
23
+ agentDir: string;
24
+ };
25
+ /**
26
+ * Materialize global (agentDir/settings.json) and project (cwd/.pi/settings.json)
27
+ * settings in temp directories, then read them back through the same public
28
+ * SettingsManager.create the extension uses. Never touches the real ~/.pi.
29
+ */
30
+ export declare function settingsFixture(options?: {
31
+ global?: Record<string, unknown>;
32
+ project?: Record<string, unknown>;
33
+ reserveTokens?: number;
34
+ }): SettingsFixture;
35
+ export type EventHandler = (event: never, ctx: ExtensionContext) => unknown;
36
+ type SendMessageArg = Parameters<ExtensionAPI["sendMessage"]>[0];
37
+ export type SentMessage = {
38
+ message: SendMessageArg;
39
+ options: {
40
+ triggerTurn?: boolean;
41
+ } | undefined;
42
+ };
43
+ export type Captured = {
44
+ tools: Map<string, ToolDefinition>;
45
+ handlers: Map<string, EventHandler[]>;
46
+ commands: Map<string, CommandOptions>;
47
+ sent: SentMessage[];
48
+ contextMessages: unknown[];
49
+ flags: string[];
50
+ };
51
+ export type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
52
+ export type CompactionHookResult = {
53
+ cancel: true;
54
+ } | {
55
+ compaction: {
56
+ summary: string;
57
+ firstKeptEntryId: string | null;
58
+ tokensBefore: number;
59
+ details?: unknown;
60
+ };
61
+ } | undefined;
62
+ /** TypeBox's TSchema does not expose `type`/`required` statically; read them structurally. */
63
+ export declare function objectSchema(tool: ToolDefinition | undefined): {
64
+ type?: string;
65
+ required?: string[];
66
+ } | undefined;
67
+ export declare function manager(persisted?: boolean): SessionManager;
68
+ export declare function makeExtension(sessionManager: SessionManager, settingsManager?: SettingsManager): Captured;
69
+ export declare function explicitBoot(ctx: ExtensionContext, currentWindowId: string, previousWindowId: string | undefined): Promise<string>;
70
+ export declare function context(sessionManager: SessionManager, compact?: ExtensionContext["compact"], usage?: ContextUsage, idle?: boolean, cwd?: string, projectTrusted?: boolean, model?: string | {
71
+ provider: string;
72
+ id: string;
73
+ }): ExtensionContext;
74
+ export declare function noticesOf(ctx: ExtensionContext): Notice[];
75
+ export declare function sentOf(captured: Captured, customType: string): SentMessage[];
76
+ export declare function call(captured: Captured, name: string, params: Record<string, unknown>, ctx: ExtensionContext): Promise<AgentToolResult<unknown>>;
77
+ export declare function resultJson<T>(result: AgentToolResult<unknown>): T;
78
+ /** Assert the delivered wire text fits the tool-output budget, header included for raw reads. */
79
+ export declare function assertWithinBudget(result: AgentToolResult<unknown>, message: string): void;
80
+ /** Decoded raw read response using the shared READ WINDOW grammar. */
81
+ export type ReadWindow = {
82
+ header: string;
83
+ content: string;
84
+ offset_chars: number;
85
+ total_chars: number;
86
+ next_offset_chars: number | null;
87
+ details: Record<string, unknown>;
88
+ };
89
+ /** Decode either raw read without including its shared metadata block in the payload. */
90
+ export declare function resultRead(result: AgentToolResult<unknown>): ReadWindow;
91
+ export declare function runManualCompact(captured: Captured, ctx: ExtensionContext): Promise<CompactionHookResult>;
92
+ export declare function runHandlers(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<void>;
93
+ export declare function runCommand(captured: Captured, name: string, args: string, ctx: ExtensionContext): Promise<Notice[]>;
94
+ export type ContextHookResult = {
95
+ messages: unknown[];
96
+ } | undefined;
97
+ export declare function runContextHook(captured: Captured, ctx: ExtensionContext, eventOverride?: Record<string, unknown>): Promise<ContextHookResult>;
98
+ export declare function runContextWithSystemHook(captured: Captured, ctx: ExtensionContext, messages: unknown[]): Promise<ContextHookResult>;
99
+ export declare function commitTurnEndBoundary(captured: Captured, sessionManager: SessionManager, ctx: ExtensionContext): Promise<{
100
+ entries: SessionBoundaryDraft[];
101
+ continue: boolean;
102
+ }>;
103
+ export declare function appendText(sessionManager: SessionManager, role: "user" | "assistant" | "toolResult", text: string, toolName?: string): string;
104
+ export {};