@astrosheep/pi-context 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +88 -7
  2. package/dist/build-info.json +2 -2
  3. package/dist/extension.js +616 -370
  4. package/dist/src/context/boot.d.ts +24 -0
  5. package/dist/src/context/boot.js +33 -24
  6. package/dist/src/context/budget.d.ts +9 -0
  7. package/dist/src/context/budget.js +19 -15
  8. package/dist/src/context/context-window.d.ts +41 -0
  9. package/dist/src/context/context-window.js +16 -1
  10. package/dist/src/context/prompts.d.ts +20 -0
  11. package/dist/src/context/prompts.js +1 -1
  12. package/dist/src/context/reset-artifacts.d.ts +26 -0
  13. package/dist/src/context/reset-artifacts.js +18 -17
  14. package/dist/src/context/reset-lifecycle.d.ts +89 -0
  15. package/dist/src/context/reset-lifecycle.js +103 -75
  16. package/dist/src/context/runtime.d.ts +3 -0
  17. package/dist/src/context/runtime.js +53 -21
  18. package/dist/src/context/thresholds.d.ts +33 -0
  19. package/dist/src/context/thresholds.js +1 -1
  20. package/dist/src/dream/cli.d.ts +10 -0
  21. package/dist/src/dream/cli.js +1 -1
  22. package/dist/src/dream/doctor.d.ts +2 -0
  23. package/dist/src/dream/doctor.js +6 -2
  24. package/dist/src/dream/gates.d.ts +10 -0
  25. package/dist/src/dream/git.d.ts +21 -0
  26. package/dist/src/dream/lock.d.ts +31 -0
  27. package/dist/src/dream/runner.d.ts +30 -0
  28. package/dist/src/dream/settings.d.ts +16 -0
  29. package/dist/src/history/history-tools.d.ts +2 -0
  30. package/dist/src/history/history.d.ts +57 -0
  31. package/dist/src/index.d.ts +39 -0
  32. package/dist/src/index.js +4 -4
  33. package/dist/src/notes/address.d.ts +26 -0
  34. package/dist/src/notes/address.js +8 -14
  35. package/dist/src/notes/constants.d.ts +3 -0
  36. package/dist/src/notes/constants.js +3 -0
  37. package/dist/src/notes/context.d.ts +10 -0
  38. package/dist/src/notes/context.js +33 -0
  39. package/dist/src/notes/frontmatter.d.ts +46 -0
  40. package/dist/src/notes/frontmatter.js +10 -5
  41. package/dist/src/notes/index.d.ts +4 -0
  42. package/dist/src/notes/index.js +2 -0
  43. package/dist/src/notes/paths.d.ts +21 -0
  44. package/dist/src/notes/paths.js +72 -76
  45. package/dist/src/notes/store.d.ts +94 -0
  46. package/dist/src/notes/store.js +298 -242
  47. package/dist/src/pi/notes/adapter.d.ts +12 -0
  48. package/dist/src/pi/notes/adapter.js +39 -0
  49. package/dist/src/pi/notes/session-replay.d.ts +16 -0
  50. package/dist/src/{notes → pi/notes}/session-replay.js +2 -2
  51. package/dist/src/pi/notes/snapshot.d.ts +33 -0
  52. package/dist/src/{notes/notes-snapshot.js → pi/notes/snapshot.js} +11 -3
  53. package/dist/src/pi/notes/tools.d.ts +2 -0
  54. package/dist/src/{notes → pi/notes}/tools.js +24 -21
  55. package/dist/src/protocol.d.ts +41 -0
  56. package/dist/src/protocol.js +4 -6
  57. package/dist/src/session-reader.d.ts +5 -0
  58. package/dist/src/settings.d.ts +6 -0
  59. package/dist/src/tool-output.d.ts +101 -0
  60. package/dist/src/tool-schema.d.ts +17 -0
  61. package/dist/test/agent-loop.test.d.ts +1 -0
  62. package/dist/test/agent-loop.test.js +318 -19
  63. package/dist/test/boot.integration.test.d.ts +1 -0
  64. package/dist/test/boot.integration.test.js +55 -29
  65. package/dist/test/budget-settings.integration.test.d.ts +1 -0
  66. package/dist/test/budget-settings.integration.test.js +8 -7
  67. package/dist/test/doctor.test.d.ts +1 -0
  68. package/dist/test/doctor.test.js +10 -2
  69. package/dist/test/dream-skill.test.d.ts +1 -0
  70. package/dist/test/dream-skill.test.js +69 -0
  71. package/dist/test/dream.test.d.ts +1 -0
  72. package/dist/test/helpers/extension.d.ts +115 -0
  73. package/dist/test/helpers/extension.js +6 -6
  74. package/dist/test/helpers/notes.d.ts +6 -0
  75. package/dist/test/helpers/notes.js +13 -0
  76. package/dist/test/history.integration.test.d.ts +1 -0
  77. package/dist/test/notes-library.test.d.ts +1 -0
  78. package/dist/test/notes-library.test.js +111 -0
  79. package/dist/test/notes.integration.test.d.ts +1 -0
  80. package/dist/test/notes.integration.test.js +22 -24
  81. package/dist/test/notes.test.d.ts +1 -0
  82. package/dist/test/notes.test.js +137 -7
  83. package/dist/test/reset-lifecycle.test.d.ts +1 -0
  84. package/dist/test/reset-lifecycle.test.js +142 -85
  85. package/docs/architecture.md +8 -8
  86. package/docs/reset-lifecycle.md +63 -79
  87. package/package.json +35 -2
  88. package/playbook.md +33 -32
  89. package/skills/dream/SKILL.md +12 -0
  90. package/src/context/boot.ts +44 -25
  91. package/src/context/budget.ts +25 -17
  92. package/src/context/context-window.ts +16 -1
  93. package/src/context/prompts.ts +2 -2
  94. package/src/context/reset-artifacts.ts +26 -24
  95. package/src/context/reset-lifecycle.ts +117 -111
  96. package/src/context/runtime.ts +50 -22
  97. package/src/context/thresholds.ts +1 -1
  98. package/src/dream/cli.ts +1 -1
  99. package/src/dream/doctor.ts +5 -2
  100. package/src/index.ts +4 -4
  101. package/src/notes/address.ts +9 -15
  102. package/src/notes/constants.ts +3 -0
  103. package/src/notes/context.ts +40 -0
  104. package/src/notes/frontmatter.ts +18 -12
  105. package/src/notes/index.ts +22 -0
  106. package/src/notes/paths.ts +64 -78
  107. package/src/notes/store.ts +308 -244
  108. package/src/pi/notes/adapter.ts +44 -0
  109. package/src/{notes → pi/notes}/session-replay.ts +3 -3
  110. package/src/{notes/notes-snapshot.ts → pi/notes/snapshot.ts} +13 -4
  111. package/src/{notes → pi/notes}/tools.ts +25 -23
  112. package/src/protocol.ts +5 -6
@@ -1,8 +1,9 @@
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";
5
+ import { listNotes } from "./helpers/notes.js";
6
+ import { CONTINUATION_TYPE, WARNING_TYPE } from "../src/protocol.js";
6
7
  import { appendText, call, commitTurnEndBoundary, context, installExtensionTestEnvironment, makeExtension, manager, noticesOf, resultJson, resultRead, runContextHook, runContextWithSystemHook, runHandlers, runManualCompact, runCommand, sentOf, } from "./helpers/extension.js";
7
8
  const testEnvironment = installExtensionTestEnvironment("pi-context-integration");
8
9
  test.beforeEach(() => testEnvironment.beforeEach());
@@ -21,7 +22,12 @@ test("custom reset marker removes old provider context while history remains sea
21
22
  const branch = sessionManager.getBranch();
22
23
  const marker = branch.find((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
23
24
  assert.ok(marker && marker.type === "custom");
24
- assert.equal(marker.parentId, toolResultId, "marker follows the completed tool result");
25
+ const checkpoint = branch.find((entry) => entry.type === "compaction");
26
+ assert.ok(checkpoint && checkpoint.type === "compaction", "reset persists a native retain-none checkpoint");
27
+ assert.equal(checkpoint.parentId, toolResultId, "checkpoint follows the completed tool result");
28
+ assert.equal(checkpoint.summary, "");
29
+ assert.equal(checkpoint.firstKeptEntryId, checkpoint.id);
30
+ assert.equal(marker.parentId, checkpoint.id, "marker follows the native checkpoint");
25
31
  assert.deepEqual(Object.keys(marker.data), ["windowId"]);
26
32
  const projected = await runContextWithSystemHook(captured, ctx, sessionManager.buildSessionContext().messages);
27
33
  const providerText = JSON.stringify(projected?.messages ?? []);
@@ -46,14 +52,14 @@ test("the root boot and reset boot carry durable window identity", async () => {
46
52
  appendText(sessionManager, "assistant", "working");
47
53
  await call(captured, "notes_write", { path: "decisions.md", content: "use terra" }, ctx);
48
54
  // Root window: session_start persists the boot block without triggering a turn.
49
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
55
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
50
56
  assert.equal(captured.sent.length, 1);
51
57
  const rootBoot = captured.sent[0];
52
58
  assert.equal(rootBoot?.message.customType, internal.BOOT_TYPE);
53
59
  assert.equal(rootBoot?.message.display, false, "boot block stays out of the TUI");
54
60
  assert.equal(rootBoot?.options?.triggerTurn, false);
55
61
  assert.equal(noticesOf(ctx).length, 0, "initial boot is silent");
56
- runHandlers(captured, "session_start", { reason: "reload" }, ctx);
62
+ await runHandlers(captured, "session_start", { reason: "reload" }, ctx);
57
63
  assert.equal(noticesOf(ctx).length, 0, "reload is silent");
58
64
  assert.deepEqual(rootBoot?.message.details, { windowId: `pcw:${sessionManager.getSessionId().slice(0, 8)}:root` });
59
65
  const rootText = typeof rootBoot?.message.content === "string" ? rootBoot.message.content : "";
@@ -63,7 +69,7 @@ test("the root boot and reset boot carry durable window identity", async () => {
63
69
  assert.match(rootText, new RegExp(`First context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
64
70
  assert.match(rootText, new RegExp(`Current context window id: pcw:${sessionManager.getSessionId().slice(0, 8)}:root`));
65
71
  assert.ok(rootText.includes("decisions.md"));
66
- const decisionsMeta = listNotes(ctx, { scope: "session" }).find((row) => row.path === "decisions.md")?.meta;
72
+ const decisionsMeta = (await listNotes(ctx, { scope: "session" })).find((row) => row.path === "decisions.md")?.meta;
67
73
  assert.ok(decisionsMeta);
68
74
  assert.match(rootText, /updated \d+s ago\)/, "boot note metadata carries a relative update time");
69
75
  assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
@@ -72,8 +78,8 @@ test("the root boot and reset boot carry durable window identity", async () => {
72
78
  assert.equal(noticesOf(ctx).length, 0, "requesting a reset does not announce success");
73
79
  const boundary = await commitTurnEndBoundary(captured, sessionManager, ctx);
74
80
  assert.equal(boundary.continue, true);
75
- runHandlers(captured, "turn_start", {}, ctx);
76
- runHandlers(captured, "agent_settled", {}, ctx);
81
+ await runHandlers(captured, "turn_start", {}, ctx);
82
+ await runHandlers(captured, "agent_settled", {}, ctx);
77
83
  assert.equal(noticesOf(ctx).filter((notice) => notice.message.includes("memory cleared")).length, 1, "the committed reset is announced once");
78
84
  const entries = sessionManager.getBranch();
79
85
  const marker = entries.find((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE);
@@ -85,7 +91,28 @@ test("the root boot and reset boot carry durable window identity", async () => {
85
91
  const continuation = entries.find((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE);
86
92
  assert.ok(continuation && continuation.type === "custom_message" && continuation.display === false, "the resumed run is represented by one hidden continuation");
87
93
  });
88
- test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", () => {
94
+ test("an aborted async boot repair emits no boot, continuation, or incomplete-notes notice", async () => {
95
+ const notesHome = process.env.PI_NOTES_HOME;
96
+ assert.ok(notesHome);
97
+ rmSync(notesHome, { recursive: true, force: true });
98
+ writeFileSync(notesHome, "not a directory");
99
+ const sessionManager = manager();
100
+ const windowId = "pcw:aborted-boot-repair";
101
+ sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
102
+ const captured = makeExtension(sessionManager);
103
+ const ctx = context(sessionManager);
104
+ const controller = new AbortController();
105
+ ctx.signal = controller.signal;
106
+ const sessionStart = captured.handlers.get("session_start")?.[0];
107
+ assert.ok(sessionStart);
108
+ const pending = sessionStart({ reason: "startup" }, ctx);
109
+ controller.abort();
110
+ await pending;
111
+ assert.equal(captured.sent.length, 0, "aborted snapshot work cannot append the reset boot or continuation");
112
+ assert.equal(noticesOf(ctx).some((notice) => notice.message.includes("notes index incomplete")), false, "aborted snapshot work cannot notify about missing homes");
113
+ assert.equal(sessionManager.getBranch().some((entry) => entry.type === "custom_message" && [internal.BOOT_TYPE, CONTINUATION_TYPE].includes(entry.customType)), false);
114
+ });
115
+ test("a marker tail with only metadata repairs its missing boot and continuation without moving the boundary", async () => {
89
116
  const sessionManager = manager(true);
90
117
  const windowId = "pcw:metadata-tail";
91
118
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
@@ -93,7 +120,7 @@ test("a marker tail with only metadata repairs its missing boot and continuation
93
120
  sessionManager.appendThinkingLevelChange("low");
94
121
  const captured = makeExtension(sessionManager);
95
122
  const ctx = context(sessionManager);
96
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
123
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
97
124
  const branch = sessionManager.getBranch();
98
125
  const markerIndex = branch.findIndex((entry) => entry.id === markerId);
99
126
  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 +135,13 @@ test("a marker tail with only metadata repairs its missing boot and continuation
108
135
  assert.equal(captured.sent[1]?.message.customType, CONTINUATION_TYPE);
109
136
  assert.equal(noticesOf(ctx).length, 0, "repairing a reset tail is not a new reset");
110
137
  });
111
- test("a bare marker tail repairs the full ordered reset shape", () => {
138
+ test("a bare marker tail repairs the full ordered reset shape", async () => {
112
139
  const sessionManager = manager(true);
113
140
  const windowId = "pcw:bare-marker";
114
141
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
115
142
  const captured = makeExtension(sessionManager);
116
143
  const ctx = context(sessionManager);
117
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
144
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
118
145
  assert.equal(captured.sent.length, 2, "a bare marker emits a boot and a continuation");
119
146
  const branch = sessionManager.getBranch();
120
147
  const markerIndex = branch.findIndex((entry) => entry.id === markerId);
@@ -123,7 +150,7 @@ test("a bare marker tail repairs the full ordered reset shape", () => {
123
150
  assert.ok(bootIndex > markerIndex, "the repaired boot follows its marker");
124
151
  assert.ok(continuationIndex > bootIndex, "the repaired continuation follows its boot");
125
152
  });
126
- test("a marker tail that already carries a boot repairs only its missing continuation, once", () => {
153
+ test("a marker tail that already carries a boot repairs only its missing continuation, once", async () => {
127
154
  const sessionManager = manager(true);
128
155
  const windowId = "pcw:missing-continuation";
129
156
  const markerId = sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
@@ -131,23 +158,23 @@ test("a marker tail that already carries a boot repairs only its missing continu
131
158
  sessionManager.appendCustomMessageEntry(internal.BOOT_TYPE, "already-persisted boot", false, { windowId });
132
159
  const captured = makeExtension(sessionManager);
133
160
  const ctx = context(sessionManager);
134
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
161
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
135
162
  assert.equal(captured.sent.length, 1, "only the missing continuation is emitted");
136
163
  assert.equal(captured.sent[0]?.message.customType, CONTINUATION_TYPE);
137
164
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === internal.BOOT_TYPE).length, 1, "the existing boot is never duplicated");
138
165
  assert.ok(sessionManager.getBranch().findIndex((entry) => entry.id === markerId) >= 0);
139
- runHandlers(captured, "session_start", { reason: "reload" }, ctx);
166
+ await runHandlers(captured, "session_start", { reason: "reload" }, ctx);
140
167
  assert.equal(captured.sent.length, 1, "a complete reset tail is idempotent");
141
168
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom_message" && entry.customType === CONTINUATION_TYPE).length, 1);
142
169
  });
143
- test("a marker tail followed by real conversation is never repaired", () => {
170
+ test("a marker tail followed by real conversation is never repaired", async () => {
144
171
  const sessionManager = manager(true);
145
172
  const windowId = "pcw:unsafe-tail";
146
173
  sessionManager.appendCustomEntry(internal.RESET_MARKER_TYPE, { windowId });
147
174
  appendText(sessionManager, "user", "post-marker work that must not be pushed behind a late boot");
148
175
  const captured = makeExtension(sessionManager);
149
176
  const ctx = context(sessionManager);
150
- runHandlers(captured, "session_start", { reason: "startup" }, ctx);
177
+ await runHandlers(captured, "session_start", { reason: "startup" }, ctx);
151
178
  assert.equal(captured.sent.length, 0, "an unsafe marker tail emits nothing");
152
179
  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
180
  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 +207,36 @@ test("wipe_memory uses one turn boundary and never calls ctx.compact", async ()
180
207
  assert.equal(sessionManager.getBranch().filter((entry) => entry.type === "custom" && entry.customType === internal.RESET_MARKER_TYPE).length, 1);
181
208
  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
209
  });
183
- test("pi-context command toggles future work, /wipe-memory is the manual path, and active markers cancel /compact", async () => {
210
+ test("pi-context command toggles future work, /wipe-memory starts close-out, and /compact remains disabled", async () => {
184
211
  const sessionManager = manager();
185
212
  appendText(sessionManager, "user", "hello");
186
213
  const captured = makeExtension(sessionManager);
187
214
  const low = context(sessionManager, undefined, { tokens: 170_000, contextWindow: 200_000, percent: 85 });
188
215
  // On by default: session_start persists the root boot block.
189
- runHandlers(captured, "session_start", { reason: "startup" }, low);
216
+ await runHandlers(captured, "session_start", { reason: "startup" }, low);
190
217
  assert.equal(captured.sent.length, 1);
191
218
  assert.equal(captured.sent[0]?.message.customType, internal.BOOT_TYPE);
192
219
  let notices = await runCommand(captured, "pi-context", "off", low);
193
220
  assert.match(notices[0]?.message ?? "", /off/);
194
221
  assert.equal(await runContextHook(captured, low), undefined, "no guidance while off");
195
222
  assert.equal(sentOf(captured, internal.GUIDANCE_TYPE).length, 0, "no guidance persisted while off");
196
- runHandlers(captured, "session_start", { reason: "startup" }, low);
223
+ await runHandlers(captured, "session_start", { reason: "startup" }, low);
197
224
  assert.equal(captured.sent.length, 1, "no new boot block is persisted while off");
198
225
  const offResult = resultJson(await call(captured, "wipe_memory", {}, low));
199
226
  assert.match(offResult.error ?? "", /off/, "wipe_memory refuses while off");
200
227
  notices = await runCommand(captured, "pi-context", "on", low);
201
228
  assert.match(notices[0]?.message ?? "", /on/);
202
229
  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);
230
+ assert.equal(notices.length, 0, "the command begins a model close-out rather than announcing a reset request");
231
+ assert.equal(captured.sent.length, 2, "the command persists one hidden warning after the startup boot");
232
+ assert.equal(captured.sent[1]?.options?.triggerTurn, true, "the shared warning starts an ordinary agent turn");
233
+ assert.equal(captured.sent[1]?.message.customType, WARNING_TYPE);
234
+ assert.equal(captured.sent[1]?.message.display, false);
235
+ 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
236
  const markerContext = await runManualCompact(captured, low);
211
- assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while a marker is active");
237
+ assert.deepEqual(markerContext, { cancel: true }, "/compact is canceled while context windows are enabled");
212
238
  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");
239
+ assert.equal(JSON.stringify(projected?.messages ?? []).includes("hello"), true, "the old window remains active until the close-out commits");
214
240
  notices = await runCommand(captured, "pi-context", "maybe", low);
215
241
  assert.equal(notices[0]?.type, "error", "unknown argument rejected");
216
242
  // Bare command reports current state without changing it.
@@ -0,0 +1 @@
1
+ export {};
@@ -31,7 +31,7 @@ test("the visible countdown ends at the warning line, clamps at zero, and preser
31
31
  });
32
32
  const sm = manager();
33
33
  const captured = makeExtension(sm);
34
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
34
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd, true));
35
35
  const readBudget = async (tokens, trusted = true) => {
36
36
  const ctx = context(sm, undefined, { tokens, contextWindow: 200_000, percent: tokens === null ? null : tokens / 2000 }, true, fixture.cwd, trusted);
37
37
  return resultJson(await call(captured, "get_context_remaining", {}, ctx)).remaining_tokens;
@@ -44,7 +44,7 @@ test("the visible countdown ends at the warning line, clamps at zero, and preser
44
44
  const absent = context(sm, undefined, undefined, true, fixture.cwd);
45
45
  assert.equal(resultJson(await call(captured, "get_context_remaining", {}, absent)).remaining_tokens, null);
46
46
  const untrusted = context(sm, undefined, { tokens: 72_563, contextWindow: 200_000, percent: 36.2815 }, true, fixture.cwd, false);
47
- runHandlers(captured, "session_start", {}, untrusted);
47
+ await runHandlers(captured, "session_start", {}, untrusted);
48
48
  assert.equal(await readBudget(72_563, false), 98_765, "session start reloads the global reserve when the project is untrusted");
49
49
  });
50
50
  test("absent pi-context key or margins reproduce the default reminder threshold at Pi's default reserve", async () => {
@@ -59,7 +59,7 @@ test("absent pi-context key or margins reproduce the default reminder threshold
59
59
  const captured = makeExtension(sm);
60
60
  // Thresholds are resolved once per session and cached; branch navigation clears the
61
61
  // 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));
62
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
63
63
  const window = 200_000;
64
64
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
65
65
  const first = at(40_961);
@@ -81,7 +81,7 @@ test("project pi-context reminder margin and reserve override global per key", a
81
81
  // Project reserve wins: reminder = 50000 + 40000 (project margin).
82
82
  const sm = manager();
83
83
  const captured = makeExtension(sm);
84
- runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
84
+ await runHandlers(captured, "session_tree", {}, context(sm, undefined, undefined, true, fixture.cwd));
85
85
  const window = 300_000;
86
86
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
87
87
  assert.equal(await runContextHook(captured, at(90_001)), undefined, "nothing injected above the project-derived reminder");
@@ -96,12 +96,13 @@ test("an invalid reminder margin degrades to its default with one warning and ne
96
96
  const sm = manager();
97
97
  const captured = makeExtension(sm);
98
98
  const ctx = context(sm, undefined, undefined, true, fixture.cwd);
99
- assert.doesNotThrow(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
99
+ await assert.doesNotReject(() => runHandlers(captured, "session_start", { reason: "startup" }, ctx));
100
100
  const notices = noticesOf(ctx).filter((notice) => notice.type === "warning");
101
101
  assert.equal(notices.length, 1, "one warning for the offending key");
102
102
  assert.equal(notices[0]?.type, "warning");
103
103
  assert.match(notices[0]?.message ?? "", /reminderMarginTokens/);
104
- assert.match(notices[0]?.message ?? "", /24576/);
104
+ assert.match(notices[0]?.message ?? "", /default reminder margin/);
105
+ assert.equal(/\d+\s*k?\s*(remaining|tokens)/i.test(notices[0]?.message ?? ""), false, "UI notices do not expose token counts");
105
106
  const window = 200_000;
106
107
  const at = (remaining) => context(sm, undefined, { tokens: window - remaining, percent: 0, contextWindow: window }, true, fixture.cwd);
107
108
  // The degraded reminder is Pi's default reserve + default margin = 40960.
@@ -121,6 +122,6 @@ test("the warning supersedes the early reminder when usage jumps across both thr
121
122
  assert.deepEqual(warningResult?.messages.map((message) => message.customType), [internal.WARNING_TYPE]);
122
123
  await commitTurnEndBoundary(captured, sm, ctx);
123
124
  const reloaded = makeExtension(sm);
124
- runHandlers(reloaded, "context", {}, ctx);
125
+ await runHandlers(reloaded, "context", {}, ctx);
125
126
  assert.equal(reloaded.sent.length, 0, "persisted warning also suppresses a late reminder after reload");
126
127
  });
@@ -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,115 @@
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
+ /**
92
+ * Assert a value is a local-time ISO 8601 string with an explicit numeric offset (never "Z")
93
+ * and that Date.parse restores the stored epoch milliseconds. No time zone is assumed.
94
+ */
95
+ export declare function assertLocalIso(value: unknown, epochMs: number, message: string): void;
96
+ /** Assert the text contains a well-formed local ISO timestamp and return it, without pinning surrounding wording. */
97
+ export declare function assertIsoTimestamp(text: string, message: string): string;
98
+ /** Assert `actual` is a middle-truncation of `original`: same head, same tail, strictly fewer characters. */
99
+ export declare function assertTruncationOf(original: string, actual: string): void;
100
+ export declare function runManualCompact(captured: Captured, ctx: ExtensionContext): Promise<CompactionHookResult>;
101
+ export declare function runHandlers(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<void>;
102
+ export declare function runHandlersAsync(captured: Captured, name: string, event: unknown, ctx: ExtensionContext): Promise<unknown[]>;
103
+ export declare function completeRequestedCompaction(ctx: ExtensionContext): void;
104
+ export declare function runCommand(captured: Captured, name: string, args: string, ctx: ExtensionContext): Promise<Notice[]>;
105
+ export type ContextHookResult = {
106
+ messages: unknown[];
107
+ } | undefined;
108
+ export declare function runContextHook(captured: Captured, ctx: ExtensionContext, eventOverride?: Record<string, unknown>): Promise<ContextHookResult>;
109
+ export declare function runContextWithSystemHook(captured: Captured, ctx: ExtensionContext, messages: unknown[]): Promise<ContextHookResult>;
110
+ export declare function commitTurnEndBoundary(captured: Captured, sessionManager: SessionManager, ctx: ExtensionContext): Promise<{
111
+ entries: SessionBoundaryDraft[];
112
+ continue: boolean;
113
+ }>;
114
+ export declare function appendText(sessionManager: SessionManager, role: "user" | "assistant" | "toolResult", text: string, toolName?: string): string;
115
+ export {};
@@ -4,9 +4,9 @@ import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
  import { SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
6
6
  import piContext, { createPiContext } from "../../src/index.js";
7
- import { loadNotesSnapshot } from "../../src/notes/notes-snapshot.js";
7
+ import { loadNotesSnapshot } from "../../src/pi/notes/snapshot.js";
8
8
  import { renderBootBlock } from "../../src/context/prompts.js";
9
- import { agentSlug, modelSlug } from "../../src/notes/paths.js";
9
+ import { agentSlug, modelSlug } from "../../src/pi/notes/adapter.js";
10
10
  import { rootWindowId } from "../../src/context/context-window.js";
11
11
  import { TOOL_OUTPUT_MAX_BYTES } from "../../src/tool-output.js";
12
12
  let defaultCwd = "/private/tmp/pi-context-test-cwd";
@@ -130,14 +130,14 @@ export function makeExtension(sessionManager, settingsManager) {
130
130
  (settingsManager ? createPiContext({ settingsManager }) : piContext)(api);
131
131
  return captured;
132
132
  }
133
- export function explicitBoot(ctx, currentWindowId, previousWindowId) {
133
+ export async function explicitBoot(ctx, currentWindowId, previousWindowId) {
134
134
  return renderBootBlock({
135
135
  agentName: agentSlug(ctx),
136
136
  modelName: modelSlug(ctx),
137
137
  firstWindowId: rootWindowId(ctx.sessionManager.getSessionId()),
138
138
  currentWindowId,
139
139
  previousWindowId,
140
- notes: loadNotesSnapshot(ctx),
140
+ notes: await loadNotesSnapshot(ctx),
141
141
  });
142
142
  }
143
143
  export function context(sessionManager, compact, usage, idle = true, cwd = defaultCwd, projectTrusted = true, model) {
@@ -270,13 +270,13 @@ export async function runManualCompact(captured, ctx) {
270
270
  };
271
271
  return (await handler(event, ctx));
272
272
  }
273
- export function runHandlers(captured, name, event, ctx) {
273
+ export async function runHandlers(captured, name, event, ctx) {
274
274
  const isIdle = ctx.isIdle;
275
275
  if (name === "agent_settled")
276
276
  ctx.isIdle = () => true;
277
277
  try {
278
278
  for (const handler of captured.handlers.get(name) ?? [])
279
- handler(event, ctx);
279
+ await handler(event, ctx);
280
280
  }
281
281
  finally {
282
282
  ctx.isIdle = isIdle;
@@ -0,0 +1,6 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type NoteRow, type NotesQuery, type Scope } from "../../src/notes/index.js";
3
+ /** Test-only conveniences for fixtures that still model notes through a live Pi context. */
4
+ export declare function physicalPath(scope: Scope, path: string, ctx: ExtensionContext, who?: string): string;
5
+ export declare function scopeDir(scope: Scope, ctx: ExtensionContext, who?: string): string;
6
+ export declare function listNotes(ctx: ExtensionContext, query?: NotesQuery): Promise<NoteRow[]>;
@@ -0,0 +1,13 @@
1
+ import { createNotesStore } from "../../src/notes/index.js";
2
+ import { notesContextFromPi } from "../../src/pi/notes/adapter.js";
3
+ import { physicalPath as corePhysicalPath, scopeDir as coreScopeDir } from "../../src/notes/paths.js";
4
+ /** Test-only conveniences for fixtures that still model notes through a live Pi context. */
5
+ export function physicalPath(scope, path, ctx, who) {
6
+ return corePhysicalPath(scope, path, notesContextFromPi(ctx), who);
7
+ }
8
+ export function scopeDir(scope, ctx, who) {
9
+ return coreScopeDir(scope, notesContextFromPi(ctx), who);
10
+ }
11
+ export function listNotes(ctx, query = {}) {
12
+ return createNotesStore(notesContextFromPi(ctx)).list(query);
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};