@astrosheep/pi-context 0.21.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.
@@ -117,12 +117,12 @@ export async function call(captured, name, params, ctx) {
117
117
  if (noteCall && "path" in params && !("address" in params)) {
118
118
  const { path, scope, ...rest } = params;
119
119
  assert.equal(typeof path, "string", "legacy note fixture path is a string");
120
- const address = scope === "project" ? `@project/${path}` : scope === "global" ? `@global/${path}` : path;
120
+ const address = scope === "project" ? `@project/${path}` : scope === "personal" ? `@personal/${path}` : path;
121
121
  return tool.execute("call-1", { ...rest, address }, new AbortController().signal, () => { }, ctx);
122
122
  }
123
- if ((name === "notes_list" || name === "notes_search") && params.scope === "global") {
123
+ if ((name === "notes_list" || name === "notes_search") && params.scope === "personal") {
124
124
  const { scope: _scope, pattern, ...rest } = params;
125
- return tool.execute("call-1", { ...rest, pattern: `@global/${typeof pattern === "string" ? pattern : "**"}` }, new AbortController().signal, () => { }, ctx);
125
+ return tool.execute("call-1", { ...rest, pattern: `@personal/${typeof pattern === "string" ? pattern : "**"}` }, new AbortController().signal, () => { }, ctx);
126
126
  }
127
127
  if ((name === "notes_list" || name === "notes_search") && params.scope === "session") {
128
128
  const { scope: _scope, pattern, ...rest } = params;
@@ -134,7 +134,7 @@ export function resultJson(result) {
134
134
  const text = result.content[0];
135
135
  assert.ok(text && text.type === "text", "tool result carries text");
136
136
  const value = JSON.parse(text.text);
137
- const suffix = (address) => address.startsWith("@project/") ? address.slice("@project/".length) : address.startsWith("@global/") ? address.slice("@global/".length) : address;
137
+ const suffix = (address) => address.startsWith("@project/") ? address.slice("@project/".length) : address.startsWith("@personal/") ? address.slice("@personal/".length) : address;
138
138
  const legacyPath = (row) => {
139
139
  if (typeof row.address === "string" && row.path === undefined)
140
140
  Object.defineProperty(row, "path", { value: suffix(row.address), enumerable: false });
@@ -323,36 +323,36 @@ test("notes_list is most-recently-updated first across merged scopes", async ()
323
323
  put("session", "b.md", base + 10);
324
324
  put("session", "a.md", base + 10);
325
325
  put("project", "c.md", base + 5);
326
- put("global", "e.md", base + 20);
326
+ put("personal", "e.md", base + 20);
327
327
  const files = async (params) => resultJson(await call(captured, "notes_list", params, ctx)).files;
328
- assert.deepEqual((await files({})).map((file) => file.address), ["@global/e.md", "a.md", "b.md", "@project/c.md"], "updated_at descending with address ascending as the tiebreak");
328
+ assert.deepEqual((await files({})).map((file) => file.address), ["@personal/e.md", "a.md", "b.md", "@project/c.md"], "updated_at descending with address ascending as the tiebreak");
329
329
  // A same-path pair in two scopes keeps both rows; equal timestamps tie-break by scope name.
330
- put("global", "a.md", base + 10);
331
- assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.scope), ["global", "session"], "equal timestamps tie-break by full address");
330
+ put("personal", "a.md", base + 10);
331
+ assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.scope), ["personal", "session"], "equal timestamps tie-break by full address");
332
332
  assert.deepEqual((await files({ pattern: "*.md" })).map((file) => file.address), ["a.md", "b.md"], "a bare pattern narrows to the session home");
333
333
  });
334
334
  test("notes are real files that persist across sessions and round-trip Unicode", async () => {
335
335
  const original = manager();
336
336
  const captured = makeExtension(original);
337
337
  const ctx = context(original);
338
- await call(captured, "notes_write", { path: "checkpoint/进度.md", content: "第一行\nneedle Café", scope: "global" }, ctx);
339
- // A brand-new session over the same physical root sees the global note: nothing is replayed
338
+ await call(captured, "notes_write", { path: "checkpoint/进度.md", content: "第一行\nneedle Café", scope: "personal" }, ctx);
339
+ // A brand-new session over the same physical root sees the personal note: nothing is replayed
340
340
  // from session entries, the file itself is the durable artifact.
341
341
  const restored = manager();
342
342
  const restoredCaptured = makeExtension(restored);
343
343
  const restoredCtx = context(restored);
344
- const rawRead = await call(restoredCaptured, "notes_read", { path: "checkpoint/进度.md", scope: "global", offset_chars: -4 }, restoredCtx);
344
+ const rawRead = await call(restoredCaptured, "notes_read", { path: "checkpoint/进度.md", scope: "personal", offset_chars: -4 }, restoredCtx);
345
345
  const read = resultRead(rawRead);
346
- assert.equal(read.details.address, "@global/checkpoint/进度.md");
346
+ assert.equal(read.details.address, "@personal/checkpoint/进度.md");
347
347
  assert.equal(read.content, "Café", "a negative offset reads the body tail in one call");
348
- assert.equal(read.details.scope, "global");
349
- const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "global" }, restoredCtx));
348
+ assert.equal(read.details.scope, "personal");
349
+ const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "personal" }, restoredCtx));
350
350
  assert.equal(searched.files[0]?.matches[0]?.line, 2);
351
- const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**", scope: "global" }, restoredCtx));
351
+ const listedFiles = resultJson(await call(restoredCaptured, "notes_list", { pattern: "checkpoint/**", scope: "personal" }, restoredCtx));
352
352
  assert.equal(listedFiles.files.length, 1, "glob ** crosses into the checkpoint directory");
353
353
  assert.equal(listedFiles.files[0]?.path, "checkpoint/进度.md");
354
354
  // A single-segment * never crosses `/`, so a nested-only store matches nothing at the root.
355
- const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "global" }, restoredCtx));
355
+ const rootOnly = resultJson(await call(restoredCaptured, "notes_list", { pattern: "*", scope: "personal" }, restoredCtx));
356
356
  assert.equal(rootOnly.files.length, 0, "glob * stays within one segment");
357
357
  assert.equal(searched.files[0]?.created_at, listedFiles.files[0]?.created_at, "note tools agree on the timestamp format");
358
358
  assert.equal(searched.files[0]?.updated_at, listedFiles.files[0]?.updated_at);
@@ -380,6 +380,17 @@ test("stale lifecycle: writes and metadata-only edits close and revive a note",
380
380
  const missing = resultJson(await call(captured, "notes_edit", { path: "missing.md", stale: true }, ctx));
381
381
  assert.equal(missing.error, "note not found");
382
382
  });
383
+ test("notes tools stay usable while a dream holds the lock", async () => {
384
+ // A live dream lock is not a general lock: the awake notes tools never consult it.
385
+ writeFileSync(join(process.env.PI_NOTES_HOME, ".dream.lock"), String(process.pid));
386
+ const sm = manager();
387
+ const captured = makeExtension(sm);
388
+ const ctx = context(sm);
389
+ const written = resultJson(await call(captured, "notes_write", { path: "during-dream.md", content: "awake" }, ctx));
390
+ assert.equal(written.address, "during-dream.md");
391
+ const edited = resultJson(await call(captured, "notes_edit", { path: "during-dream.md", edits: [{ oldText: "awake", newText: "still awake" }] }, ctx));
392
+ assert.equal(edited.applied, 1, "notes_edit still applies while a dream lock is held");
393
+ });
383
394
  test("the boot notes index excludes stale notes while list, read, and search still see them", async () => {
384
395
  const sm = manager();
385
396
  const captured = makeExtension(sm);
@@ -416,22 +427,22 @@ test("the boot block gives awake agents the notes-home file layout", () => {
416
427
  const session = manager();
417
428
  const rendered = bootBlock(context(session), "pcw:test:root", undefined, false);
418
429
  assert.equal(rendered.includes(process.env.PI_NOTES_HOME ?? ""), false, "the absolute notes home is never exposed");
419
- assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@global\/<vpath>/);
430
+ assert.match(rendered, /bare <vpath>.*@project\/<vpath>.*@personal\/<vpath>/);
420
431
  assert.match(rendered, /there is no cross-home fallback/);
421
432
  assert.match(rendered, /Any other note is a plain file — use the file tools/);
422
433
  });
423
- test("the boot block keeps fresh global and project maps resident, never a session map", async () => {
434
+ test("the boot block keeps fresh personal and project maps resident, never a session map", async () => {
424
435
  const session = manager();
425
436
  const captured = makeExtension(session);
426
437
  const ctx = context(session);
427
438
  await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
428
439
  await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
429
- await call(captured, "notes_write", { address: "@global/MAP.md", content: "MAP: global" }, ctx);
440
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal" }, ctx);
430
441
  const rendered = bootBlock(ctx, "pcw:test:root", undefined, false);
431
- assert.ok(rendered.includes("MAP: global"));
442
+ assert.ok(rendered.includes("MAP: personal"));
432
443
  assert.ok(rendered.includes("MAP: project"));
433
444
  assert.equal(rendered.includes("MAP: session"), false);
434
- assert.ok(rendered.indexOf("MAP: global") < rendered.indexOf("MAP: project"), "global map precedes project map");
445
+ assert.ok(rendered.indexOf("MAP: personal") < rendered.indexOf("MAP: project"), "personal map precedes project map");
435
446
  });
436
447
  test("paged tool outputs stay bounded and cursors reconstruct history and notes", async () => {
437
448
  const session = manager();
@@ -1035,8 +1046,7 @@ test("the boot block is persisted at the root and baked into every reset summary
1035
1046
  assert.ok(rootText.includes("decisions.md"));
1036
1047
  const decisionsMeta = listNotes(ctx, { scope: "session" }).find((row) => row.path === "decisions.md")?.meta;
1037
1048
  assert.ok(decisionsMeta);
1038
- const bootUpdated = assertIsoTimestamp(rootText, "note metadata carries an updated timestamp");
1039
- assert.equal(Date.parse(bootUpdated), decisionsMeta.updated_at, "boot note timestamp restores the persisted updatedAt");
1049
+ assert.match(rootText, /updated \d+s ago\)/, "boot note metadata carries a relative update time");
1040
1050
  assert.ok(rootText.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
1041
1051
  // Reset: the boot block IS the compaction summary; no separate boot/hint is persisted.
1042
1052
  await call(captured, "new_context", {}, ctx);
@@ -1050,8 +1060,8 @@ test("the boot block is persisted at the root and baked into every reset summary
1050
1060
  assert.equal(before.compaction.summary.startsWith(internal.CONTEXT_WINDOW_OPEN_TAG), false, "a reset line precedes the identity block");
1051
1061
  assert.match(before.compaction.summary, new RegExp(`Current context window id: ${details.windowId}`));
1052
1062
  assert.ok(before.compaction.summary.includes("decisions.md"));
1053
- const resetUpdated = assertIsoTimestamp(before.compaction.summary, "reset summary keeps the note updated timestamp");
1054
- assert.equal(Date.parse(resetUpdated), decisionsMeta.updated_at, "reset summary keeps the persisted updatedAt");
1063
+ assert.match(before.compaction.summary, /updated \d+s ago\)/, "reset summary carries a relative update time");
1064
+ assert.equal(listNotes(ctx, { scope: "session" }).find((row) => row.path === "decisions.md")?.meta.updated_at, decisionsMeta.updated_at, "rendering relative time preserves the stored timestamp");
1055
1065
  assert.ok(before.compaction.summary.includes(internal.CONTEXT_WINDOW_PROTOCOL_OPEN_TAG));
1056
1066
  const windows = historyFromSession(ctx);
1057
1067
  assert.ok(before.compaction.summary.includes(`Previous context window id: ${windows[windows.length - 1]?.windowId}`));
@@ -62,7 +62,7 @@ test("write lands a real markdown file with harness frontmatter and a pure body"
62
62
  }
63
63
  assert.equal(typeof result.meta.created_at, "string", "wire meta renders timestamps as ISO strings");
64
64
  // A leading YAML block in user content is stripped from the body.
65
- 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);
66
66
  const stripped = readFileSync(physicalPath("session", "stripped.md", ctx), "utf8");
67
67
  assert.match(stripped, /\n---\n\nreal body$/, "the injected block is not part of the body");
68
68
  assert.equal(stripped.includes("nonsense"), false, "the injected block never reaches the file");
@@ -210,17 +210,17 @@ test.skip("scope resolution and movement are superseded by explicit address test
210
210
  const session = manager();
211
211
  const captured = makeExtension(session);
212
212
  const ctx = context(session);
213
- 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);
214
214
  await call(captured, "notes_write", { path: "shared.md", content: "session body", scope: "session" }, ctx);
215
215
  const first = resultRead(await call(captured, "notes_read", { path: "shared.md" }, ctx));
216
- 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");
217
217
  const readAgain = resultRead(await call(captured, "notes_read", { path: "shared.md", scope: "session" }, ctx));
218
218
  assert.ok(readAgain.content.includes("session body"));
219
219
  const sessionMeta = listNotes(ctx, { scope: "session" })[0].meta;
220
220
  assert.equal(sessionMeta.access_count, 2, "each read bumps access_count");
221
- const globalMeta = listNotes(ctx, { scope: "global" })[0].meta;
222
- assert.equal(globalMeta.access_count, 0, "the global copy is untouched");
223
- // 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.
224
224
  const moved = resultJson(await call(captured, "notes_edit", { path: "shared.md", edits: [{ oldText: "session", newText: "moved" }], scope: "project" }, ctx));
225
225
  assert.equal(moved.meta.scope, "project");
226
226
  assert.equal(moved.resolved_scope, "session", "resolved_scope names the layer the file moved from");
@@ -228,11 +228,11 @@ test.skip("scope resolution and movement are superseded by explicit address test
228
228
  assert.equal(existsSync(physicalPath("project", "shared.md", ctx)), true, "the file now lives in the project scope");
229
229
  // A move onto an existing target is refused and both files survive unchanged.
230
230
  await call(captured, "notes_write", { path: "clash.md", content: "session stay", scope: "session" }, ctx);
231
- await call(captured, "notes_write", { path: "clash.md", content: "global stay", scope: "global" }, ctx);
232
- const beforeGlobal = readFileSync(physicalPath("global", "clash.md", ctx), "utf8");
233
- 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));
234
234
  assert.match(refusal.error, /already exists/);
235
- 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");
236
236
  });
237
237
  test("list and search merge scopes and carry scope; the path jail rejects escapes", async () => {
238
238
  freshRoot();
@@ -241,20 +241,20 @@ test("list and search merge scopes and carry scope; the path jail rejects escape
241
241
  const ctx = context(session);
242
242
  await call(captured, "notes_write", { path: "one.md", content: "needle one", scope: "session" }, ctx);
243
243
  await call(captured, "notes_write", { path: "two.md", content: "needle two", scope: "project" }, ctx);
244
- 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);
245
245
  const listed = resultJson(await call(captured, "notes_list", {}, ctx));
246
- 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");
247
247
  for (const row of listed.files) {
248
248
  assert.equal(typeof row.size_bytes, "number");
249
249
  assert.equal(row.origin, "self");
250
250
  assert.equal(row.status, "active");
251
251
  assert.equal(row.stale, false);
252
252
  }
253
- const scoped = resultJson(await call(captured, "notes_list", { scope: "global" }, ctx));
253
+ const scoped = resultJson(await call(captured, "notes_list", { scope: "personal" }, ctx));
254
254
  assert.deepEqual(scoped.files.map((file) => file.path), ["three.md"], "a scope filter narrows the set");
255
255
  const searched = resultJson(await call(captured, "notes_search", { query: "needle" }, ctx));
256
256
  assert.equal(searched.files.length, 3, "literal search finds matches in every scope");
257
- 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"]);
258
258
  assert.equal(searched.files.every((file) => file.matches_total === 1), true);
259
259
  const hit = searched.files[0].matches[0];
260
260
  assert.equal(hit.line, 1);
@@ -276,12 +276,12 @@ test("the boot index reads the physical store across scopes and excludes stale n
276
276
  const captured = makeExtension(session);
277
277
  const ctx = context(session);
278
278
  await call(captured, "notes_write", { path: "fresh.md", content: "fresh content" }, ctx);
279
- 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);
280
280
  await call(captured, "notes_write", { path: "old.md", content: "stale content", stale: true }, ctx);
281
281
  runHandlers(captured, "session_start", {}, ctx);
282
282
  const text = typeof captured.sent[0]?.message.content === "string" ? captured.sent[0].message.content : "";
283
283
  assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
284
- 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");
285
285
  assert.equal(text.includes("old.md"), false, "a stale note leaves the index");
286
286
  assert.equal(text.includes("stale content"), false, "the stale note's body is absent from boot");
287
287
  for (const name of ["notes_write", "notes_edit", "notes_read", "notes_search", "notes_list"]) {
@@ -356,31 +356,31 @@ test("write-time caps refuse an oversized vpath or serialized file, and edit ref
356
356
  const refusedEdit = resultJson(await call(captured, "notes_edit", { path: "small.md", edits: [{ oldText: "small", newText: "y".repeat(MAX_NOTE_BYTES) }] }, ctx));
357
357
  assert.match(refusedEdit.error, new RegExp(String(MAX_NOTE_BYTES)), "an edit that would exceed the cap is refused");
358
358
  });
359
- test("fresh global and project MAP.md bodies are both resident before the pocket", async () => {
359
+ test("fresh personal and project MAP.md bodies are both resident before the pocket", async () => {
360
360
  freshRoot();
361
361
  const session = manager();
362
362
  const captured = makeExtension(session);
363
363
  const ctx = context(session);
364
364
  await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
365
365
  await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
366
- await call(captured, "notes_write", { address: "@global/MAP.md", content: "MAP: global\nMAP: second" }, ctx);
366
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal\nMAP: second" }, ctx);
367
367
  await call(captured, "notes_write", { path: "recent.md", content: "recent body" }, ctx);
368
368
  runHandlers(captured, "session_start", {}, ctx);
369
369
  const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
370
- assert.ok(boot.includes("MAP: global"), "the global map is injected");
370
+ assert.ok(boot.includes("MAP: personal"), "the personal map is injected");
371
371
  assert.ok(boot.includes("MAP: project"), "the project map is injected");
372
372
  assert.equal(boot.includes("MAP: session"), false, "the session map is never injected");
373
- assert.ok(boot.indexOf("MAP: global") < boot.indexOf("MAP: project"), "the global map precedes the project map");
373
+ assert.ok(boot.indexOf("MAP: personal") < boot.indexOf("MAP: project"), "the personal map precedes the project map");
374
374
  assert.ok(boot.indexOf("MAP: project") < boot.indexOf("crumpled note"), "both map bodies precede the pocket");
375
375
  assert.ok(PROTOCOL_BLOCK.includes("notes_write"), "the protocol text still rides along");
376
376
  });
377
- test("the boot pocket applies per-home quotas in session, project, global order", async () => {
377
+ test("the boot pocket applies per-home quotas in session, project, personal order", async () => {
378
378
  freshRoot();
379
379
  const session = manager();
380
380
  const captured = makeExtension(session);
381
381
  const ctx = context(session);
382
382
  const base = Date.parse("2026-01-01T00:00:00.000Z");
383
- for (const [scope, count] of [["session", 6], ["project", 3], ["global", 3]]) {
383
+ for (const [scope, count] of [["session", 6], ["project", 3], ["personal", 3]]) {
384
384
  for (let index = 0; index < count; index++) {
385
385
  const path = `${scope}-${index}.md`;
386
386
  await call(captured, "notes_write", { path, content: `${scope} body`, scope }, ctx);
@@ -389,21 +389,21 @@ test("the boot pocket applies per-home quotas in session, project, global order"
389
389
  }
390
390
  await call(captured, "notes_write", { address: "MAP.md", content: "MAP: session" }, ctx);
391
391
  await call(captured, "notes_write", { address: "@project/MAP.md", content: "MAP: project" }, ctx);
392
- await call(captured, "notes_write", { address: "@global/MAP.md", content: "MAP: global" }, ctx);
392
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "MAP: personal" }, ctx);
393
393
  runHandlers(captured, "session_start", {}, ctx);
394
394
  const boot = typeof captured.sent.at(-1)?.message.content === "string" ? captured.sent.at(-1).message.content : "";
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 global). 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", "@global/global-2.md", "@global/global-1.md"]) {
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
397
  assert.ok(boot.includes(name), `${name} stays in the pocket`);
398
398
  }
399
- for (const name of ["session-0.md", "@project/project-0.md", "@global/global-0.md", "MAP.md", "MAP: session"]) {
399
+ for (const name of ["session-0.md", "@project/project-0.md", "@personal/personal-0.md", "MAP.md", "MAP: session"]) {
400
400
  assert.equal(boot.includes(name), false, `${name} is not a pocket entry`);
401
401
  }
402
402
  assert.ok(boot.indexOf("session-5.md") < boot.indexOf("session-4.md"), "session notes are most-recent-first");
403
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("@global/global-2.md") < boot.indexOf("@global/global-1.md"), "global 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
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("@global/global-2.md"), "project notes precede global notes");
406
+ assert.ok(boot.indexOf("@project/project-1.md") < boot.indexOf("@personal/personal-2.md"), "project notes precede personal notes");
407
407
  });
408
408
  test("project scope keys off the git root basename and sha1 prefix", () => {
409
409
  const root = mkdtempSync(join(tmpdir(), "pi-context-proj-"));
@@ -421,54 +421,54 @@ test("@ addresses select one home, reject illegal sigils, and never fall back",
421
421
  const ctx = context(session);
422
422
  await call(captured, "notes_write", { address: "same.md", content: "session" }, ctx);
423
423
  await call(captured, "notes_write", { address: "@project/same.md", content: "project" }, ctx);
424
- await call(captured, "notes_write", { address: "@global/same.md", content: "global" }, ctx);
424
+ await call(captured, "notes_write", { address: "@personal/same.md", content: "personal" }, ctx);
425
425
  assert.ok(existsSync(physicalPath("project", "same.md", ctx)), "@project writes to the current project home");
426
- assert.ok(existsSync(physicalPath("global", "same.md", ctx)), "@global writes to the global home");
426
+ assert.ok(existsSync(physicalPath("personal", "same.md", ctx)), "@personal writes to the personal home");
427
427
  assert.match(resultRead(await call(captured, "notes_read", { address: "same.md" }, ctx)).content, /session$/);
428
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\/.*@global\/.*bare names are the session home/);
430
- await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@global\/.*bare names are the session home/);
431
- assert.equal(existsSync(join(root, "global", "bad@name.md")), false, "a bad sigil creates nothing anywhere");
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
432
  });
433
- test("full addresses drive outputs and patterns; legacy scope is read then dropped", async () => {
433
+ test("full addresses drive outputs and patterns; on-disk scope is read then dropped", async () => {
434
434
  freshRoot();
435
435
  const session = manager();
436
436
  const captured = makeExtension(session);
437
437
  const ctx = context(session);
438
438
  await call(captured, "notes_write", { address: "root.md", content: "needle" }, ctx);
439
439
  await call(captured, "notes_write", { address: "@project/project.md", content: "needle" }, ctx);
440
- await call(captured, "notes_write", { address: "@global/global.md", content: "needle" }, ctx);
440
+ await call(captured, "notes_write", { address: "@personal/personal.md", content: "needle" }, ctx);
441
441
  const list = resultJson(await call(captured, "notes_list", { pattern: "**" }, ctx));
442
- assert.deepEqual(list.files.map((file) => file.address).sort(), ["@global/global.md", "@project/project.md", "root.md"]);
442
+ assert.deepEqual(list.files.map((file) => file.address).sort(), ["@personal/personal.md", "@project/project.md", "root.md"]);
443
443
  assert.deepEqual(resultJson(await call(captured, "notes_list", { pattern: "*.md" }, ctx)).files.map((file) => file.address), ["root.md"]);
444
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: "@global/global.md" }, ctx));
446
- assert.match(read.header, /^\[@global\/global\.md /, "the raw read header echoes the full address");
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
447
  const legacy = physicalPath("project", "legacy.md", ctx);
448
- writeFileSync(legacy, "---\nscope: global\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");
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
449
  const legacyRead = resultRead(await call(captured, "notes_read", { address: "@project/legacy.md" }, ctx));
450
450
  assert.equal(legacyRead.details.scope, "project", "scope is derived from the file location");
451
451
  await call(captured, "notes_edit", { address: "@project/legacy.md", stale: true }, ctx);
452
452
  assert.equal(/^scope:/m.test(readFileSync(legacy, "utf8")), false, "the next write removes legacy scope frontmatter");
453
453
  });
454
- test("stale project and global maps are skipped independently", async () => {
454
+ test("stale project and personal maps are skipped independently", async () => {
455
455
  freshRoot();
456
456
  const session = manager();
457
457
  const captured = makeExtension(session);
458
458
  const ctx = context(session);
459
459
  await call(captured, "notes_write", { address: "@project/MAP.md", content: "project fresh" }, ctx);
460
- await call(captured, "notes_write", { address: "@global/MAP.md", content: "global stale", stale: true }, ctx);
460
+ await call(captured, "notes_write", { address: "@personal/MAP.md", content: "personal stale", stale: true }, ctx);
461
461
  runHandlers(captured, "session_start", {}, ctx);
462
462
  let boot = String(captured.sent.at(-1)?.message.content ?? "");
463
- assert.ok(boot.includes("project fresh"), "a fresh project map survives a stale global map");
464
- assert.equal(boot.includes("global stale"), false, "the stale global map is skipped");
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
465
  const second = manager();
466
466
  const secondCaptured = makeExtension(second);
467
467
  const secondCtx = context(second);
468
468
  await call(secondCaptured, "notes_edit", { address: "@project/MAP.md", stale: true }, secondCtx);
469
- await call(secondCaptured, "notes_edit", { address: "@global/MAP.md", stale: false }, secondCtx);
469
+ await call(secondCaptured, "notes_edit", { address: "@personal/MAP.md", stale: false }, secondCtx);
470
470
  runHandlers(secondCaptured, "session_start", {}, secondCtx);
471
471
  boot = String(secondCaptured.sent.at(-1)?.message.content ?? "");
472
472
  assert.equal(boot.includes("project fresh"), false, "the stale project map is skipped");
473
- assert.ok(boot.includes("global stale"), "a fresh global map survives a stale project map");
473
+ assert.ok(boot.includes("personal stale"), "a fresh personal map survives a stale project map");
474
474
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/pi-context",
3
- "version": "0.21.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",
package/playbook.md CHANGED
@@ -2,7 +2,7 @@ I am dreaming over my notes. They are plain markdown files in three homes, addre
2
2
 
3
3
  - bare `<vpath>` for this session
4
4
  - `@project/<vpath>` for this project
5
- - `@global/<vpath>` for global notes
5
+ - `@personal/<vpath>` for personal notes
6
6
 
7
7
  Every note has this frontmatter block:
8
8
 
@@ -23,10 +23,10 @@ access_count: 0
23
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
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
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 `@global/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, global. 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.
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
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
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 `@global/<vpath>`. Keep notes compact and preserve useful provenance in the body.
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
31
 
32
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/dream/cli.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { acquireLock, failLock, releaseLock } from "./lock.js";
4
+ import { fileURLToPath } from "node:url";
5
+ import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
5
6
  import { materialGate, timeGate } from "./gates.js";
6
- import { loadPlaybook, runDreamer } from "./runner.js";
7
+ import { loadPlaybook, runDreamer, type DreamerSessionFactory, type DreamResult, type DreamWrite } from "./runner.js";
7
8
  import { gitCommit } from "./git.js";
9
+ import { readDreamerSettings, type DreamerSetting } from "../thresholds.js";
8
10
  import { notesRoot } from "../notes/paths.js";
9
11
 
10
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; }
@@ -13,21 +15,97 @@ function packageRoot(): string {
13
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; }
14
16
  }
15
17
 
16
- export async function main(argv = process.argv.slice(2)): Promise<number> {
17
- 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>]\nDefault dreamer: in-process pi SDK session with jailed file tools. Default playbook: <installed package root>/playbook.md; --playbook overrides it."); 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; }
18
59
  const home = resolve(String(a["notes-home"] ?? notesRoot())); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
19
- const lockPath = join(home, ".dream.lock"); const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
20
- const time = timeGate(lockPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
21
- const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
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;
22
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; }
23
66
  const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-"); const reportPath = join(home, "dreams", `${stamp}.md`);
24
- gitCommit(home, `baseline ${stamp}`);
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 result = await runDreamer(playbook, home, { modelPattern: a.dreamer ? String(a.dreamer) : undefined });
29
- const writes = result.writes.length ? result.writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
30
- mkdirSync(join(home, "dreams"), { recursive: true }); writeFileSync(reportPath, `# Dream ${stamp}\n\n${result.report}\n\n${writes}\n`); gitCommit(home, `dream ${stamp}`); 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; });
@@ -3,18 +3,24 @@ import { join } from "node:path";
3
3
  import { sessionHomesRoot } from "../notes/paths.js";
4
4
 
5
5
  export type GateResult = { ok: boolean; reason: string };
6
- export function timeGate(lockPath: string, minHours: number, now = Date.now()): GateResult {
7
- if (!existsSync(lockPath)) return { ok: true, reason: "time gate: no prior lock" };
8
- const age = now - statSync(lockPath).mtimeMs;
9
- return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
6
+
7
+ /**
8
+ * The scheduler reads the last-run sidecar, not the lock: the lock's lifetime says
9
+ * nothing about when the last dream ran, while the sidecar records exactly that.
10
+ */
11
+ export function timeGate(stampPath: string, minHours: number, now = Date.now()): GateResult {
12
+ if (!existsSync(stampPath)) return { ok: true, reason: "time gate: no prior dream" };
13
+ const age = now - statSync(stampPath).mtimeMs;
14
+ return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: last dream is too recent" };
10
15
  }
11
- export function materialGate(home: string, lockMtime: number, minSessions: number): GateResult {
16
+
17
+ export function materialGate(home: string, sinceMtime: number, minSessions: number): GateResult {
12
18
  const root = sessionHomesRoot(home);
13
19
  let changed = 0;
14
20
  if (existsSync(root)) for (const dir of readdirSync(root, { withFileTypes: true })) {
15
21
  if (!dir.isDirectory()) continue;
16
22
  const files = readdirSync(join(root, dir.name), { withFileTypes: true });
17
- if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > lockMtime)) changed++;
23
+ if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > sinceMtime)) changed++;
18
24
  }
19
25
  return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
20
26
  }