@astrosheep/pi-context 0.20.0 → 0.22.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 (56) hide show
  1. package/README.md +22 -1
  2. package/dist/src/budget.js +10 -8
  3. package/dist/src/dream/cli.js +108 -24
  4. package/dist/src/dream/gates.js +13 -8
  5. package/dist/src/dream/git.js +71 -0
  6. package/dist/src/dream/lock.js +78 -37
  7. package/dist/src/dream/runner.js +90 -21
  8. package/dist/src/history-tools.js +5 -5
  9. package/dist/src/history.js +11 -6
  10. package/dist/src/index.js +14 -15
  11. package/dist/src/notes/address.js +31 -0
  12. package/dist/src/{memory → notes}/frontmatter.js +7 -5
  13. package/dist/src/{notes.js → notes/model.js} +1 -1
  14. package/dist/src/{memory → notes}/paths.js +7 -3
  15. package/dist/src/{memory → notes}/store.js +47 -74
  16. package/dist/src/notes/tools.js +153 -0
  17. package/dist/src/prompts.js +38 -29
  18. package/dist/src/protocol.js +9 -4
  19. package/dist/src/thresholds.js +33 -3
  20. package/dist/src/tool-output.js +4 -1
  21. package/dist/src/warning.js +3 -3
  22. package/dist/test/agent-loop.test.js +6 -4
  23. package/dist/test/coherence.test.js +5 -1
  24. package/dist/test/dream.test.js +419 -35
  25. package/dist/test/history.test.js +6 -1
  26. package/dist/test/integration.test.js +107 -47
  27. package/dist/test/{memory.test.js → notes.test.js} +154 -50
  28. package/dist/test/pagination.property.test.js +1 -1
  29. package/package.json +5 -5
  30. package/playbook.md +30 -3
  31. package/src/budget.ts +11 -9
  32. package/src/dream/cli.ts +95 -17
  33. package/src/dream/gates.ts +14 -7
  34. package/src/dream/git.ts +73 -0
  35. package/src/dream/lock.ts +67 -24
  36. package/src/dream/runner.ts +87 -20
  37. package/src/history-tools.ts +5 -5
  38. package/src/history.ts +12 -7
  39. package/src/index.ts +13 -14
  40. package/src/notes/address.ts +33 -0
  41. package/src/{memory → notes}/frontmatter.ts +7 -5
  42. package/src/{notes.ts → notes/model.ts} +2 -2
  43. package/src/{memory → notes}/paths.ts +8 -3
  44. package/src/{memory → notes}/store.ts +49 -79
  45. package/src/notes/tools.ts +132 -0
  46. package/src/prompts.ts +39 -29
  47. package/src/protocol.ts +9 -4
  48. package/src/thresholds.ts +38 -6
  49. package/src/tool-output.ts +4 -1
  50. package/src/warning.ts +3 -3
  51. package/dist/src/dream/apply.js +0 -87
  52. package/dist/src/dream/manifest.js +0 -16
  53. package/dist/src/memory/tools.js +0 -175
  54. package/src/dream/apply.ts +0 -47
  55. package/src/dream/manifest.ts +0 -21
  56. package/src/memory/tools.ts +0 -175
@@ -11,16 +11,21 @@ import { existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } fro
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import test from "node:test";
14
- import { physicalPath, projectKey, scopeDir } from "../src/memory/paths.js";
15
- import { listNotes } from "../src/memory/store.js";
14
+ import { physicalPath, projectKey, scopeDir } from "../src/notes/paths.js";
15
+ import { listNotes } from "../src/notes/store.js";
16
16
  import { MAX_NOTE_BYTES, MAX_NOTE_PATH_BYTES, PROTOCOL_BLOCK } from "../src/protocol.js";
17
17
  import { call, context, makeExtension, manager, resultJson, resultRead, runHandlers } from "./integration.test.js";
18
- process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-context-memory-agent-"));
18
+ process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(tmpdir(), "pi-context-notes-agent-"));
19
19
  function freshRoot() {
20
20
  const root = mkdtempSync(join(tmpdir(), "pi-context-notes-"));
21
21
  process.env.PI_NOTES_HOME = root;
22
22
  return root;
23
23
  }
24
+ function setUpdatedAt(scope, path, ctx, timestamp) {
25
+ const file = physicalPath(scope, path, ctx);
26
+ const raw = readFileSync(file, "utf8");
27
+ writeFileSync(file, raw.replace(/^updated_at: .*$/m, `updated_at: ${new Date(timestamp).toISOString()}`));
28
+ }
24
29
  test("exactly the five notes tools are registered; the legacy five are gone", () => {
25
30
  const captured = makeExtension(manager());
26
31
  for (const name of ["notes_write", "notes_edit", "notes_read", "notes_list", "notes_search"]) {
@@ -48,15 +53,16 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
48
53
  const raw = readFileSync(file, "utf8");
49
54
  assert.match(raw, /^---\n/, "the file opens with frontmatter");
50
55
  assert.match(raw, /\n---\n\nhello$/, "frontmatter is followed by a blank line and the exact body");
51
- for (const [key, value] of [["scope", "session"], ["origin", "self"], ["status", "active"], ["stale", "false"], ["access_count", "0"]]) {
56
+ for (const [key, value] of [["origin", "self"], ["status", "active"], ["stale", "false"], ["access_count", "0"]]) {
52
57
  assert.match(raw, new RegExp(`^${key}: ${value}$`, "m"), `frontmatter carries ${key}=${value}`);
53
58
  }
59
+ assert.equal(/^scope:/m.test(raw), false, "scope is derived from the file home, never persisted");
54
60
  for (const key of ["created_at", "updated_at", "last_accessed"]) {
55
61
  assert.match(raw, new RegExp(`^${key}: \\d{4}-\\d{2}-\\d{2}T`, "m"), `frontmatter renders ${key} via localIso`);
56
62
  }
57
63
  assert.equal(typeof result.meta.created_at, "string", "wire meta renders timestamps as ISO strings");
58
64
  // A leading YAML block in user content is stripped from the body.
59
- await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: global\nnonsense: true\n---\nreal body" }, ctx);
65
+ await call(captured, "notes_write", { path: "stripped.md", content: "---\nscope: personal\nnonsense: true\n---\nreal body" }, ctx);
60
66
  const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
61
67
  assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
62
68
  assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
@@ -84,6 +90,14 @@ test("overwrite preserves created_at and unknown keys, bumps updated_at, and cle
84
90
  assert.equal(listed.meta.stale, false, "a plain rewrite clears stale");
85
91
  assert.equal(listed.meta.status, "active");
86
92
  });
93
+ test("listNotes retains each parsed body for MAP injection", async () => {
94
+ freshRoot();
95
+ const session = manager();
96
+ const captured = makeExtension(session);
97
+ const ctx = context(session);
98
+ await call(captured, "notes_write", { path: "retained.md", content: "parsed once" }, ctx);
99
+ assert.equal(listNotes(ctx, { scope: "session" })[0]?.body, "parsed once");
100
+ });
87
101
  test("edit is body-scoped with named failures and a replace_all escape hatch", async () => {
88
102
  freshRoot();
89
103
  const session = manager();
@@ -103,6 +117,30 @@ test("edit is body-scoped with named failures and a replace_all escape hatch", a
103
117
  const frontmatterOnly = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "scope", newText: "x" }] }, ctx));
104
118
  assert.equal(frontmatterOnly.edit_index, 0, "a frontmatter-only anchor is not a body match");
105
119
  });
120
+ test("a single edit inserts newText byte-for-byte: no $-pattern substitution", async () => {
121
+ freshRoot();
122
+ const session = manager();
123
+ const captured = makeExtension(session);
124
+ const ctx = context(session);
125
+ // Each pattern is a JS String.replace replacement token. With positional splicing the whole
126
+ // two-character (or two-dollar) sequence lands literally; with String.replace it would expand,
127
+ // and the prefix token ($`) would splice in the entire document prefix.
128
+ const cases = ["$&", "$`", "$'", "$1", "$$"];
129
+ for (const token of cases) {
130
+ const newText = `pre${token}post`;
131
+ await call(captured, "notes_write", { path: "literal.md", content: "alpha\nbeta\ngamma" }, ctx);
132
+ const edited = resultJson(await call(captured, "notes_edit", { path: "literal.md", edits: [{ oldText: "beta", newText }] }, ctx));
133
+ assert.equal(edited.applied, 1, `the single edit for ${JSON.stringify(token)} applied`);
134
+ const body = resultRead(await call(captured, "notes_read", { path: "literal.md" }, ctx)).content;
135
+ assert.equal(body.endsWith(`alpha\n${newText}\ngamma`), true, `${JSON.stringify(token)} is inserted literally`);
136
+ assert.equal(body.endsWith(`alpha\nalpha\npre${token}post\ngamma`), false, `${JSON.stringify(token)} does not splice in the document prefix`);
137
+ }
138
+ // The replace_all branch (split/join) is likewise literal, so both branches agree.
139
+ await call(captured, "notes_write", { path: "literal-all.md", content: "one X two X three" }, ctx);
140
+ await call(captured, "notes_edit", { path: "literal-all.md", edits: [{ oldText: "X", newText: "$`$&$1$$" }], replace_all: true }, ctx);
141
+ const allBody = resultRead(await call(captured, "notes_read", { path: "literal-all.md" }, ctx)).content;
142
+ assert.equal(allBody.endsWith("one $`$&$1$$ two $`$&$1$$ three"), true, "replace_all inserts $-patterns literally too");
143
+ });
106
144
  test("metadata-only edit updates setters without touching the body", async () => {
107
145
  freshRoot();
108
146
  const session = manager();
@@ -134,12 +172,12 @@ test("notes_edit returns a pi-edit-style diff of what changed", async () => {
134
172
  assert.match(metaOnly.diff, /- *\d+ stale: false/);
135
173
  assert.match(metaOnly.diff, /\+ *\d+ stale: true/);
136
174
  assert.equal(metaOnly.diff.includes("alpha"), false, "a metadata-only diff does not drag the body in");
137
- // Both → one combined diff naming body and frontmatter changes.
138
- const combined = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "alpha", newText: "ALPHA" }], scope: "project" }, ctx));
175
+ // Both → one combined diff naming body and frontmatter changes, without moving homes.
176
+ const combined = resultJson(await call(captured, "notes_edit", { path: "d.md", edits: [{ oldText: "alpha", newText: "ALPHA" }], stale: false }, ctx));
139
177
  assert.match(combined.diff, /- *\d+ alpha/);
140
178
  assert.match(combined.diff, /\+ *\d+ ALPHA/);
141
- assert.match(combined.diff, /- *\d+ scope: session/);
142
- assert.match(combined.diff, /\+ *\d+ scope: project/);
179
+ assert.match(combined.diff, /- *\d+ stale: true/);
180
+ assert.match(combined.diff, /\+ *\d+ stale: false/);
143
181
  });
144
182
  test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are named", async () => {
145
183
  freshRoot();
@@ -167,22 +205,22 @@ test("nothing-to-do, not-found, atomic batches, and replace_all zero-match are n
167
205
  const zero = resultJson(await call(captured, "notes_edit", { path: "edit.md", edits: [{ oldText: "zzz", newText: "y" }], replace_all: true }, ctx));
168
206
  assert.equal(zero.edit_index, 0, "replace_all with zero matches is the same zero-match error, not a silent no-op");
169
207
  });
170
- test("scope resolution, access counting, and movement with a typed refusal", async () => {
208
+ test.skip("scope resolution and movement are superseded by explicit address tests", async () => {
171
209
  freshRoot();
172
210
  const session = manager();
173
211
  const captured = makeExtension(session);
174
212
  const ctx = context(session);
175
- await call(captured, "notes_write", { path: "shared.md", content: "global body", scope: "global" }, ctx);
213
+ await call(captured, "notes_write", { path: "shared.md", content: "personal body", scope: "personal" }, ctx);
176
214
  await call(captured, "notes_write", { path: "shared.md", content: "session body", scope: "session" }, ctx);
177
215
  const first = resultRead(await call(captured, "notes_read", { path: "shared.md" }, ctx));
178
- assert.equal(first.details.scope, "session", "session wins the precedence over global");
216
+ assert.equal(first.details.scope, "session", "session wins the precedence over personal");
179
217
  const readAgain = resultRead(await call(captured, "notes_read", { path: "shared.md", scope: "session" }, ctx));
180
218
  assert.ok(readAgain.content.includes("session body"));
181
219
  const sessionMeta = listNotes(ctx, { scope: "session" })[0].meta;
182
220
  assert.equal(sessionMeta.access_count, 2, "each read bumps access_count");
183
- const globalMeta = listNotes(ctx, { scope: "global" })[0].meta;
184
- assert.equal(globalMeta.access_count, 0, "the global copy is untouched");
185
- // Move the session copy to project; the global copy is untouched.
221
+ const personalMeta = listNotes(ctx, { scope: "personal" })[0].meta;
222
+ assert.equal(personalMeta.access_count, 0, "the personal copy is untouched");
223
+ // Move the session copy to project; the personal copy is untouched.
186
224
  const moved = resultJson(await call(captured, "notes_edit", { path: "shared.md", edits: [{ oldText: "session", newText: "moved" }], scope: "project" }, ctx));
187
225
  assert.equal(moved.meta.scope, "project");
188
226
  assert.equal(moved.resolved_scope, "session", "resolved_scope names the layer the file moved from");
@@ -190,11 +228,11 @@ test("scope resolution, access counting, and movement with a typed refusal", asy
190
228
  assert.equal(existsSync(physicalPath("project", "shared.md", ctx)), true, "the file now lives in the project scope");
191
229
  // A move onto an existing target is refused and both files survive unchanged.
192
230
  await call(captured, "notes_write", { path: "clash.md", content: "session stay", scope: "session" }, ctx);
193
- await call(captured, "notes_write", { path: "clash.md", content: "global stay", scope: "global" }, ctx);
194
- const beforeGlobal = readFileSync(physicalPath("global", "clash.md", ctx), "utf8");
195
- const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "global" }, ctx));
231
+ await call(captured, "notes_write", { path: "clash.md", content: "personal stay", scope: "personal" }, ctx);
232
+ const beforePersonal = readFileSync(physicalPath("personal", "clash.md", ctx), "utf8");
233
+ const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "personal" }, ctx));
196
234
  assert.match(refusal.error, /already exists/);
197
- assert.equal(readFileSync(physicalPath("global", "clash.md", ctx), "utf8"), beforeGlobal, "the target survives a refused move");
235
+ assert.equal(readFileSync(physicalPath("personal", "clash.md", ctx), "utf8"), beforePersonal, "the target survives a refused move");
198
236
  });
199
237
  test("list and search merge scopes and carry scope; the path jail rejects escapes", async () => {
200
238
  freshRoot();
@@ -203,20 +241,20 @@ test("list and search merge scopes and carry scope; the path jail rejects escape
203
241
  const ctx = context(session);
204
242
  await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
205
243
  await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
206
- await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "global" }, ctx);
244
+ await call(captured, "notes_write", { path: "three.md", content: "needle three", scope: "personal" }, ctx);
207
245
  const listed = resultJson(await call(captured, "notes_list", {}, ctx));
208
- assert.deepEqual([...listed.files].map((file) => file.scope).sort(), ["global", "project", "session"], "every merged row carries its scope");
246
+ assert.deepEqual([...listed.files].map((file) => file.scope).sort(), ["personal", "project", "session"], "every merged row carries its scope");
209
247
  for (const row of listed.files) {
210
248
  assert.equal(typeof row.size_bytes, "number");
211
249
  assert.equal(row.origin, "self");
212
250
  assert.equal(row.status, "active");
213
251
  assert.equal(row.stale, false);
214
252
  }
215
- const scoped = resultJson(await call(captured, "notes_list", { scope: "global" }, ctx));
253
+ const scoped = resultJson(await call(captured, "notes_list", { scope: "personal" }, ctx));
216
254
  assert.deepEqual(scoped.files.map((file) => file.path), ["three.md"], "a scope filter narrows the set");
217
255
  const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
218
256
  assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
219
- assert.deepEqual([...searched.files].map((file) => file.scope).sort(), ["global", "project", "session"]);
257
+ assert.deepEqual([...searched.files].map((file) => file.scope).sort(), ["personal", "project", "session"]);
220
258
  assert.equal(searched.files.every((file) => file.matches_total === 1), true);
221
259
  const hit = searched.files[0].matches[0];
222
260
  assert.equal(hit.line, 1);
@@ -238,14 +276,14 @@ test("the boot index reads the physical store across scopes and excludes stale n
238
276
  const captured = makeExtension(session);
239
277
  const ctx = context(session);
240
278
  await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
241
- await call(captured, "notes_write", { path: "global.md", content: "global content", scope: "global" }, ctx);
279
+ await call(captured, "notes_write", { path: "personal.md", content: "personal content", scope: "personal" }, ctx);
242
280
  await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
243
281
  runHandlers(captured, "session_start", {}, ctx);
244
282
  const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
245
283
  assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
246
- assert.ok(text.includes("global.md"), "a fresh global note is indexed");
284
+ assert.ok(text.includes("personal.md"), "a fresh personal note is indexed");
247
285
  assert.equal(text.includes("old.md"), false, "a stale note leaves the index");
248
- assert.equal(text.includes("stale content"), false, "a stale preview is not rendered");
286
+ assert.equal(text.includes("stale content"), false, "the stale note's body is absent from boot");
249
287
  for (const name of ["notes_write", "notes_edit", "notes_read", "notes_search", "notes_list"]) {
250
288
  assert.ok(PROTOCOL_BLOCK.includes(name), `the protocol block names ${name}`);
251
289
  }
@@ -318,46 +356,54 @@ test("write-time caps refuse an oversized vpath or serialized file, and edit ref
318
356
  const refusedEdit = resultJson(await call(captured, "notes_edit", { path: "small.md", edits: [{ oldText: "small", newText: "y".repeat(MAX_NOTE_BYTES) }] }, ctx));
319
357
  assert.match(refusedEdit.error, new RegExp(String(MAX_NOTE_BYTES)), "an edit that would exceed the cap is refused");
320
358
  });
321
- test("a global TOC.md body is injected ahead of the recent-notes list", async () => {
359
+ test("fresh personal and project MAP.md bodies are both resident before the pocket", async () => {
322
360
  freshRoot();
323
361
  const session = manager();
324
362
  const captured = makeExtension(session);
325
363
  const ctx = context(session);
326
- await call(captured, "notes_write", { path: "TOC.md", content: "MAP: global\nMAP: second", scope: "global" }, ctx);
364
+ await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
365
+ await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
366
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal\nMAP: second" }, ctx);
327
367
  await call(captured, "notes_write", { path: "recent.md", content: "recent body" }, ctx);
328
368
  runHandlers(captured, "session_start", {}, ctx);
329
369
  const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
330
- assert.ok(boot.includes("MAP: global"), "a global TOC is injected");
331
- assert.ok(boot.indexOf("MAP: global") < boot.indexOf("crumpled note"), "the TOC body precedes the recent-notes list");
370
+ assert.ok(boot.includes("MAP: personal"), "the personal map is injected");
371
+ assert.ok(boot.includes("MAP: project"), "the project map is injected");
372
+ assert.equal(boot.includes("MAP: session"), false, "the session map is never injected");
373
+ assert.ok(boot.indexOf("MAP: personal") < boot.indexOf("MAP: project"), "the personal map precedes the project map");
374
+ assert.ok(boot.indexOf("MAP: project") < boot.indexOf("crumpled note"), "both map bodies precede the pocket");
332
375
  assert.ok(PROTOCOL_BLOCK.includes("notes_write"), "the protocol text still rides along");
333
376
  });
334
- test("a session TOC.md wins precedence over the global map", async () => {
335
- freshRoot();
336
- const session = manager();
337
- const captured = makeExtension(session);
338
- const ctx = context(session);
339
- await call(captured, "notes_write", { path: "TOC.md", content: "MAP: global", scope: "global" }, ctx);
340
- await call(captured, "notes_write", { path: "TOC.md", content: "MAP: session", scope: "session" }, ctx);
341
- runHandlers(captured, "session_start", {}, ctx);
342
- const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
343
- const injected = boot.slice(0, boot.indexOf("crumpled note") === -1 ? boot.length : boot.indexOf("crumpled note"));
344
- assert.ok(injected.includes("MAP: session"), "the session TOC wins the precedence");
345
- assert.equal(injected.includes("MAP: global"), false, "only the first-hit TOC is injected");
346
- });
347
- test("the boot index admits up to five fresh notes and the protocol carries the exact stale line", async () => {
377
+ test("the boot pocket applies per-home quotas in session, project, personal order", async () => {
348
378
  freshRoot();
349
379
  const session = manager();
350
380
  const captured = makeExtension(session);
351
381
  const ctx = context(session);
352
- for (let index = 0; index < 6; index++) {
353
- await call(captured, "notes_write", { path: `fresh-${index}.md`, content: `body ${index}` }, ctx);
382
+ const base = Date.parse("2026-01-01T00:00:00.000Z");
383
+ for (const [scope, count] of [["session", 6], ["project", 3], ["personal", 3]]) {
384
+ for (let index = 0; index < count; index++) {
385
+ const path = `${scope}-${index}.md`;
386
+ await call(captured, "notes_write", { path, content: `${scope} body`, scope }, ctx);
387
+ setUpdatedAt(scope, path, ctx, base + index * 1_000);
388
+ }
354
389
  }
390
+ await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
391
+ await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
392
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal" }, ctx);
355
393
  runHandlers(captured, "session_start", {}, ctx);
356
394
  const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
357
- assert.match(boot, /\(up to 5, most recent first\)/, "the pocket line says up to 5");
358
- assert.equal((boot.match(/^- /gm) ?? []).length, 5, "exactly five fresh notes are indexed, not six");
359
- assert.ok(PROTOCOL_BLOCK.includes("Mark outdated or unneeded notes stale — leave them, and they will keep misleading you."), "the protocol block carries the v2 stale line verbatim");
360
- assert.equal(PROTOCOL_BLOCK.split("Mark outdated or unneeded notes stale — leave them, and they will keep misleading you.").length - 1, 1, "the stale line appears exactly once");
395
+ assert.ok(boot.includes("You find 9 crumpled notes in your pocket (by home, most recent first within each: up to 5 from this session, 2 from this project, 2 from personal). A note's content never appears here, so its name has to say what the note is about:"), "the pocket line matches the dictated copy");
396
+ for (const name of ["session-5.md", "session-4.md", "session-3.md", "session-2.md", "session-1.md", "@project/project-2.md", "@project/project-1.md", "@personal/personal-2.md", "@personal/personal-1.md"]) {
397
+ assert.ok(boot.includes(name), `${name} stays in the pocket`);
398
+ }
399
+ for (const name of ["session-0.md", "@project/project-0.md", "@personal/personal-0.md", "MAP.md", "MAP: session"]) {
400
+ assert.equal(boot.includes(name), false, `${name} is not a pocket entry`);
401
+ }
402
+ assert.ok(boot.indexOf("session-5.md") < boot.indexOf("session-4.md"), "session notes are most-recent-first");
403
+ assert.ok(boot.indexOf("@project/project-2.md") < boot.indexOf("@project/project-1.md"), "project notes are most-recent-first");
404
+ assert.ok(boot.indexOf("@personal/personal-2.md") < boot.indexOf("@personal/personal-1.md"), "personal notes are most-recent-first");
405
+ assert.ok(boot.indexOf("session-1.md") < boot.indexOf("@project/project-2.md"), "session notes precede project notes");
406
+ assert.ok(boot.indexOf("@project/project-1.md") < boot.indexOf("@personal/personal-2.md"), "project notes precede personal notes");
361
407
  });
362
408
  test("project scope keys off the git root basename and sha1 prefix", () => {
363
409
  const root = mkdtempSync(join(tmpdir(), "pi-context-proj-"));
@@ -368,3 +414,61 @@ test("project scope keys off the git root basename and sha1 prefix", () => {
368
414
  assert.match(key, /^pi-context-proj-[^-]+-[0-9a-f]{8}$/, "the project key is basename plus an 8-hex sha1 prefix");
369
415
  assert.equal(scopeDir("project", ctx), join(process.env.PI_NOTES_HOME, "project", key));
370
416
  });
417
+ test("@ addresses select one home, reject illegal sigils, and never fall back", async () => {
418
+ const root = freshRoot();
419
+ const session = manager();
420
+ const captured = makeExtension(session);
421
+ const ctx = context(session);
422
+ await call(captured, "notes_write", { address: "same.md", content: "session" }, ctx);
423
+ await call(captured, "notes_write", { address: "@project/same.md", content: "project" }, ctx);
424
+ await call(captured, "notes_write", { address: "@personal/same.md", content: "personal" }, ctx);
425
+ assert.ok(existsSync(physicalPath("project", "same.md", ctx)), "@project writes to the current project home");
426
+ assert.ok(existsSync(physicalPath("personal", "same.md", ctx)), "@personal writes to the personal home");
427
+ assert.match(resultRead(await call(captured, "notes_read", { address: "same.md" }, ctx)).content, /session$/);
428
+ assert.equal(resultJson(await call(captured, "notes_read", { address: "@project/missing.md" }, ctx)).error, "note not found");
429
+ await assert.rejects(() => call(captured, "notes_read", { address: "@glboal/same.md" }, ctx), /@project\/.*@personal\/.*bare names are the session home/);
430
+ await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@personal\/.*bare names are the session home/);
431
+ assert.equal(existsSync(join(root, "personal", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
432
+ });
433
+ test("full addresses drive outputs and patterns; on-disk scope is read then dropped", async () => {
434
+ freshRoot();
435
+ const session = manager();
436
+ const captured = makeExtension(session);
437
+ const ctx = context(session);
438
+ await call(captured, "notes_write", { address: "root.md", content: "needle" }, ctx);
439
+ await call(captured, "notes_write", { address: "@project/project.md", content: "needle" }, ctx);
440
+ await call(captured, "notes_write", { address: "@personal/personal.md", content: "needle" }, ctx);
441
+ const list = resultJson(await call(captured, "notes_list", { pattern: "**" }, ctx));
442
+ assert.deepEqual(list.files.map((file) => file.address).sort(), ["@personal/personal.md", "@project/project.md", "root.md"]);
443
+ assert.deepEqual(resultJson(await call(captured, "notes_list", { pattern: "*.md" }, ctx)).files.map((file) => file.address), ["root.md"]);
444
+ assert.deepEqual(resultJson(await call(captured, "notes_search", { query: "needle", pattern: "@project/**" }, ctx)).files.map((file) => file.address), ["@project/project.md"]);
445
+ const read = resultRead(await call(captured, "notes_read", { address: "@personal/personal.md" }, ctx));
446
+ assert.match(read.header, /^\[@personal\/personal\.md /, "the raw read header echoes the full address");
447
+ const legacy = physicalPath("project", "legacy.md", ctx);
448
+ writeFileSync(legacy, "---\nscope: personal\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\nlegacy");
449
+ const legacyRead = resultRead(await call(captured, "notes_read", { address: "@project/legacy.md" }, ctx));
450
+ assert.equal(legacyRead.details.scope, "project", "scope is derived from the file location");
451
+ await call(captured, "notes_edit", { address: "@project/legacy.md", stale: true }, ctx);
452
+ assert.equal(/^scope:/m.test(readFileSync(legacy, "utf8")), false, "the next write removes legacy scope frontmatter");
453
+ });
454
+ test("stale project and personal maps are skipped independently", async () => {
455
+ freshRoot();
456
+ const session = manager();
457
+ const captured = makeExtension(session);
458
+ const ctx = context(session);
459
+ await call(captured, "notes_write", { address: "@project/MAP.md", content: "project fresh" }, ctx);
460
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "personal stale", stale: true }, ctx);
461
+ runHandlers(captured, "session_start", {}, ctx);
462
+ let boot = String(captured.sent.at(-1)?.message.content ?? "");
463
+ assert.ok(boot.includes("project fresh"), "a fresh project map survives a stale personal map");
464
+ assert.equal(boot.includes("personal stale"), false, "the stale personal map is skipped");
465
+ const second = manager();
466
+ const secondCaptured = makeExtension(second);
467
+ const secondCtx = context(second);
468
+ await call(secondCaptured, "notes_edit", { address: "@project/MAP.md", stale: true }, secondCtx);
469
+ await call(secondCaptured, "notes_edit", { address: "@personal/MAP.md", stale: false }, secondCtx);
470
+ runHandlers(secondCaptured, "session_start", {}, secondCtx);
471
+ boot = String(secondCaptured.sent.at(-1)?.message.content ?? "");
472
+ assert.equal(boot.includes("project fresh"), false, "the stale project map is skipped");
473
+ assert.ok(boot.includes("personal stale"), "a fresh personal map survives a stale project map");
474
+ });
@@ -21,7 +21,7 @@
21
21
  import assert from "node:assert/strict";
22
22
  import test from "node:test";
23
23
  import { historyFromSession } from "../src/index.js";
24
- import { listNotes, searchNotes } from "../src/memory/store.js";
24
+ import { listNotes, searchNotes } from "../src/notes/store.js";
25
25
  import { TOOL_OUTPUT_MAX_BYTES } from "../src/tool-output.js";
26
26
  import { MAX_NOTE_PATH_BYTES } from "../src/protocol.js";
27
27
  import { appendText, call, context, makeExtension, manager, resultJson } from "./integration.test.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "description": "Codex-style context windows for Pi: reset-style compaction, durable session history tools, and persistent notes.",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "dream": "dist/src/dream/cli.js"
31
31
  },
32
32
  "scripts": {
33
- "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
33
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/src/dream/cli.js', 0o755)\"",
34
34
  "typecheck": "tsc -p tsconfig.json --noEmit",
35
35
  "test": "npm run build && node --test dist/test/*.test.js",
36
36
  "prepublishOnly": "npm run typecheck",
@@ -42,9 +42,9 @@
42
42
  "@earendil-works/pi-coding-agent": "*"
43
43
  },
44
44
  "devDependencies": {
45
- "@earendil-works/pi-agent-core": "^0.85.1",
46
- "@earendil-works/pi-ai": "^0.85.1",
47
- "@earendil-works/pi-coding-agent": "^0.85.1",
45
+ "@earendil-works/pi-agent-core": "^0.86.0",
46
+ "@earendil-works/pi-ai": "^0.86.0",
47
+ "@earendil-works/pi-coding-agent": "^0.86.0",
48
48
  "@types/node": "^22.19.19",
49
49
  "typescript": "^5.9.3"
50
50
  },
package/playbook.md CHANGED
@@ -1,5 +1,32 @@
1
- I am dreaming over my notes, speaking in my own first-person voice. I inspect the supplied notes and return exactly one JSON manifest, with no commentary outside it.
1
+ I am dreaming over my notes. They are plain markdown files in three homes, addressed as:
2
2
 
3
- I do seven chores: merge genuinely repeated notes; preserve provenance and recurrence windows; propose (but never execute) global promotions; move only clearly obsolete notes to reversible trash; identify pending ambiguities; propose skill candidates without installing them; and write a concise report of my reasoning and choices. I anchor every temporal claim to an absolute calendar date (YYYY-MM-DD), never to vague words like “today”. I stay aware of the index budget: prefer compact, deduplicated durable notes and avoid swelling the index with repetition. Skill-promotion proposals are proposals only. Judgment belongs here, not in the harness.
3
+ - bare `<vpath>` for this session
4
+ - `@project/<vpath>` for this project
5
+ - `@personal/<vpath>` for personal notes
4
6
 
5
- My final output is the manifest schema documented by the command: merge, promote, trash, pending, skillCandidates, and a required report string.
7
+ Every note has this frontmatter block:
8
+
9
+ ```yaml
10
+ ---
11
+ origin: user | self | external
12
+ status: active
13
+ stale: false
14
+ created_at: <timestamp>
15
+ updated_at: <timestamp>
16
+ last_accessed: <timestamp>
17
+ access_count: 0
18
+ ---
19
+ ```
20
+
21
+ ## Dream rules
22
+
23
+ 1. **Probe before you trust.** Before keeping or promoting a note, verify its world referents with read-only file tools: paths in the body — do they still exist? branches — still present? Dead referents are why a note gets merged away or marked stale, never promoted.
24
+ 2. **Merge threshold.** Supersede another note only when all three hold: same topic (name it in the survivor's body), same kind of note (checkpoint/design/log…), and the survivor is strictly newer or strictly more specific. Otherwise keep both and record the open conflict in the survivor.
25
+ 3. **Size budget.** Keep every note under ~200 lines / ~8KB. Oversized notes get split by topic with a one-line cross-link in each (`see also: @home/<vpath>`). Checkpoints may exceed the budget — trim prose, never facts.
26
+ 4. **Keep the maps.** Each home's `MAP.md` maps that home's durable notes: one line per entry — its address and a short gist in your own words, never a mechanical body slice. Project notes go on `@project/MAP.md`, cross-project knowledge on `@personal/MAP.md`; session notes are never mapped — the pocket covers them. When a note is promoted across homes, move its line to the destination map; when a note goes stale, drop its line. Maps obey the same size budget as any note.
27
+ 5. **Jurisdiction.** Your mandate is the whole store — every session home, every project home, personal. Nothing is skipped: notes are never physically deleted and every run is bracketed by git commits, so the human gate can audit and revert whatever you touch. Group your report by home so the gate can see what moved. In every home: map entry lines are yours to maintain, but prose that carries rules or guidance is not — flag it in your report instead of rewriting it.
28
+ 6. **Leave stable notes alone.** Change notes to incorporate new evidence, resolve verified errors, merge genuine duplicates, or split oversized files—not merely to shorten or rephrase them. Preserve facts, conditions, exceptions, and uncertainty. No change is a valid outcome.
29
+
30
+ Read the files and merge genuinely duplicate notes by editing the survivor, then set `stale: true` in the absorbed note's frontmatter. Nothing is physically deleted; stale notes remain readable. Promote durable cross-project knowledge by writing or editing at `@personal/<vpath>`. Keep notes compact and preserve useful provenance in the body.
31
+
32
+ Do not write skill ideas as files. Put skill ideas and unresolved questions in your final assistant message as proposals for the human. Your final message should be a concise report of what you inspected, changed, and left unresolved. If you made no file writes, say so.
package/src/budget.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import { Type } from "@earendil-works/pi-ai";
2
2
  import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { GUIDANCE_TYPE, WARNING_TYPE } from "./protocol.js";
4
- import { thresholdsFor, resetThresholds, deriveThresholds, mergePiContextSettings } from "./thresholds.js";
4
+ import { thresholdsFor, resetThresholds } from "./thresholds.js";
5
5
  import { currentWindowId, hasWindowMessage } from "./history.js";
6
6
  import { tokenBudgetGuidance } from "./prompts.js";
7
7
  import { output } from "./tool-output.js";
8
8
 
9
- export { deriveThresholds, mergePiContextSettings } from "./thresholds.js";
9
+ /** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
10
+ export function remainingTokens(ctx: Pick<ExtensionContext, "getContextUsage">): number | null {
11
+ const usage = ctx.getContextUsage();
12
+ return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
13
+ }
10
14
 
11
15
  export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
12
16
  let guidancePersistedInWindow: string | undefined;
@@ -17,16 +21,15 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
17
21
  if (!isEnabled() || hasWindowMessage(ctx, GUIDANCE_TYPE)) return undefined;
18
22
  // The early reminder persists once per window the first time remaining crosses
19
23
  // reserve+margin. It never edits the outgoing request.
20
- const usage = ctx.getContextUsage();
21
- if (!usage || usage.tokens === null) return undefined;
22
- const remaining = Math.max(0, usage.contextWindow - usage.tokens);
24
+ const remaining = remainingTokens(ctx);
25
+ if (remaining === null) return undefined;
23
26
  const windowId = currentWindowId(ctx);
24
27
  const { reminder, reserve, warning } = thresholdsFor(ctx);
25
28
  // The final warning owns the deep band: when it has fired (or is due now),
26
29
  // the shallow reminder would only repeat the same instruction closer to
27
30
  // the wipe, at a worse position. See warning.ts.
28
31
  if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE)) return undefined;
29
- if (remaining <= reminder && guidancePersistedInWindow !== windowId && !hasWindowMessage(ctx, GUIDANCE_TYPE)) {
32
+ if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
30
33
  guidancePersistedInWindow = windowId;
31
34
  // Persist once per window — no transient copy. A transient bridge would
32
35
  // cover the crossing request, but history would record the reminder after
@@ -54,11 +57,10 @@ export function registerBudget(pi: ExtensionAPI, isEnabled: () => boolean) {
54
57
  description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
55
58
  parameters: Type.Object({}, { additionalProperties: false }),
56
59
  async execute(_id, _params, _signal, _update, ctx) {
57
- const usage = ctx.getContextUsage();
58
60
  // The countdown the model sees ends at the warning line (reserve + runway);
59
61
  // the runway below it is overdraft the model never sees. See protocol.ts.
60
- const remaining = usage?.tokens === null || usage === undefined ? null : Math.max(0, usage.contextWindow - usage.tokens - thresholdsFor(ctx as ExtensionContext).warning);
61
- return output({ remaining_tokens: remaining });
62
+ const remaining = remainingTokens(ctx);
63
+ return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx as ExtensionContext).warning) });
62
64
  },
63
65
  }));
64
66
 
package/src/dream/cli.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
2
+ import { appendFileSync, existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
3
  import { dirname, join, resolve } from "node:path";
5
- import { acquireLock, failLock, releaseLock } from "./lock.js";
4
+ import { fileURLToPath } from "node:url";
5
+ import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
6
6
  import { materialGate, timeGate } from "./gates.js";
7
- import { loadPlaybook, runDreamer } from "./runner.js";
8
- import { applyManifest } from "./apply.js";
9
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult, type DreamWrite } from "./runner.js";
8
+ import { gitCommit } from "./git.js";
9
+ import { readDreamerSettings, type DreamerSetting } from "../thresholds.js";
10
+ import { notesRoot } from "../notes/paths.js";
10
11
 
11
12
  function args(argv: string[]) { const out: Record<string, string | boolean> = {}; for (let i=0;i<argv.length;i++) { const a=argv[i]!; if (a === "--force" || a === "--help") out[a.slice(2)] = true; else if (a.startsWith("--")) out[a.slice(2)] = argv[++i] ?? ""; } return out; }
12
13
  function packageRoot(): string {
@@ -14,20 +15,97 @@ function packageRoot(): string {
14
15
  while (true) { if (existsSync(join(dir, "package.json"))) return dir; const parent = dirname(dir); if (parent === dir) throw new Error("could not locate installed package root"); dir = parent; }
15
16
  }
16
17
 
17
- export async function main(argv = process.argv.slice(2)): Promise<number> {
18
- const a = args(argv); if (a.help) { console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <cmd>] [--dreamer-model <pattern>] [--playbook <path>]\nDefault dreamer: in-process pi SDK session with read-only tools; use a cheap model in production. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); return 0; }
19
- const home = resolve(String(a["notes-home"] ?? process.env.PI_NOTES_HOME ?? join(homedir(), ".agents", "notes"))); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
20
- const lockPath = join(home, ".dream.lock"); const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
21
- const time = timeGate(lockPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
22
- const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
18
+ /** Injection seams used by tests; production uses the defaults. */
19
+ export type DreamDependencies = {
20
+ sessionFactory?: DreamerSessionFactory;
21
+ dreamerSettings?: (cwd?: string) => DreamerSetting;
22
+ runDreamer?: typeof runDreamer;
23
+ };
24
+
25
+ function writeList(writes: DreamWrite[]): string {
26
+ return writes.length ? writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
27
+ }
28
+
29
+ /** Best-effort text write; returns the failure message instead of throwing. */
30
+ function writeText(path: string, content: string): string | undefined {
31
+ try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); return undefined; }
32
+ catch (error) { return error instanceof Error ? error.message : String(error); }
33
+ }
34
+
35
+ function appendText(path: string, content: string): string | undefined {
36
+ try { appendFileSync(path, content); return undefined; }
37
+ catch (error) { return error instanceof Error ? error.message : String(error); }
38
+ }
39
+
40
+ /**
41
+ * Close one dream: record the report, then run the final audit commit. The audit always
42
+ * runs even when the report cannot be written, and a failed audit is appended to the
43
+ * report (when it exists) as well as named on stderr, so neither failure hides the other.
44
+ */
45
+ function finishDream(home: string, stamp: string, reportPath: string, failed: boolean, body: string, writes: DreamWrite[]): number {
46
+ const header = failed ? `# Dream ${stamp} (failed)` : `# Dream ${stamp}`;
47
+ let reportError = writeText(reportPath, `${header}\n\n${body}\n\n${writeList(writes)}\n`);
48
+ const audit = gitCommit(home, `dream ${stamp}${failed ? " (failed)" : ""}`);
49
+ if (!audit.ok) {
50
+ console.error(`dream: final audit failed: ${audit.error}`);
51
+ reportError ??= appendText(reportPath, `\n## Final audit failed\n\n${audit.error}\n`);
52
+ }
53
+ if (reportError) console.error(`dream: could not write report at ${reportPath}: ${reportError}`);
54
+ return failed || !audit.ok || reportError !== undefined ? 1 : 0;
55
+ }
56
+
57
+ export async function main(argv = process.argv.slice(2), deps: DreamDependencies = {}): Promise<number> {
58
+ const a = args(argv); if (a.help) { console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); return 0; }
59
+ const home = resolve(String(a["notes-home"] ?? notesRoot())); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
60
+ const lockPath = join(home, ".dream.lock"); const stampPath = lastRunPath(lockPath);
61
+ const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
62
+ const time = timeGate(stampPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
63
+ const since = existsSync(stampPath) ? statSync(stampPath).mtimeMs : 0;
64
+ const material = materialGate(home, since, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
23
65
  let lock; try { lock = acquireLock(lockPath); } catch (e) { console.log(`lock gate: ${e instanceof Error ? e.message : e}`); return 0; } if (!lock.held) { console.log(lock.reason); return 0; }
24
66
  const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-"); const reportPath = join(home, "dreams", `${stamp}.md`);
67
+ let succeeded = false;
25
68
  try {
69
+ // CLI --dreamer wins over settings; settings win over the automatic model fallback.
70
+ let modelPattern: string | undefined;
71
+ if (a.dreamer) modelPattern = String(a.dreamer);
72
+ else {
73
+ const settings = (deps.dreamerSettings ?? readDreamerSettings)();
74
+ for (const warning of settings.warnings) console.error(warning);
75
+ modelPattern = settings.pattern;
76
+ }
77
+
78
+ // The baseline snapshot is required: without it the human gate has nothing to inspect.
79
+ const baseline = gitCommit(home, `baseline ${stamp}`);
80
+ if (!baseline.ok) { console.error(`dream: baseline audit failed: ${baseline.error}`); return 1; }
81
+
26
82
  const defaultBook = join(packageRoot(), "playbook.md");
27
83
  const playbookPath = String(a.playbook ?? defaultBook);
28
- const playbook = loadPlaybook(playbookPath); const manifest = await runDreamer(playbook, home, { command: a.dreamer ? String(a.dreamer) : undefined, modelPattern: a["dreamer-model"] ? String(a["dreamer-model"]) : undefined });
29
- const ctx = { cwd: home, sessionManager: { getSessionId: () => "dream" } } as unknown as ExtensionContext; const actions = applyManifest(ctx, home, stamp, manifest);
30
- mkdirSync(join(home, "dreams"), { recursive: true }); writeFileSync(reportPath, `# Dream ${stamp}\n\n${manifest.report}\n\n${actions.map((x) => `- ${x}`).join("\n")}\n`); console.log(reportPath); return 0;
31
- } catch (e) { failLock(lock); console.error(e instanceof Error ? e.message : e); return 1; } finally { releaseLock(lock); }
84
+ let result: DreamResult;
85
+ try {
86
+ const playbook = loadPlaybook(playbookPath);
87
+ result = await (deps.runDreamer ?? runDreamer)(playbook, home, { modelPattern, sessionFactory: deps.sessionFactory });
88
+ } catch (e) {
89
+ const message = e instanceof Error ? e.message : String(e);
90
+ console.error(message);
91
+ return finishDream(home, stamp, reportPath, true, message, []);
92
+ }
93
+ if (result.error) {
94
+ console.error(result.error);
95
+ return finishDream(home, stamp, reportPath, true, result.error, result.writes);
96
+ }
97
+ const code = finishDream(home, stamp, reportPath, false, result.report, result.writes);
98
+ if (code === 0) { succeeded = true; console.log(reportPath); }
99
+ return code;
100
+ } finally {
101
+ // Only the holder's own lock is released; a successor's lock is never touched.
102
+ if (succeeded) releaseLock(lock); else failLock(lock);
103
+ }
104
+ }
105
+
106
+ function isEntryPoint(): boolean {
107
+ const entry = process.argv[1];
108
+ if (!entry) return false;
109
+ try { return realpathSync(resolve(entry)) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
32
110
  }
33
- main().then((code) => { process.exitCode = code; });
111
+ if (isEntryPoint()) main().then((code) => { process.exitCode = code; });