@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/dream.test.js
CHANGED
|
@@ -1,48 +1,167 @@
|
|
|
1
1
|
import test from "node:test";
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
3
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
4
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { mkdtempSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
6
7
|
import { join } from "node:path";
|
|
7
|
-
import { acquireLock, failLock } from "../src/dream/lock.js";
|
|
8
|
+
import { acquireLock, failLock, lastRunPath, releaseLock } from "../src/dream/lock.js";
|
|
8
9
|
import { materialGate, timeGate } from "../src/dream/gates.js";
|
|
9
10
|
import { defaultDreamerSessionFactory, dreamerWriteToolDefinitions, runDreamer, DREAMER_TOOLS } from "../src/dream/runner.js";
|
|
10
11
|
import { gitCommit } from "../src/dream/git.js";
|
|
11
|
-
import {
|
|
12
|
+
import { main } from "../src/dream/cli.js";
|
|
13
|
+
import { deriveDreamer, mergePiContextSettings, readDreamerSettings } from "../src/thresholds.js";
|
|
14
|
+
import { PI_CONTEXT_DREAMER_KEY, PI_CONTEXT_SETTINGS_KEY } from "../src/protocol.js";
|
|
12
15
|
import { contentText } from "../src/history.js";
|
|
13
16
|
function fixture() { return mkdtempSync(join(tmpdir(), "dream-")); }
|
|
14
17
|
function old(path) { const d = new Date(Date.now() - 48 * 3600_000); utimesSync(path, d, d); }
|
|
18
|
+
/** A session whose prompt runs a scripted interaction with the recorded event handler. */
|
|
19
|
+
function scriptedSession(run) {
|
|
20
|
+
return async ({ cwd }) => {
|
|
21
|
+
let handler = () => { };
|
|
22
|
+
const session = {
|
|
23
|
+
subscribe(next) { handler = next; return () => { }; },
|
|
24
|
+
async prompt() { run(handler, cwd); },
|
|
25
|
+
dispose() { },
|
|
26
|
+
};
|
|
27
|
+
return session;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function successSession() {
|
|
31
|
+
return scriptedSession((handler) => handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }] } }));
|
|
32
|
+
}
|
|
33
|
+
/** Capture console.error lines without letting CLI chatter pollute the test output. */
|
|
34
|
+
async function captureErrors(run) {
|
|
35
|
+
const errors = [];
|
|
36
|
+
const original = console.error;
|
|
37
|
+
console.error = (...parts) => { errors.push(parts.map(String).join(" ")); };
|
|
38
|
+
try {
|
|
39
|
+
return { code: await run(), errors };
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
console.error = original;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
15
45
|
test("time and material gates preserve skip decisions and reasons", () => {
|
|
16
46
|
const home = fixture();
|
|
17
47
|
const lock = join(home, ".dream.lock");
|
|
18
|
-
|
|
19
|
-
|
|
48
|
+
const stamp = lastRunPath(lock);
|
|
49
|
+
assert.deepEqual(timeGate(stamp, 24).ok, true, "no prior dream passes");
|
|
50
|
+
writeFileSync(stamp, new Date().toISOString());
|
|
51
|
+
const fresh = timeGate(stamp, 24);
|
|
20
52
|
assert.equal(fresh.ok, false);
|
|
21
|
-
assert.equal(fresh.reason, "time gate:
|
|
22
|
-
old(
|
|
23
|
-
assert.
|
|
53
|
+
assert.equal(fresh.reason, "time gate: last dream is too recent");
|
|
54
|
+
old(stamp);
|
|
55
|
+
assert.equal(timeGate(stamp, 24).ok, true);
|
|
24
56
|
mkdirSync(join(home, "pi/session/one"), { recursive: true });
|
|
25
57
|
writeFileSync(join(home, "pi/session/one/a.md"), "a");
|
|
26
|
-
const material = materialGate(home, statSync(
|
|
58
|
+
const material = materialGate(home, statSync(stamp).mtimeMs, 1);
|
|
27
59
|
assert.equal(material.ok, true);
|
|
28
60
|
assert.match(material.reason, /material gate: 1 changed sessions/);
|
|
29
61
|
assert.equal(materialGate(home, Date.now(), 2).ok, false);
|
|
62
|
+
// The lock file's own mtime no longer carries the scheduler timestamp.
|
|
63
|
+
writeFileSync(lock, "1");
|
|
64
|
+
old(lock);
|
|
65
|
+
assert.equal(timeGate(stamp, 24).ok, true, "the sidecar survives an old lock file");
|
|
30
66
|
});
|
|
31
|
-
test("
|
|
67
|
+
test("failure restores the last-run timestamp and cleanup is idempotent", () => {
|
|
32
68
|
const home = fixture();
|
|
33
69
|
const lock = join(home, ".dream.lock");
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
assert.
|
|
70
|
+
const stamp = lastRunPath(lock);
|
|
71
|
+
writeFileSync(stamp, new Date(Date.now() - 48 * 3600_000).toISOString());
|
|
72
|
+
old(stamp);
|
|
73
|
+
const prior = statSync(stamp).mtimeMs;
|
|
74
|
+
const held = acquireLock(lock);
|
|
75
|
+
assert.equal(held.held, true);
|
|
76
|
+
assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${held.token}`, "the lock records its owner and token");
|
|
77
|
+
assert.ok(statSync(stamp).mtimeMs > prior + 1000, "a held lock advances the last-run sidecar, not itself");
|
|
78
|
+
failLock(held);
|
|
79
|
+
assert.equal(held.held, false, "failure marks the state released");
|
|
80
|
+
assert.ok(Math.abs(statSync(stamp).mtimeMs - prior) < 2000, "a failed run restores the last-run timestamp");
|
|
81
|
+
assert.equal(existsSync(lock), false, "a failed run releases its lock");
|
|
82
|
+
// Repeated cleanup on an already-released state is harmless.
|
|
83
|
+
const settled = statSync(stamp).mtimeMs;
|
|
84
|
+
failLock(held);
|
|
85
|
+
releaseLock(held);
|
|
86
|
+
assert.ok(Math.abs(statSync(stamp).mtimeMs - settled) < 1000, "repeated failure cleanup changes nothing");
|
|
87
|
+
const released = acquireLock(lock);
|
|
88
|
+
assert.equal(released.held, true);
|
|
89
|
+
releaseLock(released);
|
|
90
|
+
releaseLock(released);
|
|
91
|
+
assert.equal(released.held, false, "release marks the state released");
|
|
92
|
+
assert.equal(existsSync(lock), false, "release removes the PID marker");
|
|
93
|
+
assert.equal(existsSync(stamp), true, "the timestamp sidecar survives release");
|
|
94
|
+
});
|
|
95
|
+
test("any existing lock refuses acquisition and is left byte-identical", () => {
|
|
96
|
+
const home = fixture();
|
|
97
|
+
const lock = join(home, ".dream.lock");
|
|
98
|
+
const stamp = lastRunPath(lock);
|
|
99
|
+
const markers = [
|
|
100
|
+
`${process.pid} live-owner`, // a live owner's marker
|
|
101
|
+
"999999", // a dead PID
|
|
102
|
+
"999999 dead-token", // a dead PID with a token
|
|
103
|
+
"", // empty
|
|
104
|
+
"not-a-pid", // malformed
|
|
105
|
+
];
|
|
106
|
+
for (const marker of markers) {
|
|
107
|
+
writeFileSync(lock, marker);
|
|
108
|
+
old(lock);
|
|
109
|
+
const result = acquireLock(lock);
|
|
110
|
+
assert.equal(result.held, false, `refuses an existing marker ${JSON.stringify(marker)}`);
|
|
111
|
+
assert.equal(result.reason, "lock gate: lock already exists");
|
|
112
|
+
assert.equal(readFileSync(lock, "utf8"), marker, "the existing marker is byte-identical");
|
|
113
|
+
}
|
|
114
|
+
assert.equal(existsSync(stamp), false, "a refused acquisition writes no timestamp");
|
|
115
|
+
});
|
|
116
|
+
test("an acquired lock blocks every later contender until released", () => {
|
|
117
|
+
const home = fixture();
|
|
118
|
+
const lock = join(home, ".dream.lock");
|
|
119
|
+
const first = acquireLock(lock);
|
|
120
|
+
assert.equal(first.held, true);
|
|
121
|
+
const second = acquireLock(lock);
|
|
122
|
+
assert.equal(second.held, false);
|
|
123
|
+
assert.equal(second.reason, "lock gate: lock already exists");
|
|
124
|
+
assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${first.token}`, "the holder's marker is untouched");
|
|
125
|
+
});
|
|
126
|
+
test("cleanup never removes a successor's lock, even repeated", () => {
|
|
127
|
+
const home = fixture();
|
|
128
|
+
const lock = join(home, ".dream.lock");
|
|
129
|
+
const stamp = lastRunPath(lock);
|
|
130
|
+
const mine = acquireLock(lock);
|
|
131
|
+
assert.equal(mine.held, true);
|
|
132
|
+
// A successor acquires after ours is externally gone (human/supported protocol).
|
|
133
|
+
unlinkSync(lock);
|
|
134
|
+
const successor = acquireLock(lock);
|
|
135
|
+
assert.equal(successor.held, true);
|
|
136
|
+
const successorStamp = Date.now() - 5000;
|
|
137
|
+
utimesSync(stamp, new Date(), new Date(successorStamp));
|
|
138
|
+
failLock(mine);
|
|
139
|
+
releaseLock(mine);
|
|
140
|
+
assert.equal(mine.held, false, "the old state is marked released by the first cleanup");
|
|
141
|
+
assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${successor.token}`, "the successor's lock survives");
|
|
142
|
+
assert.ok(Math.abs(statSync(stamp).mtimeMs - successorStamp) < 1000, "the successor's timestamp survives");
|
|
143
|
+
});
|
|
144
|
+
/** Each child makes one actual acquisition attempt; a holder stays alive so later attempts see it. */
|
|
145
|
+
async function acquireRaceOutcomes(lock, contenders) {
|
|
146
|
+
const moduleUrl = new URL("../src/dream/lock.js", import.meta.url).href;
|
|
147
|
+
const script = [
|
|
148
|
+
`import { acquireLock, releaseLock } from ${JSON.stringify(moduleUrl)};`,
|
|
149
|
+
`const result = acquireLock(${JSON.stringify(lock)});`,
|
|
150
|
+
`if (result.held) await new Promise((resolve) => setTimeout(resolve, 1200));`,
|
|
151
|
+
`process.stdout.write(result.held ? "held" : "lost");`,
|
|
152
|
+
`if (result.held) releaseLock(result);`,
|
|
153
|
+
].join("\n");
|
|
154
|
+
const contender = () => new Promise((resolve, reject) => {
|
|
155
|
+
execFile(process.execPath, ["--input-type=module", "-e", script], (error, stdout) => error ? reject(error) : resolve(stdout.trim()));
|
|
156
|
+
});
|
|
157
|
+
return Promise.all(Array.from({ length: contenders }, contender));
|
|
158
|
+
}
|
|
159
|
+
test("concurrent acquisitions of an absent lock yield exactly one held:true", async () => {
|
|
160
|
+
const home = fixture();
|
|
161
|
+
const lock = join(home, ".dream.lock");
|
|
162
|
+
assert.equal(existsSync(lock), false, "the lock starts absent");
|
|
163
|
+
const outcomes = await acquireRaceOutcomes(lock, 8);
|
|
164
|
+
assert.equal(outcomes.filter((outcome) => outcome === "held").length, 1, "exactly one actual held:true return");
|
|
46
165
|
});
|
|
47
166
|
test("dreamer write jail accepts home files and refuses escapes", async () => {
|
|
48
167
|
const home = fixture();
|
|
@@ -76,6 +195,9 @@ test("dreamer write jail accepts home files and refuses escapes", async () => {
|
|
|
76
195
|
symlinkSync(insideFile, join(home, "global/inside-link.md"));
|
|
77
196
|
await tools.get("write").execute("write", { path: "global/inside-link.md", content: "updated" }, undefined, undefined, ctx);
|
|
78
197
|
assert.equal(readFileSync(insideFile, "utf8"), "updated", "the jail permits a symlinked file that resolves inside home");
|
|
198
|
+
// The jail has no `.git` special case: an in-home `.git` path is written like any other.
|
|
199
|
+
await tools.get("write").execute("write", { path: ".git/config", content: "not protected" }, undefined, undefined, ctx);
|
|
200
|
+
assert.equal(readFileSync(join(home, ".git/config"), "utf8"), "not protected", "the dream write jail does not protect .git");
|
|
79
201
|
});
|
|
80
202
|
test("dreamer allowlist contains only the file tools and reports their writes", async () => {
|
|
81
203
|
let configured = [];
|
|
@@ -92,6 +214,7 @@ test("dreamer allowlist contains only the file tools and reports their writes",
|
|
|
92
214
|
assert.deepEqual(result.writes, [{ tool: "write", path: "global/a.md" }, { tool: "edit", path: "project/p.md" }]);
|
|
93
215
|
assert.equal(result.report, "done\nagain");
|
|
94
216
|
assert.equal(result.report, contentText([{ type: "text", text: "done" }, { type: "text", text: "again" }]), "dream and history share the text projection");
|
|
217
|
+
assert.equal(result.error, undefined);
|
|
95
218
|
});
|
|
96
219
|
test("dreamer session has exactly the jailed file-tool allowlist", async () => {
|
|
97
220
|
const session = await defaultDreamerSessionFactory({ cwd: fixture(), tools: DREAMER_TOOLS });
|
|
@@ -110,14 +233,14 @@ test("playbook describes plain files and the retained frontmatter", () => {
|
|
|
110
233
|
assert.equal(/^scope:/m.test(playbook), false, "scope is derived from the address rather than persisted");
|
|
111
234
|
assert.match(playbook, /Nothing is physically deleted/);
|
|
112
235
|
});
|
|
113
|
-
test("provider errors
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
handler:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
236
|
+
test("provider errors are reported with partial writes instead of parsing a response", async () => {
|
|
237
|
+
const factory = scriptedSession((handler) => {
|
|
238
|
+
handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
|
|
239
|
+
handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
|
|
240
|
+
});
|
|
241
|
+
const result = await runDreamer("playbook", "/tmp/notes", { sessionFactory: factory });
|
|
242
|
+
assert.match(result.error ?? "", /Insufficient Balance/);
|
|
243
|
+
assert.deepEqual(result.writes, [{ tool: "write", path: "global/partial.md" }]);
|
|
121
244
|
});
|
|
122
245
|
test("default dreamer rejects an unresolvable model pattern", async () => {
|
|
123
246
|
await assert.rejects(() => defaultDreamerSessionFactory({ cwd: "/tmp/notes", modelPattern: "definitely-not-a-real-model", tools: DREAMER_TOOLS }), /definitely-not-a-real-model/);
|
|
@@ -125,18 +248,180 @@ test("default dreamer rejects an unresolvable model pattern", async () => {
|
|
|
125
248
|
test("git audit layer commits baseline and dream, stays silent when clean, keeps file content", () => {
|
|
126
249
|
const home = fixture();
|
|
127
250
|
writeFileSync(join(home, "a.md"), "one");
|
|
128
|
-
gitCommit(home, "baseline t");
|
|
129
|
-
|
|
251
|
+
const baseline = gitCommit(home, "baseline t");
|
|
252
|
+
assert.equal(baseline.ok, true);
|
|
253
|
+
const clean = gitCommit(home, "dream t"); // clean tree — no empty commit
|
|
254
|
+
assert.equal(clean.ok, true);
|
|
255
|
+
assert.equal(clean.empty, true);
|
|
130
256
|
const log1 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
|
|
131
257
|
assert.equal(log1, "baseline t");
|
|
132
258
|
writeFileSync(join(home, "a.md"), "two");
|
|
133
|
-
gitCommit(home, "dream t2");
|
|
259
|
+
const second = gitCommit(home, "dream t2");
|
|
260
|
+
assert.equal(second.ok, true);
|
|
261
|
+
assert.equal(second.empty, false);
|
|
134
262
|
const log2 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
|
|
135
263
|
assert.equal(log2, "dream t2\nbaseline t");
|
|
136
264
|
assert.equal(readFileSync(join(home, "a.md"), "utf8"), "two"); // notes themselves untouched by the layer
|
|
137
265
|
const before = execFileSync("git", ["show", "HEAD~1:a.md"], { cwd: home, encoding: "utf8" }).trim();
|
|
138
266
|
assert.equal(before, "one"); // rollback information actually recorded
|
|
139
267
|
});
|
|
140
|
-
test("git audit layer
|
|
141
|
-
|
|
268
|
+
test("git audit layer gives a newly initialized empty repository a real baseline snapshot", () => {
|
|
269
|
+
const home = fixture();
|
|
270
|
+
const baseline = gitCommit(home, "baseline empty");
|
|
271
|
+
assert.equal(baseline.ok, true);
|
|
272
|
+
if (baseline.ok) {
|
|
273
|
+
assert.notEqual(baseline.commit, "", "the baseline carries a real commit, not an empty marker");
|
|
274
|
+
assert.equal(baseline.empty, true, "no files changed");
|
|
275
|
+
}
|
|
276
|
+
assert.equal(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), "baseline empty");
|
|
277
|
+
});
|
|
278
|
+
test("git audit layer reports its failure instead of swallowing it", () => {
|
|
279
|
+
const missing = gitCommit(join(fixture(), "missing", "home"), "x");
|
|
280
|
+
assert.equal(missing.ok, false);
|
|
281
|
+
const broken = fixture();
|
|
282
|
+
writeFileSync(join(broken, ".git"), "not a git directory");
|
|
283
|
+
const result = gitCommit(broken, "x");
|
|
284
|
+
assert.equal(result.ok, false);
|
|
285
|
+
if (!result.ok)
|
|
286
|
+
assert.ok(result.error.length > 0, "the audit failure names its cause");
|
|
287
|
+
});
|
|
288
|
+
test("CLI: a failed final audit is recorded in the report and in the exit status", async () => {
|
|
289
|
+
const home = fixture();
|
|
290
|
+
const sessionFactory = scriptedSession((_handler, cwd) => {
|
|
291
|
+
mkdirSync(join(cwd, "global"), { recursive: true });
|
|
292
|
+
writeFileSync(join(cwd, "global/ok.md"), "ok");
|
|
293
|
+
// Break the repository after the baseline so only the final audit fails.
|
|
294
|
+
rmSync(join(cwd, ".git"), { recursive: true, force: true });
|
|
295
|
+
writeFileSync(join(cwd, ".git"), "broken");
|
|
296
|
+
});
|
|
297
|
+
const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
|
|
298
|
+
assert.notEqual(code, 0, "a dream with no final snapshot is not a success");
|
|
299
|
+
assert.ok(errors.some((line) => /final audit failed/.test(line)), "the audit failure is named");
|
|
300
|
+
const reports = readdirSync(join(home, "dreams"));
|
|
301
|
+
assert.equal(reports.length, 1);
|
|
302
|
+
assert.match(readFileSync(join(home, "dreams", reports[0]), "utf8"), /Final audit failed/, "the success report records the audit failure");
|
|
303
|
+
});
|
|
304
|
+
test("CLI: a report-write failure does not prevent the final failure audit", async () => {
|
|
305
|
+
const home = fixture();
|
|
306
|
+
writeFileSync(join(home, "dreams"), "not a directory");
|
|
307
|
+
const sessionFactory = scriptedSession((handler, cwd) => {
|
|
308
|
+
mkdirSync(join(cwd, "global"), { recursive: true });
|
|
309
|
+
writeFileSync(join(cwd, "global/partial.md"), "half");
|
|
310
|
+
handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
|
|
311
|
+
handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
|
|
312
|
+
});
|
|
313
|
+
const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
|
|
314
|
+
assert.notEqual(code, 0);
|
|
315
|
+
assert.ok(errors.some((line) => /could not write report/.test(line)), "the report failure is surfaced");
|
|
316
|
+
assert.match(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), /\(failed\)/, "the final failure audit still ran");
|
|
317
|
+
assert.equal(execFileSync("git", ["show", "HEAD:global/partial.md"], { cwd: home, encoding: "utf8" }), "half", "the partial write is still committed");
|
|
318
|
+
});
|
|
319
|
+
test("CLI: an unwritable report is a failure even when the dreamer succeeds", async () => {
|
|
320
|
+
const home = fixture();
|
|
321
|
+
writeFileSync(join(home, "dreams"), "not a directory");
|
|
322
|
+
const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory: successSession(), dreamerSettings: () => ({ warnings: [] }) }));
|
|
323
|
+
assert.notEqual(code, 0, "a missing report is not a success");
|
|
324
|
+
assert.ok(errors.some((line) => /could not write report/.test(line)), "the report failure is surfaced");
|
|
325
|
+
assert.notEqual(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), "", "the final audit still ran");
|
|
326
|
+
});
|
|
327
|
+
test("CLI: a failed baseline audit aborts before the dreamer and preserves the notes bytes", async () => {
|
|
328
|
+
const home = fixture();
|
|
329
|
+
writeFileSync(join(home, "keep.md"), "keep me");
|
|
330
|
+
writeFileSync(join(home, ".git"), "not a git directory"); // every git command fails
|
|
331
|
+
let started = false;
|
|
332
|
+
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], {
|
|
333
|
+
sessionFactory: async () => { started = true; return {}; },
|
|
334
|
+
dreamerSettings: () => ({ warnings: [] }),
|
|
335
|
+
}));
|
|
336
|
+
assert.notEqual(code, 0, "a missing baseline is a failed run");
|
|
337
|
+
assert.equal(started, false, "the dreamer never starts without a baseline");
|
|
338
|
+
assert.equal(readFileSync(join(home, "keep.md"), "utf8"), "keep me", "the notes bytes survive");
|
|
339
|
+
assert.equal(existsSync(join(home, "dreams")), false, "no dream report is written");
|
|
340
|
+
});
|
|
341
|
+
test("CLI: a dreamer failure records the failure and the partial write in a final commit", async () => {
|
|
342
|
+
const home = fixture();
|
|
343
|
+
const sessionFactory = scriptedSession((handler, cwd) => {
|
|
344
|
+
mkdirSync(join(cwd, "global"), { recursive: true });
|
|
345
|
+
writeFileSync(join(cwd, "global/partial.md"), "half");
|
|
346
|
+
handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
|
|
347
|
+
handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
|
|
348
|
+
});
|
|
349
|
+
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
|
|
350
|
+
assert.notEqual(code, 0);
|
|
351
|
+
const reports = readdirSync(join(home, "dreams"));
|
|
352
|
+
assert.equal(reports.length, 1, "one failure report");
|
|
353
|
+
const report = readFileSync(join(home, "dreams", reports[0]), "utf8");
|
|
354
|
+
assert.match(report, /Insufficient Balance/, "the report contains the failure");
|
|
355
|
+
assert.match(report, /global\/partial\.md/, "the report names the partial write");
|
|
356
|
+
const log = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
|
|
357
|
+
assert.match(log, /\(failed\)/, "the partial state is committed as a failure");
|
|
358
|
+
assert.equal(execFileSync("git", ["show", "HEAD:global/partial.md"], { cwd: home, encoding: "utf8" }), "half");
|
|
359
|
+
});
|
|
360
|
+
test("CLI: a successful run commits its report and leaves the audited tree clean", async () => {
|
|
361
|
+
const home = fixture();
|
|
362
|
+
writeFileSync(join(home, "seed.md"), "seed");
|
|
363
|
+
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory: successSession(), dreamerSettings: () => ({ warnings: [] }) }));
|
|
364
|
+
assert.equal(code, 0);
|
|
365
|
+
assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: home, encoding: "utf8" }).trim(), "", "the audited tree is clean after the lock is released");
|
|
366
|
+
const tracked = execFileSync("git", ["ls-files"], { cwd: home, encoding: "utf8" }).trim().split("\n");
|
|
367
|
+
assert.equal(tracked.some((file) => file.startsWith(".dream.lock")), false, "lock artifacts stay out of the snapshot");
|
|
368
|
+
assert.equal(tracked.includes("seed.md"), true);
|
|
369
|
+
});
|
|
370
|
+
test("CLI: --force does not bypass an existing lock", async () => {
|
|
371
|
+
const home = fixture();
|
|
372
|
+
writeFileSync(join(home, ".dream.lock"), "999999 dead-owner");
|
|
373
|
+
let started = false;
|
|
374
|
+
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], {
|
|
375
|
+
sessionFactory: async () => { started = true; return {}; },
|
|
376
|
+
dreamerSettings: () => ({ warnings: [] }),
|
|
377
|
+
}));
|
|
378
|
+
assert.equal(code, 0, "an existing lock skips the run rather than failing");
|
|
379
|
+
assert.equal(started, false, "--force never reaches the dreamer while the lock exists");
|
|
380
|
+
assert.equal(readFileSync(join(home, ".dream.lock"), "utf8"), "999999 dead-owner", "the existing lock is byte-identical");
|
|
381
|
+
assert.equal(existsSync(join(home, "dreams")), false, "no dream report is written");
|
|
382
|
+
});
|
|
383
|
+
test("dreamer setting is a non-empty string; project overrides global; invalid values warn and fall back", () => {
|
|
384
|
+
assert.deepEqual(deriveDreamer({}), { warnings: [] }, "absent setting falls back silently");
|
|
385
|
+
assert.deepEqual(deriveDreamer({ dreamer: "openai/gpt-x" }), { pattern: "openai/gpt-x", warnings: [] });
|
|
386
|
+
const merged = mergePiContextSettings({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "global/model" } }, { [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "project/model" } });
|
|
387
|
+
assert.equal(deriveDreamer(merged).pattern, "project/model", "project wins per key");
|
|
388
|
+
for (const invalid of ["", " ", 42, null]) {
|
|
389
|
+
const result = deriveDreamer({ dreamer: invalid });
|
|
390
|
+
assert.equal(result.pattern, undefined, `invalid ${JSON.stringify(invalid)} is ignored`);
|
|
391
|
+
assert.equal(result.warnings.length, 1, "one warning per invalid value");
|
|
392
|
+
assert.match(result.warnings[0], /dreamer/);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
test("readDreamerSettings reads the project setting over the global one through SettingsManager", () => {
|
|
396
|
+
const cwd = mkdtempSync(join(tmpdir(), "dream-cwd-"));
|
|
397
|
+
const agentDir = mkdtempSync(join(tmpdir(), "dream-agent-"));
|
|
398
|
+
const prior = process.env.PI_CODING_AGENT_DIR;
|
|
399
|
+
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
400
|
+
try {
|
|
401
|
+
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "global/model" } }));
|
|
402
|
+
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
|
403
|
+
writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "project/model" } }));
|
|
404
|
+
assert.equal(readDreamerSettings(cwd).pattern, "project/model");
|
|
405
|
+
writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({}));
|
|
406
|
+
assert.equal(readDreamerSettings(cwd).pattern, "global/model", "global applies when the project does not set the key");
|
|
407
|
+
}
|
|
408
|
+
finally {
|
|
409
|
+
if (prior === undefined)
|
|
410
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
411
|
+
else
|
|
412
|
+
process.env.PI_CODING_AGENT_DIR = prior;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
test("CLI: --dreamer wins over settings, settings win over the automatic fallback", async () => {
|
|
416
|
+
const home = fixture();
|
|
417
|
+
const seen = [];
|
|
418
|
+
const success = successSession();
|
|
419
|
+
const run = (argv, settings) => main(["--notes-home", home, "--force", ...argv], {
|
|
420
|
+
sessionFactory: async (options) => { seen.push(options.modelPattern); return success({ cwd: home, tools: DREAMER_TOOLS }); },
|
|
421
|
+
dreamerSettings: () => settings,
|
|
422
|
+
});
|
|
423
|
+
await captureErrors(() => run([], { pattern: "settings/model", warnings: [] }));
|
|
424
|
+
await captureErrors(() => run(["--dreamer", "cli/model"], { pattern: "settings/model", warnings: [] }));
|
|
425
|
+
await captureErrors(() => run([], { warnings: [] }));
|
|
426
|
+
assert.deepEqual(seen, ["settings/model", "cli/model", undefined]);
|
|
142
427
|
});
|
|
@@ -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 === "
|
|
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 === "
|
|
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: `@
|
|
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("@
|
|
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("
|
|
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), ["@
|
|
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("
|
|
331
|
-
assert.deepEqual((await files({})).filter((file) => file.address.endsWith("a.md")).map((file) => file.scope), ["
|
|
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: "
|
|
339
|
-
// A brand-new session over the same physical root sees the
|
|
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: "
|
|
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, "@
|
|
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, "
|
|
349
|
-
const searched = resultJson(await call(restoredCaptured, "notes_search", { query: "Café", scope: "
|
|
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: "
|
|
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: "
|
|
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>.*@
|
|
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
|
|
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: "@
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1054
|
-
assert.equal(
|
|
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}`));
|