@astrosheep/pi-context 0.20.0 → 0.21.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.
- package/dist/src/budget.js +10 -8
- package/dist/src/dream/cli.js +9 -8
- package/dist/src/dream/gates.js +2 -1
- package/dist/src/dream/git.js +28 -0
- package/dist/src/dream/runner.js +84 -25
- package/dist/src/history-tools.js +5 -5
- package/dist/src/history.js +11 -6
- package/dist/src/index.js +14 -15
- package/dist/src/notes/address.js +31 -0
- package/dist/src/{memory → notes}/frontmatter.js +5 -3
- package/dist/src/{notes.js → notes/model.js} +1 -1
- package/dist/src/{memory → notes}/paths.js +5 -1
- package/dist/src/{memory → notes}/store.js +45 -72
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +31 -29
- package/dist/src/protocol.js +8 -4
- package/dist/src/thresholds.js +4 -1
- package/dist/src/tool-output.js +4 -1
- package/dist/src/warning.js +3 -3
- package/dist/test/agent-loop.test.js +6 -4
- package/dist/test/coherence.test.js +5 -1
- package/dist/test/dream.test.js +133 -34
- package/dist/test/history.test.js +6 -1
- package/dist/test/integration.test.js +84 -34
- package/dist/test/{memory.test.js → notes.test.js} +138 -34
- package/dist/test/pagination.property.test.js +1 -1
- package/package.json +5 -5
- package/playbook.md +30 -3
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +8 -8
- package/src/dream/gates.ts +2 -1
- package/src/dream/git.ts +27 -0
- package/src/dream/runner.ts +81 -23
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +5 -3
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +6 -1
- package/src/{memory → notes}/store.ts +47 -77
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +31 -29
- package/src/protocol.ts +8 -4
- package/src/thresholds.ts +4 -1
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/dist/src/dream/apply.js +0 -87
- package/dist/src/dream/manifest.js +0 -16
- package/dist/src/memory/tools.js +0 -175
- package/src/dream/apply.ts +0 -47
- package/src/dream/manifest.ts +0 -21
- 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/
|
|
15
|
-
import { listNotes } from "../src/
|
|
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-
|
|
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,9 +53,10 @@ 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 [["
|
|
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
|
}
|
|
@@ -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" }],
|
|
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+
|
|
142
|
-
assert.match(combined.diff, /\+ *\d+
|
|
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,7 +205,7 @@ 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
|
|
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);
|
|
@@ -245,7 +283,7 @@ test("the boot index reads the physical store across scopes and excludes stale n
|
|
|
245
283
|
assert.ok(text.includes("fresh.md"), "a fresh session note is indexed");
|
|
246
284
|
assert.ok(text.includes("global.md"), "a fresh global 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, "
|
|
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("
|
|
359
|
+
test("fresh global 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", {
|
|
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: "@global/MAP.md", content: "MAP: global\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"), "
|
|
331
|
-
assert.ok(boot.
|
|
370
|
+
assert.ok(boot.includes("MAP: global"), "the global 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: global") < boot.indexOf("MAP: project"), "the global 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("
|
|
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, global order", async () => {
|
|
348
378
|
freshRoot();
|
|
349
379
|
const session = manager();
|
|
350
380
|
const captured = makeExtension(session);
|
|
351
381
|
const ctx = context(session);
|
|
352
|
-
|
|
353
|
-
|
|
382
|
+
const base = Date.parse("2026-01-01T00:00:00.000Z");
|
|
383
|
+
for (const [scope, count] of [["session", 6], ["project", 3], ["global", 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: "@global/MAP.md", content: "MAP: global" }, 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.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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"]) {
|
|
397
|
+
assert.ok(boot.includes(name), `${name} stays in the pocket`);
|
|
398
|
+
}
|
|
399
|
+
for (const name of ["session-0.md", "@project/project-0.md", "@global/global-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("@global/global-2.md") < boot.indexOf("@global/global-1.md"), "global 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("@global/global-2.md"), "project notes precede global 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: "@global/same.md", content: "global" }, ctx);
|
|
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");
|
|
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\/.*@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");
|
|
432
|
+
});
|
|
433
|
+
test("full addresses drive outputs and patterns; legacy 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: "@global/global.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(), ["@global/global.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: "@global/global.md" }, ctx));
|
|
446
|
+
assert.match(read.header, /^\[@global\/global\.md /, "the raw read header echoes the full address");
|
|
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");
|
|
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 global 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: "@global/MAP.md", content: "global 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 global map");
|
|
464
|
+
assert.equal(boot.includes("global stale"), false, "the stale global 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: "@global/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("global stale"), "a fresh global 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/
|
|
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.
|
|
3
|
+
"version": "0.21.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.
|
|
46
|
-
"@earendil-works/pi-ai": "^0.
|
|
47
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
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
|
|
1
|
+
I am dreaming over my notes. They are plain markdown files in three homes, addressed as:
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
- bare `<vpath>` for this session
|
|
4
|
+
- `@project/<vpath>` for this project
|
|
5
|
+
- `@global/<vpath>` for global notes
|
|
4
6
|
|
|
5
|
-
|
|
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 `@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.
|
|
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 `@global/<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
|
|
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
|
-
|
|
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
|
|
21
|
-
if (
|
|
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
|
|
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 =
|
|
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,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { dirname, join, resolve } from "node:path";
|
|
5
4
|
import { acquireLock, failLock, releaseLock } from "./lock.js";
|
|
6
5
|
import { materialGate, timeGate } from "./gates.js";
|
|
7
6
|
import { loadPlaybook, runDreamer } from "./runner.js";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
7
|
+
import { gitCommit } from "./git.js";
|
|
8
|
+
import { notesRoot } from "../notes/paths.js";
|
|
10
9
|
|
|
11
10
|
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
11
|
function packageRoot(): string {
|
|
@@ -15,19 +14,20 @@ function packageRoot(): string {
|
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
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 <
|
|
19
|
-
const home = resolve(String(a["notes-home"] ??
|
|
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
|
+
const home = resolve(String(a["notes-home"] ?? notesRoot())); process.env.PI_NOTES_HOME = home; mkdirSync(home, { recursive: true });
|
|
20
19
|
const lockPath = join(home, ".dream.lock"); const minHours = Number(a["min-hours"] ?? 24); const minSessions = Number(a["min-sessions"] ?? 3);
|
|
21
20
|
const time = timeGate(lockPath, minHours); console.log(time.reason); if (!a.force && !time.ok) return 0;
|
|
22
21
|
const material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions); console.log(material.reason); if (!a.force && !material.ok) return 0;
|
|
23
22
|
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
23
|
const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-"); const reportPath = join(home, "dreams", `${stamp}.md`);
|
|
24
|
+
gitCommit(home, `baseline ${stamp}`);
|
|
25
25
|
try {
|
|
26
26
|
const defaultBook = join(packageRoot(), "playbook.md");
|
|
27
27
|
const playbookPath = String(a.playbook ?? defaultBook);
|
|
28
|
-
const playbook = loadPlaybook(playbookPath); const
|
|
29
|
-
const
|
|
30
|
-
mkdirSync(join(home, "dreams"), { recursive: true }); writeFileSync(reportPath, `# Dream ${stamp}\n\n${
|
|
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
31
|
} catch (e) { failLock(lock); console.error(e instanceof Error ? e.message : e); return 1; } finally { releaseLock(lock); }
|
|
32
32
|
}
|
|
33
33
|
main().then((code) => { process.exitCode = code; });
|
package/src/dream/gates.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { sessionHomesRoot } from "../notes/paths.js";
|
|
3
4
|
|
|
4
5
|
export type GateResult = { ok: boolean; reason: string };
|
|
5
6
|
export function timeGate(lockPath: string, minHours: number, now = Date.now()): GateResult {
|
|
@@ -8,7 +9,7 @@ export function timeGate(lockPath: string, minHours: number, now = Date.now()):
|
|
|
8
9
|
return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
|
|
9
10
|
}
|
|
10
11
|
export function materialGate(home: string, lockMtime: number, minSessions: number): GateResult {
|
|
11
|
-
const root =
|
|
12
|
+
const root = sessionHomesRoot(home);
|
|
12
13
|
let changed = 0;
|
|
13
14
|
if (existsSync(root)) for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
14
15
|
if (!dir.isDirectory()) continue;
|
package/src/dream/git.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Git audit layer for a dream run: one commit before (baseline) and one after
|
|
7
|
+
* (dream), so the human gate reviews `git show` instead of trusting a report,
|
|
8
|
+
* and rollback is `git revert`. This layer is a garnish, never load-bearing:
|
|
9
|
+
* every failure is logged and swallowed — a notes home without git, or a
|
|
10
|
+
* broken repo, still dreams. Nothing is committed when the tree is clean.
|
|
11
|
+
*/
|
|
12
|
+
export function gitCommit(home: string, message: string): void {
|
|
13
|
+
try {
|
|
14
|
+
if (!existsSync(join(home, ".git"))) {
|
|
15
|
+
execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
|
|
16
|
+
}
|
|
17
|
+
execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
|
|
18
|
+
try {
|
|
19
|
+
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
|
|
20
|
+
return; // clean tree — no empty commit
|
|
21
|
+
} catch { /* staged changes exist — fall through to commit */ }
|
|
22
|
+
execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
|
|
23
|
+
console.log(`git: committed "${message}"`);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.log(`git audit layer skipped: ${error instanceof Error ? error.message : error}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/dream/runner.ts
CHANGED
|
@@ -1,12 +1,77 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { createAgentSession, createEditToolDefinition, createWriteToolDefinition, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager, type AgentSession, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
5
|
-
import {
|
|
6
|
+
import { contentText } from "../history.js";
|
|
6
7
|
|
|
8
|
+
export type DreamWrite = { tool: "write" | "edit"; path: string };
|
|
9
|
+
export type DreamResult = { report: string; writes: DreamWrite[] };
|
|
7
10
|
export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
|
|
8
11
|
export type DreamerSessionFactory = (options: { cwd: string; modelPattern?: string; tools: string[] }) => Promise<DreamerSession>;
|
|
9
|
-
export const
|
|
12
|
+
export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
|
|
13
|
+
|
|
14
|
+
function isOutside(notesHome: string, target: string): boolean {
|
|
15
|
+
const fromHome = relative(notesHome, target);
|
|
16
|
+
return fromHome === ".." || fromHome.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(fromHome);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function jailWritePath(notesHome: string, path: string): Promise<void> {
|
|
20
|
+
let realNotesHome: string;
|
|
21
|
+
try {
|
|
22
|
+
realNotesHome = await realpath(notesHome);
|
|
23
|
+
} catch {
|
|
24
|
+
throw new Error(`write jail: cannot resolve notes home ${notesHome}`);
|
|
25
|
+
}
|
|
26
|
+
const target = resolve(realNotesHome, path);
|
|
27
|
+
const targetParent = dirname(target);
|
|
28
|
+
if (isOutside(realNotesHome, targetParent)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
29
|
+
// write creates parent directories itself. Create only after the lexical check, then
|
|
30
|
+
// canonicalize the parent so a symlink cannot lead the underlying tool out of home.
|
|
31
|
+
await mkdir(targetParent, { recursive: true });
|
|
32
|
+
let realTargetParent: string;
|
|
33
|
+
try {
|
|
34
|
+
realTargetParent = await realpath(targetParent);
|
|
35
|
+
} catch {
|
|
36
|
+
throw new Error(`write jail: cannot resolve target parent in notes home ${notesHome}`);
|
|
37
|
+
}
|
|
38
|
+
if (isOutside(realNotesHome, realTargetParent)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
39
|
+
let targetStats;
|
|
40
|
+
try {
|
|
41
|
+
targetStats = await lstat(target);
|
|
42
|
+
} catch (error: any) {
|
|
43
|
+
if (error.code !== "ENOENT") throw new Error(`write jail: cannot inspect target in notes home ${notesHome}`);
|
|
44
|
+
}
|
|
45
|
+
if (targetStats?.isSymbolicLink()) {
|
|
46
|
+
let realTarget: string;
|
|
47
|
+
try {
|
|
48
|
+
realTarget = await realpath(target);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`write jail: cannot resolve target in notes home ${notesHome}`);
|
|
51
|
+
}
|
|
52
|
+
if (isOutside(realNotesHome, realTarget)) throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
53
|
+
}
|
|
54
|
+
if (targetStats && targetStats.nlink > 1) throw new Error(`write jail: ${path} has hard links and is not allowed in notes home ${notesHome}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function jailToolDefinition<T extends ToolDefinition<any, any, any>>(definition: T, notesHome: string): T {
|
|
58
|
+
const execute = definition.execute;
|
|
59
|
+
return {
|
|
60
|
+
...definition,
|
|
61
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
62
|
+
await jailWritePath(notesHome, (params as { path: string }).path);
|
|
63
|
+
return execute(toolCallId, params, signal, onUpdate, ctx);
|
|
64
|
+
},
|
|
65
|
+
} as T;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The only custom definitions in the dream session replace the two built-ins with jailed versions. */
|
|
69
|
+
export function dreamerWriteToolDefinitions(notesHome: string): ToolDefinition<any, any, any>[] {
|
|
70
|
+
return [
|
|
71
|
+
jailToolDefinition(createWriteToolDefinition(notesHome), notesHome),
|
|
72
|
+
jailToolDefinition(createEditToolDefinition(notesHome), notesHome),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
10
75
|
|
|
11
76
|
export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
|
|
12
77
|
let model: Model<Api> | undefined;
|
|
@@ -16,38 +81,31 @@ export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd,
|
|
|
16
81
|
model = result.scopedModels[0]?.model;
|
|
17
82
|
if (!model) throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
|
|
18
83
|
}
|
|
19
|
-
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, noTools: "all", model });
|
|
84
|
+
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
|
|
20
85
|
return session;
|
|
21
86
|
};
|
|
22
87
|
|
|
23
|
-
export function
|
|
24
|
-
const
|
|
25
|
-
if (result.error || result.status !== 0) throw new Error(`dreamer failed: ${result.error?.message ?? result.stderr ?? `exit ${result.status}`}`);
|
|
26
|
-
return parseManifest(result.stdout);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export async function runDreamer(playbook: string, cwd: string, options: { command?: string; modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<Manifest> {
|
|
30
|
-
if (options.command) return runExternalDreamer(options.command, playbook, cwd);
|
|
31
|
-
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: READ_ONLY_TOOLS });
|
|
88
|
+
export async function runDreamer(playbook: string, cwd: string, options: { modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<DreamResult> {
|
|
89
|
+
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
|
|
32
90
|
let answer = "";
|
|
33
91
|
let providerError: string | undefined;
|
|
92
|
+
const writes: DreamWrite[] = [];
|
|
34
93
|
const unsubscribe = session.subscribe((event: any) => {
|
|
94
|
+
const tool = event.toolName ?? event.tool?.name;
|
|
95
|
+
const args = event.args ?? event.arguments ?? event.tool?.arguments;
|
|
96
|
+
if ((tool === "write" || tool === "edit") && args && typeof args === "object" && typeof args.path === "string") writes.push({ tool, path: args.path });
|
|
35
97
|
if (event.type !== "message_end" || event.message?.role !== "assistant") return;
|
|
36
98
|
if (event.message.stopReason === "error") { providerError = event.message.errorMessage ?? "unknown provider error"; return; }
|
|
37
|
-
|
|
38
|
-
answer = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part: any) => part.type === "text").map((part: any) => part.text).join("") : "";
|
|
99
|
+
answer = contentText(event.message.content);
|
|
39
100
|
});
|
|
40
101
|
try {
|
|
41
|
-
await session.prompt(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} catch (error) {
|
|
45
|
-
if (providerError) throw new Error(`dreamer failed: ${providerError}`);
|
|
46
|
-
throw error;
|
|
47
|
-
}
|
|
102
|
+
await session.prompt(playbook);
|
|
103
|
+
if (providerError) throw new Error(`dreamer failed: ${providerError}`);
|
|
104
|
+
return { report: answer, writes };
|
|
48
105
|
} finally {
|
|
49
106
|
unsubscribe?.();
|
|
50
107
|
session.dispose();
|
|
51
108
|
}
|
|
52
109
|
}
|
|
110
|
+
|
|
53
111
|
export function loadPlaybook(path: string): string { return readFileSync(path, "utf8"); }
|