@astrosheep/pi-context 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/src/dream/cli.js +117 -22
- package/dist/src/dream/doctor.js +138 -0
- package/dist/src/dream/gates.js +11 -7
- package/dist/src/dream/git.js +55 -12
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +12 -2
- package/dist/src/notes/address.js +4 -4
- package/dist/src/notes/frontmatter.js +2 -2
- package/dist/src/notes/paths.js +2 -2
- package/dist/src/notes/store.js +2 -2
- package/dist/src/notes/tools.js +1 -1
- package/dist/src/prompts.js +18 -11
- package/dist/src/protocol.js +7 -6
- package/dist/src/thresholds.js +29 -2
- package/dist/test/doctor.test.js +44 -0
- package/dist/test/dream.test.js +320 -35
- package/dist/test/integration.test.js +35 -25
- package/dist/test/notes.test.js +45 -45
- package/package.json +1 -1
- package/playbook.md +4 -4
- package/src/dream/cli.ts +101 -14
- package/src/dream/doctor.ts +97 -0
- package/src/dream/gates.ts +12 -6
- package/src/dream/git.ts +59 -13
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +12 -3
- package/src/notes/address.ts +4 -4
- package/src/notes/frontmatter.ts +2 -2
- package/src/notes/paths.ts +2 -2
- package/src/notes/store.ts +2 -2
- package/src/notes/tools.ts +1 -1
- package/src/prompts.ts +19 -11
- package/src/protocol.ts +7 -6
- package/src/thresholds.ts +34 -5
package/dist/test/notes.test.js
CHANGED
|
@@ -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:
|
|
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: "
|
|
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
|
|
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
|
|
222
|
-
assert.equal(
|
|
223
|
-
// Move the session copy to project; the
|
|
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: "
|
|
232
|
-
const
|
|
233
|
-
const refusal = resultJson(await call(captured, "notes_edit", { path: "clash.md", edits: [{ oldText: "stay", newText: "moved" }], scope: "
|
|
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("
|
|
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: "
|
|
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(), ["
|
|
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: "
|
|
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(), ["
|
|
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: "
|
|
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("
|
|
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
|
|
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: "@
|
|
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:
|
|
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:
|
|
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,
|
|
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], ["
|
|
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: "@
|
|
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
|
|
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", "@
|
|
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", "@
|
|
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("@
|
|
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("@
|
|
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: "@
|
|
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("
|
|
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\/.*@
|
|
430
|
-
await assert.rejects(() => call(captured, "notes_write", { address: "bad@name.md", content: "no" }, ctx), /@project\/.*@
|
|
431
|
-
assert.equal(existsSync(join(root, "
|
|
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;
|
|
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: "@
|
|
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(), ["@
|
|
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: "@
|
|
446
|
-
assert.match(read.header, /^\[@
|
|
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:
|
|
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
|
|
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: "@
|
|
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
|
|
464
|
-
assert.equal(boot.includes("
|
|
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: "@
|
|
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("
|
|
473
|
+
assert.ok(boot.includes("personal stale"), "a fresh personal map survives a stale project map");
|
|
474
474
|
});
|
package/package.json
CHANGED
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
|
-
- `@
|
|
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 `@
|
|
27
|
-
5. **Jurisdiction.** Your mandate is the whole store — every session home, every project home,
|
|
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 `@
|
|
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,13 @@
|
|
|
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 {
|
|
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";
|
|
10
|
+
import { doctor } from "./doctor.js";
|
|
8
11
|
import { notesRoot } from "../notes/paths.js";
|
|
9
12
|
|
|
10
13
|
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 +16,105 @@ function packageRoot(): string {
|
|
|
13
16
|
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
17
|
}
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
/** Injection seams used by tests; production uses the defaults. */
|
|
20
|
+
export type DreamDependencies = {
|
|
21
|
+
sessionFactory?: DreamerSessionFactory;
|
|
22
|
+
dreamerSettings?: (cwd?: string) => DreamerSetting;
|
|
23
|
+
runDreamer?: typeof runDreamer;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function writeList(writes: DreamWrite[]): string {
|
|
27
|
+
return writes.length ? writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Best-effort text write; returns the failure message instead of throwing. */
|
|
31
|
+
function writeText(path: string, content: string): string | undefined {
|
|
32
|
+
try { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content); return undefined; }
|
|
33
|
+
catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function appendText(path: string, content: string): string | undefined {
|
|
37
|
+
try { appendFileSync(path, content); return undefined; }
|
|
38
|
+
catch (error) { return error instanceof Error ? error.message : String(error); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Close one dream: record the report, then run the final audit commit. The audit always
|
|
43
|
+
* runs even when the report cannot be written, and a failed audit is appended to the
|
|
44
|
+
* report (when it exists) as well as named on stderr, so neither failure hides the other.
|
|
45
|
+
*/
|
|
46
|
+
function finishDream(home: string, stamp: string, reportPath: string, failed: boolean, body: string, writes: DreamWrite[]): number {
|
|
47
|
+
const header = failed ? `# Dream ${stamp} (failed)` : `# Dream ${stamp}`;
|
|
48
|
+
let reportError = writeText(reportPath, `${header}\n\n${body}\n\n${writeList(writes)}\n`);
|
|
49
|
+
const audit = gitCommit(home, `dream ${stamp}${failed ? " (failed)" : ""}`);
|
|
50
|
+
if (!audit.ok) {
|
|
51
|
+
console.error(`dream: final audit failed: ${audit.error}`);
|
|
52
|
+
reportError ??= appendText(reportPath, `\n## Final audit failed\n\n${audit.error}\n`);
|
|
53
|
+
}
|
|
54
|
+
if (reportError) console.error(`dream: could not write report at ${reportPath}: ${reportError}`);
|
|
55
|
+
return failed || !audit.ok || reportError !== undefined ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function main(argv = process.argv.slice(2), deps: DreamDependencies = {}): Promise<number> {
|
|
59
|
+
if (argv[0] === "doctor") {
|
|
60
|
+
const options = args(argv.slice(1));
|
|
61
|
+
if (options.help) { console.log("dream doctor [--notes-home <dir>] — read-only diagnostics; no model or repairs"); return 0; }
|
|
62
|
+
const home = resolve(String(options["notes-home"] ?? notesRoot()));
|
|
63
|
+
const issues = doctor(home);
|
|
64
|
+
console.log(issues.length ? issues.join("\n") : `dream doctor: OK (${home})`);
|
|
65
|
+
return issues.length ? 1 : 0;
|
|
66
|
+
}
|
|
67
|
+
const a = args(argv); if (a.help) { console.log("dream doctor [--notes-home <dir>] — read-only diagnostics\ndream --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
68
|
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
|
|
20
|
-
const
|
|
21
|
-
const
|
|
69
|
+
const lockPath = join(home, ".dream.lock"); const stampPath = lastRunPath(lockPath);
|
|
70
|
+
const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
|
|
71
|
+
const time = timeGate(stampPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
|
|
72
|
+
const since = existsSync(stampPath) ? statSync(stampPath).mtimeMs : 0;
|
|
73
|
+
const material = materialGate(home, since, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
|
|
22
74
|
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
75
|
const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-"); const reportPath = join(home, "dreams", `${stamp}.md`);
|
|
24
|
-
|
|
76
|
+
let succeeded = false;
|
|
25
77
|
try {
|
|
78
|
+
// CLI --dreamer wins over settings; settings win over the automatic model fallback.
|
|
79
|
+
let modelPattern: string | undefined;
|
|
80
|
+
if (a.dreamer) modelPattern = String(a.dreamer);
|
|
81
|
+
else {
|
|
82
|
+
const settings = (deps.dreamerSettings ?? readDreamerSettings)();
|
|
83
|
+
for (const warning of settings.warnings) console.error(warning);
|
|
84
|
+
modelPattern = settings.pattern;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// The baseline snapshot is required: without it the human gate has nothing to inspect.
|
|
88
|
+
const baseline = gitCommit(home, `baseline ${stamp}`);
|
|
89
|
+
if (!baseline.ok) { console.error(`dream: baseline audit failed: ${baseline.error}`); return 1; }
|
|
90
|
+
|
|
26
91
|
const defaultBook = join(packageRoot(), "playbook.md");
|
|
27
92
|
const playbookPath = String(a.playbook ?? defaultBook);
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
93
|
+
let result: DreamResult;
|
|
94
|
+
try {
|
|
95
|
+
const playbook = loadPlaybook(playbookPath);
|
|
96
|
+
result = await (deps.runDreamer ?? runDreamer)(playbook, home, { modelPattern, sessionFactory: deps.sessionFactory });
|
|
97
|
+
} catch (e) {
|
|
98
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
99
|
+
console.error(message);
|
|
100
|
+
return finishDream(home, stamp, reportPath, true, message, []);
|
|
101
|
+
}
|
|
102
|
+
if (result.error) {
|
|
103
|
+
console.error(result.error);
|
|
104
|
+
return finishDream(home, stamp, reportPath, true, result.error, result.writes);
|
|
105
|
+
}
|
|
106
|
+
const code = finishDream(home, stamp, reportPath, false, result.report, result.writes);
|
|
107
|
+
if (code === 0) { succeeded = true; console.log(reportPath); }
|
|
108
|
+
return code;
|
|
109
|
+
} finally {
|
|
110
|
+
// Only the holder's own lock is released; a successor's lock is never touched.
|
|
111
|
+
if (succeeded) releaseLock(lock); else failLock(lock);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isEntryPoint(): boolean {
|
|
116
|
+
const entry = process.argv[1];
|
|
117
|
+
if (!entry) return false;
|
|
118
|
+
try { return realpathSync(resolve(entry)) === realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
32
119
|
}
|
|
33
|
-
main().then((code) => { process.exitCode = code; });
|
|
120
|
+
if (isEntryPoint()) main().then((code) => { process.exitCode = code; });
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { assertAddress } from "../notes/address.js";
|
|
4
|
+
|
|
5
|
+
/** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
|
|
6
|
+
export function doctor(home: string): string[] {
|
|
7
|
+
const issues: string[] = [];
|
|
8
|
+
const report = (path: string, message: string) => issues.push(`${relative(home, path) || "."}: ${message}`);
|
|
9
|
+
const inspect = (path: string, action: () => void) => {
|
|
10
|
+
try { action(); } catch (error) { report(path, `cannot inspect: ${error instanceof Error ? error.message : String(error)}`); }
|
|
11
|
+
};
|
|
12
|
+
const directory = (path: string): boolean => {
|
|
13
|
+
const stat = lstatSync(path);
|
|
14
|
+
if (stat.isDirectory()) return true;
|
|
15
|
+
report(path, "expected a directory (symlinks are not followed); check its location/type");
|
|
16
|
+
return false;
|
|
17
|
+
};
|
|
18
|
+
const checkNote = (path: string, root: string, project?: string) => {
|
|
19
|
+
const raw = readFileSync(path, "utf8");
|
|
20
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
21
|
+
if (!match) { report(path, "missing or unclosed frontmatter; add a valid metadata block"); return; }
|
|
22
|
+
const fields = new Map<string, string>();
|
|
23
|
+
for (const line of match[1]!.split(/\r?\n/)) {
|
|
24
|
+
const field = /^([\w]+):\s*(.*?)\s*$/.exec(line);
|
|
25
|
+
if (!field) continue;
|
|
26
|
+
if (fields.has(field[1]!)) report(path, `duplicate metadata key ${field[1]}; keep one value`);
|
|
27
|
+
fields.set(field[1]!, field[2]!.replace(/^(["'])(.*)\1$/, "$2"));
|
|
28
|
+
}
|
|
29
|
+
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
|
|
30
|
+
if (!valid.test(fields.get(key) ?? "")) report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
31
|
+
}
|
|
32
|
+
for (const key of ["created_at", "updated_at", "last_accessed"]) {
|
|
33
|
+
const value = fields.get(key);
|
|
34
|
+
if (!value || !Number.isFinite(Date.parse(value))) report(path, `missing/invalid ${key}; use an ISO timestamp`);
|
|
35
|
+
}
|
|
36
|
+
if (fields.has("scope")) report(path, "obsolete scope field; remove it (home determines scope)");
|
|
37
|
+
// Check concrete, code-formatted addresses; examples/globs and prose are not links.
|
|
38
|
+
for (const link of raw.slice(match[0].length).matchAll(/`([^`\n]+)`/g)) {
|
|
39
|
+
const address = link[1]!;
|
|
40
|
+
if (!address.endsWith(".md") || /[<>*?\s]/.test(address)) continue;
|
|
41
|
+
if (!address.startsWith("@") && basename(path) !== "MAP.md") continue;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = assertAddress(address);
|
|
44
|
+
const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
|
|
45
|
+
if (!targetHome) { report(path, `${address}: project context unavailable; use a resolvable reference`); continue; }
|
|
46
|
+
if (!existsSync(join(targetHome, parsed.path))) report(path, `${address}: target missing; update or remove the reference`);
|
|
47
|
+
} catch { report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`); }
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const walk = (dir: string, root: string, project?: string) => {
|
|
51
|
+
for (const name of readdirSync(dir)) {
|
|
52
|
+
const path = join(dir, name);
|
|
53
|
+
inspect(path, () => {
|
|
54
|
+
const stat = lstatSync(path);
|
|
55
|
+
if (stat.isSymbolicLink()) report(path, "symlink not inspected; replace with a regular note/directory");
|
|
56
|
+
else if (stat.isDirectory()) walk(path, root, project);
|
|
57
|
+
else if (stat.isFile() && name.endsWith(".md")) checkNote(path, root, project);
|
|
58
|
+
else report(path, "unexpected file in note home; inspect and relocate it");
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
inspect(home, () => {
|
|
63
|
+
if (!directory(home)) return;
|
|
64
|
+
for (const name of readdirSync(home)) {
|
|
65
|
+
const path = join(home, name);
|
|
66
|
+
inspect(path, () => {
|
|
67
|
+
if (name === "global") { report(path, "legacy home; manually migrate to personal/ without overwriting existing files"); return; }
|
|
68
|
+
if (name === ".dream.lock") {
|
|
69
|
+
const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
|
|
70
|
+
report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name)) return;
|
|
74
|
+
if (name === "personal") { if (directory(path)) walk(path, path); return; }
|
|
75
|
+
if (name === "project" || name === "pi") {
|
|
76
|
+
if (!directory(path)) return;
|
|
77
|
+
const homes = name === "pi" ? join(path, "session") : path;
|
|
78
|
+
if (name === "pi") {
|
|
79
|
+
for (const entry of readdirSync(path)) if (entry !== "session") report(join(path, entry), "unexpected directory; expected pi/session/<id>/");
|
|
80
|
+
if (!existsSync(homes) || !directory(homes)) return;
|
|
81
|
+
}
|
|
82
|
+
for (const id of readdirSync(homes)) {
|
|
83
|
+
const root = join(homes, id);
|
|
84
|
+
inspect(root, () => {
|
|
85
|
+
if (!directory(root)) return;
|
|
86
|
+
if (name === "project" && !/^.+-[\da-f]{8}$/.test(id)) report(root, "invalid project key; expected <name>-<8 hex>");
|
|
87
|
+
walk(root, root, name === "project" ? root : undefined);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
return issues;
|
|
97
|
+
}
|
package/src/dream/gates.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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 >
|
|
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
|
}
|