@astrosheep/pi-context 0.24.0 → 0.25.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 +52 -5
- package/dist/build-info.json +4 -0
- package/dist/extension.js +1951 -0
- package/dist/src/context/boot.js +46 -0
- package/dist/src/context/budget.js +150 -0
- package/dist/src/context/context-window.js +112 -0
- package/dist/src/context/prompts.js +91 -0
- package/dist/src/context/reset-artifacts.js +86 -0
- package/dist/src/context/reset-lifecycle.js +182 -0
- package/dist/src/context/runtime.js +151 -0
- package/dist/src/context/thresholds.js +62 -0
- package/dist/src/dream/cli.js +1 -1
- package/dist/src/dream/doctor.js +34 -6
- package/dist/src/dream/runner.js +1 -1
- package/dist/src/dream/settings.js +30 -0
- package/dist/src/{history-tools.js → history/history-tools.js} +3 -3
- package/dist/src/{history.js → history/history.js} +8 -46
- package/dist/src/index.js +27 -94
- package/dist/src/notes/address.js +97 -16
- package/dist/src/notes/frontmatter.js +18 -3
- package/dist/src/notes/notes-snapshot.js +30 -0
- package/dist/src/notes/paths.js +64 -7
- package/dist/src/notes/session-replay.js +41 -0
- package/dist/src/notes/store.js +76 -22
- package/dist/src/notes/tools.js +7 -7
- package/dist/src/protocol.js +9 -9
- package/dist/src/settings.js +16 -0
- package/dist/src/tool-schema.js +1 -1
- package/dist/test/agent-loop.test.js +815 -221
- package/dist/test/boot.integration.test.js +219 -0
- package/dist/test/budget-settings.integration.test.js +126 -0
- package/dist/test/doctor.test.js +14 -36
- package/dist/test/dream.test.js +37 -380
- package/dist/test/helpers/extension.js +392 -0
- package/dist/test/history.integration.test.js +316 -0
- package/dist/test/notes.integration.test.js +270 -0
- package/dist/test/notes.test.js +40 -359
- package/dist/test/reset-lifecycle.test.js +443 -178
- package/docs/architecture.md +35 -18
- package/docs/reset-lifecycle.md +73 -14
- package/package.json +11 -10
- package/src/context/boot.ts +68 -0
- package/src/context/budget.ts +148 -0
- package/src/context/context-window.ts +118 -0
- package/src/context/prompts.ts +108 -0
- package/src/context/reset-artifacts.ts +101 -0
- package/src/context/reset-lifecycle.ts +272 -0
- package/src/context/runtime.ts +151 -0
- package/src/context/thresholds.ts +78 -0
- package/src/dream/cli.ts +1 -1
- package/src/dream/doctor.ts +27 -6
- package/src/dream/runner.ts +1 -1
- package/src/dream/settings.ts +32 -0
- package/src/{history-tools.ts → history/history-tools.ts} +3 -3
- package/src/{history.ts → history/history.ts} +9 -48
- package/src/index.ts +27 -89
- package/src/notes/address.ts +82 -16
- package/src/notes/frontmatter.ts +20 -3
- package/src/notes/notes-snapshot.ts +40 -0
- package/src/notes/paths.ts +64 -7
- package/src/notes/session-replay.ts +53 -0
- package/src/notes/store.ts +78 -25
- package/src/notes/tools.ts +7 -7
- package/src/protocol.ts +9 -10
- package/src/settings.ts +20 -0
- package/src/tool-schema.ts +1 -2
- package/dist/src/budget.js +0 -65
- package/dist/src/notes/model.js +0 -101
- package/dist/src/prompts.js +0 -88
- package/dist/src/reset-lifecycle.js +0 -155
- package/dist/src/thresholds.js +0 -102
- package/dist/src/warning.js +0 -44
- package/dist/test/coherence.test.js +0 -371
- package/dist/test/history.test.js +0 -26
- package/dist/test/integration.test.js +0 -1759
- package/dist/test/pagination.property.test.js +0 -471
- package/src/budget.ts +0 -67
- package/src/notes/model.ts +0 -109
- package/src/prompts.ts +0 -91
- package/src/reset-lifecycle.ts +0 -173
- package/src/thresholds.ts +0 -110
- package/src/warning.ts +0 -46
package/dist/test/dream.test.js
CHANGED
|
@@ -1,21 +1,13 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
1
|
import assert from "node:assert/strict";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import test from "node:test";
|
|
6
5
|
import { tmpdir } from "node:os";
|
|
7
6
|
import { join } from "node:path";
|
|
8
|
-
import { acquireLock,
|
|
9
|
-
import {
|
|
10
|
-
import { defaultDreamerSessionFactory, dreamerWriteToolDefinitions, runDreamer, DREAMER_TOOLS } from "../src/dream/runner.js";
|
|
11
|
-
import { gitCommit } from "../src/dream/git.js";
|
|
7
|
+
import { acquireLock, releaseLock } from "../src/dream/lock.js";
|
|
8
|
+
import { dreamerWriteToolDefinitions } from "../src/dream/runner.js";
|
|
12
9
|
import { main } from "../src/dream/cli.js";
|
|
13
|
-
|
|
14
|
-
import { PI_CONTEXT_DREAMER_KEY, PI_CONTEXT_SETTINGS_KEY } from "../src/protocol.js";
|
|
15
|
-
import { contentText } from "../src/history.js";
|
|
16
|
-
function fixture() { return mkdtempSync(join(tmpdir(), "dream-")); }
|
|
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. */
|
|
10
|
+
const fixture = () => mkdtempSync(join(tmpdir(), "dream-test-"));
|
|
19
11
|
function scriptedSession(run) {
|
|
20
12
|
return async ({ cwd }) => {
|
|
21
13
|
let handler = () => { };
|
|
@@ -27,10 +19,6 @@ function scriptedSession(run) {
|
|
|
27
19
|
return session;
|
|
28
20
|
};
|
|
29
21
|
}
|
|
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
22
|
async function captureErrors(run) {
|
|
35
23
|
const errors = [];
|
|
36
24
|
const original = console.error;
|
|
@@ -42,286 +30,64 @@ async function captureErrors(run) {
|
|
|
42
30
|
console.error = original;
|
|
43
31
|
}
|
|
44
32
|
}
|
|
45
|
-
test("
|
|
46
|
-
const home = fixture();
|
|
47
|
-
const lock = join(home, ".dream.lock");
|
|
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);
|
|
52
|
-
assert.equal(fresh.ok, false);
|
|
53
|
-
assert.equal(fresh.reason, "time gate: last dream is too recent");
|
|
54
|
-
old(stamp);
|
|
55
|
-
assert.equal(timeGate(stamp, 24).ok, true);
|
|
56
|
-
mkdirSync(join(home, "pi/session/one"), { recursive: true });
|
|
57
|
-
writeFileSync(join(home, "pi/session/one/a.md"), "a");
|
|
58
|
-
const material = materialGate(home, statSync(stamp).mtimeMs, 1);
|
|
59
|
-
assert.equal(material.ok, true);
|
|
60
|
-
assert.match(material.reason, /material gate: 1 changed sessions/);
|
|
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");
|
|
66
|
-
});
|
|
67
|
-
test("failure restores the last-run timestamp and cleanup is idempotent", () => {
|
|
33
|
+
test("dream lock refuses existing owners and preserves ownership", () => {
|
|
68
34
|
const home = fixture();
|
|
69
35
|
const lock = join(home, ".dream.lock");
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
36
|
+
writeFileSync(lock, "999999 dead-owner");
|
|
37
|
+
const refused = acquireLock(lock);
|
|
38
|
+
assert.equal(refused.held, false);
|
|
39
|
+
assert.equal(refused.reason, "lock gate: lock already exists");
|
|
40
|
+
assert.equal(readFileSync(lock, "utf8"), "999999 dead-owner");
|
|
41
|
+
rmSync(lock);
|
|
74
42
|
const held = acquireLock(lock);
|
|
75
43
|
assert.equal(held.held, true);
|
|
76
|
-
|
|
77
|
-
assert.
|
|
78
|
-
|
|
79
|
-
|
|
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);
|
|
44
|
+
const contender = acquireLock(lock);
|
|
45
|
+
assert.equal(contender.held, false);
|
|
46
|
+
assert.equal(readFileSync(lock, "utf8").trim(), `${process.pid} ${held.token}`);
|
|
47
|
+
writeFileSync(lock, "123 other-owner");
|
|
85
48
|
releaseLock(held);
|
|
86
|
-
assert.
|
|
49
|
+
assert.equal(readFileSync(lock, "utf8"), "123 other-owner", "release cannot remove a replacement owner");
|
|
50
|
+
const own = acquireLock(lock);
|
|
51
|
+
assert.equal(own.held, false);
|
|
52
|
+
rmSync(lock);
|
|
87
53
|
const released = acquireLock(lock);
|
|
88
54
|
assert.equal(released.held, true);
|
|
89
55
|
releaseLock(released);
|
|
90
|
-
|
|
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");
|
|
56
|
+
assert.equal(existsSync(lock), false, "release removes the lock this run owns");
|
|
165
57
|
});
|
|
166
|
-
test("dreamer
|
|
58
|
+
test("dreamer writes stay inside the notes home, including symlink escapes", async () => {
|
|
167
59
|
const home = fixture();
|
|
60
|
+
const outside = fixture();
|
|
168
61
|
const tools = new Map(dreamerWriteToolDefinitions(home).map((tool) => [tool.name, tool]));
|
|
169
62
|
const ctx = { cwd: home };
|
|
170
63
|
await tools.get("write").execute("write", { path: "global/x.md", content: "one" }, undefined, undefined, ctx);
|
|
171
64
|
assert.equal(readFileSync(join(home, "global/x.md"), "utf8"), "one");
|
|
172
|
-
const
|
|
65
|
+
const rejects = async (tool, path) => {
|
|
173
66
|
const params = tool === "write" ? { path, content: "outside" } : { path, edits: [{ oldText: "one", newText: "outside" }] };
|
|
174
|
-
await assert.rejects(() => tools.get(tool).execute("escape", params, undefined, undefined, ctx),
|
|
67
|
+
await assert.rejects(() => tools.get(tool).execute("escape", params, undefined, undefined, ctx), /dream-test/);
|
|
175
68
|
};
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
await rejectsOutsideHome(tool, "../dream-jail-outside.md");
|
|
179
|
-
}
|
|
180
|
-
const outside = fixture();
|
|
69
|
+
await rejects("write", "../outside.md");
|
|
70
|
+
await rejects("edit", "/tmp/outside.md");
|
|
181
71
|
symlinkSync(outside, join(home, "escape"));
|
|
182
|
-
await
|
|
183
|
-
|
|
184
|
-
assert.equal(existsSync(join(outside, "outside.md")), false, "the jail does not write through an in-home symlink");
|
|
185
|
-
const outsideFile = join(outside, "outside.md");
|
|
186
|
-
writeFileSync(outsideFile, "outside");
|
|
187
|
-
symlinkSync(outsideFile, join(home, "global/outside-link.md"));
|
|
188
|
-
await rejectsOutsideHome("write", "global/outside-link.md");
|
|
189
|
-
assert.equal(readFileSync(outsideFile, "utf8"), "outside", "the jail does not write through a symlinked file outside home");
|
|
190
|
-
linkSync(outsideFile, join(home, "global/hardlink.md"));
|
|
191
|
-
await rejectsOutsideHome("write", "global/hardlink.md");
|
|
192
|
-
assert.equal(readFileSync(outsideFile, "utf8"), "outside", "the jail does not write through a hardlinked file outside home");
|
|
193
|
-
const insideFile = join(home, "global/inside.md");
|
|
194
|
-
writeFileSync(insideFile, "inside");
|
|
195
|
-
symlinkSync(insideFile, join(home, "global/inside-link.md"));
|
|
196
|
-
await tools.get("write").execute("write", { path: "global/inside-link.md", content: "updated" }, undefined, undefined, ctx);
|
|
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");
|
|
201
|
-
});
|
|
202
|
-
test("dreamer allowlist contains only the file tools and reports their writes", async () => {
|
|
203
|
-
let configured = [];
|
|
204
|
-
const session = {
|
|
205
|
-
subscribe(handler) { this.handler = handler; return () => { }; },
|
|
206
|
-
handler: (_event) => { },
|
|
207
|
-
async prompt(_text) { this.handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/a.md", content: "a" } }); this.handler({ type: "tool_execution_start", toolName: "edit", args: { path: "project/p.md", edits: [] } }); this.handler({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "done" }, { type: "text", text: "again" }] } }); },
|
|
208
|
-
dispose() { },
|
|
209
|
-
};
|
|
210
|
-
const result = await runDreamer("playbook", "/tmp/notes", { sessionFactory: async (options) => { configured = options.tools; return session; } });
|
|
211
|
-
assert.deepEqual(configured, DREAMER_TOOLS);
|
|
212
|
-
assert.deepEqual(configured, ["read", "grep", "find", "ls", "write", "edit"]);
|
|
213
|
-
assert.equal(configured.some((tool) => tool.startsWith("notes_")), false);
|
|
214
|
-
assert.deepEqual(result.writes, [{ tool: "write", path: "global/a.md" }, { tool: "edit", path: "project/p.md" }]);
|
|
215
|
-
assert.equal(result.report, "done\nagain");
|
|
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);
|
|
218
|
-
});
|
|
219
|
-
test("dreamer session has exactly the jailed file-tool allowlist", async () => {
|
|
220
|
-
const session = await defaultDreamerSessionFactory({ cwd: fixture(), tools: DREAMER_TOOLS });
|
|
221
|
-
try {
|
|
222
|
-
assert.deepEqual(session.agent.state.tools.map((tool) => tool.name).sort(), [...DREAMER_TOOLS].sort());
|
|
223
|
-
}
|
|
224
|
-
finally {
|
|
225
|
-
session.dispose();
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
test("playbook describes plain files, retained frontmatter, and the read-only session WAL", () => {
|
|
229
|
-
const playbook = readFileSync(join(process.cwd(), "playbook.md"), "utf8");
|
|
230
|
-
assert.equal(playbook.includes("notes_"), false);
|
|
231
|
-
for (const field of ["origin", "status", "stale", "created_at", "updated_at", "last_accessed", "access_count"])
|
|
232
|
-
assert.match(playbook, new RegExp(`^${field}:`, "m"));
|
|
233
|
-
assert.equal(/^scope:/m.test(playbook), false, "scope is derived from the address rather than persisted");
|
|
234
|
-
assert.match(playbook, /`pi\/session\/\*\*` is a live agent's write-ahead log/);
|
|
235
|
-
assert.match(playbook, /never write or edit anything there/);
|
|
236
|
-
assert.match(playbook, /Session notes remain untouched even when promoted/);
|
|
237
|
-
assert.match(playbook, /Nothing is physically deleted/);
|
|
238
|
-
});
|
|
239
|
-
test("CLI finds its package root when the installed file URL contains spaces", () => {
|
|
240
|
-
const install = mkdtempSync(join(tmpdir(), "dream install "));
|
|
241
|
-
const home = fixture();
|
|
242
|
-
try {
|
|
243
|
-
cpSync(join(process.cwd(), "dist/src"), join(install, "dist/src"), { recursive: true });
|
|
244
|
-
symlinkSync(join(process.cwd(), "node_modules"), join(install, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
|
245
|
-
cpSync(join(process.cwd(), "playbook.md"), join(install, "playbook.md"));
|
|
246
|
-
writeFileSync(join(install, "package.json"), JSON.stringify({ type: "module" }));
|
|
247
|
-
const result = spawnSync(process.execPath, [join(install, "dist/src/dream/cli.js"), "--notes-home", home, "--force", "--dreamer", "definitely-not-a-real-model"], { encoding: "utf8" });
|
|
248
|
-
assert.equal(result.status, 1, result.stderr);
|
|
249
|
-
assert.doesNotMatch(result.stderr, /could not locate installed package root/);
|
|
250
|
-
assert.match(result.stderr, /definitely-not-a-real-model/);
|
|
251
|
-
}
|
|
252
|
-
finally {
|
|
253
|
-
rmSync(install, { recursive: true, force: true });
|
|
254
|
-
}
|
|
255
|
-
});
|
|
256
|
-
test("provider errors are reported with partial writes instead of parsing a response", async () => {
|
|
257
|
-
const factory = scriptedSession((handler) => {
|
|
258
|
-
handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
|
|
259
|
-
handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
|
|
260
|
-
});
|
|
261
|
-
const result = await runDreamer("playbook", "/tmp/notes", { sessionFactory: factory });
|
|
262
|
-
assert.match(result.error ?? "", /Insufficient Balance/);
|
|
263
|
-
assert.deepEqual(result.writes, [{ tool: "write", path: "global/partial.md" }]);
|
|
264
|
-
});
|
|
265
|
-
test("default dreamer rejects an unresolvable model pattern", async () => {
|
|
266
|
-
await assert.rejects(() => defaultDreamerSessionFactory({ cwd: "/tmp/notes", modelPattern: "definitely-not-a-real-model", tools: DREAMER_TOOLS }), /definitely-not-a-real-model/);
|
|
267
|
-
});
|
|
268
|
-
test("git audit layer commits baseline and dream, stays silent when clean, keeps file content", () => {
|
|
269
|
-
const home = fixture();
|
|
270
|
-
writeFileSync(join(home, "a.md"), "one");
|
|
271
|
-
const baseline = gitCommit(home, "baseline t");
|
|
272
|
-
assert.equal(baseline.ok, true);
|
|
273
|
-
const clean = gitCommit(home, "dream t"); // clean tree — no empty commit
|
|
274
|
-
assert.equal(clean.ok, true);
|
|
275
|
-
assert.equal(clean.empty, true);
|
|
276
|
-
const log1 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
|
|
277
|
-
assert.equal(log1, "baseline t");
|
|
278
|
-
writeFileSync(join(home, "a.md"), "two");
|
|
279
|
-
const second = gitCommit(home, "dream t2");
|
|
280
|
-
assert.equal(second.ok, true);
|
|
281
|
-
assert.equal(second.empty, false);
|
|
282
|
-
const log2 = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
|
|
283
|
-
assert.equal(log2, "dream t2\nbaseline t");
|
|
284
|
-
assert.equal(readFileSync(join(home, "a.md"), "utf8"), "two"); // notes themselves untouched by the layer
|
|
285
|
-
const before = execFileSync("git", ["show", "HEAD~1:a.md"], { cwd: home, encoding: "utf8" }).trim();
|
|
286
|
-
assert.equal(before, "one"); // rollback information actually recorded
|
|
287
|
-
});
|
|
288
|
-
test("git audit layer gives a newly initialized empty repository a real baseline snapshot", () => {
|
|
289
|
-
const home = fixture();
|
|
290
|
-
const baseline = gitCommit(home, "baseline empty");
|
|
291
|
-
assert.equal(baseline.ok, true);
|
|
292
|
-
if (baseline.ok) {
|
|
293
|
-
assert.notEqual(baseline.commit, "", "the baseline carries a real commit, not an empty marker");
|
|
294
|
-
assert.equal(baseline.empty, true, "no files changed");
|
|
295
|
-
}
|
|
296
|
-
assert.equal(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), "baseline empty");
|
|
297
|
-
});
|
|
298
|
-
test("git audit layer reports its failure instead of swallowing it", () => {
|
|
299
|
-
const missing = gitCommit(join(fixture(), "missing", "home"), "x");
|
|
300
|
-
assert.equal(missing.ok, false);
|
|
301
|
-
const broken = fixture();
|
|
302
|
-
writeFileSync(join(broken, ".git"), "not a git directory");
|
|
303
|
-
const result = gitCommit(broken, "x");
|
|
304
|
-
assert.equal(result.ok, false);
|
|
305
|
-
if (!result.ok)
|
|
306
|
-
assert.ok(result.error.length > 0, "the audit failure names its cause");
|
|
72
|
+
await rejects("write", "escape/outside.md");
|
|
73
|
+
assert.equal(existsSync(join(outside, "outside.md")), false);
|
|
307
74
|
});
|
|
308
|
-
test("CLI
|
|
75
|
+
test("CLI records a final audit failure and preserves the report", async () => {
|
|
309
76
|
const home = fixture();
|
|
310
77
|
const sessionFactory = scriptedSession((_handler, cwd) => {
|
|
311
78
|
mkdirSync(join(cwd, "global"), { recursive: true });
|
|
312
79
|
writeFileSync(join(cwd, "global/ok.md"), "ok");
|
|
313
|
-
// Break the repository after the baseline so only the final audit fails.
|
|
314
80
|
rmSync(join(cwd, ".git"), { recursive: true, force: true });
|
|
315
81
|
writeFileSync(join(cwd, ".git"), "broken");
|
|
316
82
|
});
|
|
317
83
|
const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
|
|
318
|
-
assert.notEqual(code, 0
|
|
319
|
-
assert.ok(errors.some((line) => /final audit failed/.test(line))
|
|
84
|
+
assert.notEqual(code, 0);
|
|
85
|
+
assert.ok(errors.some((line) => /final audit failed/.test(line)));
|
|
320
86
|
const reports = readdirSync(join(home, "dreams"));
|
|
321
87
|
assert.equal(reports.length, 1);
|
|
322
|
-
assert.match(readFileSync(join(home, "dreams", reports[0]), "utf8"), /Final audit failed
|
|
88
|
+
assert.match(readFileSync(join(home, "dreams", reports[0]), "utf8"), /Final audit failed/);
|
|
323
89
|
});
|
|
324
|
-
test("CLI
|
|
90
|
+
test("CLI commits partial writes when report writing fails", async () => {
|
|
325
91
|
const home = fixture();
|
|
326
92
|
writeFileSync(join(home, "dreams"), "not a directory");
|
|
327
93
|
const sessionFactory = scriptedSession((handler, cwd) => {
|
|
@@ -332,116 +98,7 @@ test("CLI: a report-write failure does not prevent the final failure audit", asy
|
|
|
332
98
|
});
|
|
333
99
|
const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
|
|
334
100
|
assert.notEqual(code, 0);
|
|
335
|
-
assert.ok(errors.some((line) => /could not write report/.test(line))
|
|
336
|
-
assert.match(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), /\(failed\)
|
|
337
|
-
assert.equal(execFileSync("git", ["show", "HEAD:global/partial.md"], { cwd: home, encoding: "utf8" }), "half", "the partial write is still committed");
|
|
338
|
-
});
|
|
339
|
-
test("CLI: an unwritable report is a failure even when the dreamer succeeds", async () => {
|
|
340
|
-
const home = fixture();
|
|
341
|
-
writeFileSync(join(home, "dreams"), "not a directory");
|
|
342
|
-
const { code, errors } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory: successSession(), dreamerSettings: () => ({ warnings: [] }) }));
|
|
343
|
-
assert.notEqual(code, 0, "a missing report is not a success");
|
|
344
|
-
assert.ok(errors.some((line) => /could not write report/.test(line)), "the report failure is surfaced");
|
|
345
|
-
assert.notEqual(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), "", "the final audit still ran");
|
|
346
|
-
});
|
|
347
|
-
test("CLI: a failed baseline audit aborts before the dreamer and preserves the notes bytes", async () => {
|
|
348
|
-
const home = fixture();
|
|
349
|
-
writeFileSync(join(home, "keep.md"), "keep me");
|
|
350
|
-
writeFileSync(join(home, ".git"), "not a git directory"); // every git command fails
|
|
351
|
-
let started = false;
|
|
352
|
-
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], {
|
|
353
|
-
sessionFactory: async () => { started = true; return {}; },
|
|
354
|
-
dreamerSettings: () => ({ warnings: [] }),
|
|
355
|
-
}));
|
|
356
|
-
assert.notEqual(code, 0, "a missing baseline is a failed run");
|
|
357
|
-
assert.equal(started, false, "the dreamer never starts without a baseline");
|
|
358
|
-
assert.equal(readFileSync(join(home, "keep.md"), "utf8"), "keep me", "the notes bytes survive");
|
|
359
|
-
assert.equal(existsSync(join(home, "dreams")), false, "no dream report is written");
|
|
360
|
-
});
|
|
361
|
-
test("CLI: a dreamer failure records the failure and the partial write in a final commit", async () => {
|
|
362
|
-
const home = fixture();
|
|
363
|
-
const sessionFactory = scriptedSession((handler, cwd) => {
|
|
364
|
-
mkdirSync(join(cwd, "global"), { recursive: true });
|
|
365
|
-
writeFileSync(join(cwd, "global/partial.md"), "half");
|
|
366
|
-
handler({ type: "tool_execution_start", toolName: "write", args: { path: "global/partial.md", content: "half" } });
|
|
367
|
-
handler({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "Insufficient Balance" } });
|
|
368
|
-
});
|
|
369
|
-
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory, dreamerSettings: () => ({ warnings: [] }) }));
|
|
370
|
-
assert.notEqual(code, 0);
|
|
371
|
-
const reports = readdirSync(join(home, "dreams"));
|
|
372
|
-
assert.equal(reports.length, 1, "one failure report");
|
|
373
|
-
const report = readFileSync(join(home, "dreams", reports[0]), "utf8");
|
|
374
|
-
assert.match(report, /Insufficient Balance/, "the report contains the failure");
|
|
375
|
-
assert.match(report, /global\/partial\.md/, "the report names the partial write");
|
|
376
|
-
const log = execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim();
|
|
377
|
-
assert.match(log, /\(failed\)/, "the partial state is committed as a failure");
|
|
101
|
+
assert.ok(errors.some((line) => /could not write report/.test(line)));
|
|
102
|
+
assert.match(execFileSync("git", ["log", "--format=%s"], { cwd: home, encoding: "utf8" }).trim(), /\(failed\)/);
|
|
378
103
|
assert.equal(execFileSync("git", ["show", "HEAD:global/partial.md"], { cwd: home, encoding: "utf8" }), "half");
|
|
379
104
|
});
|
|
380
|
-
test("CLI: a successful run commits its report and leaves the audited tree clean", async () => {
|
|
381
|
-
const home = fixture();
|
|
382
|
-
writeFileSync(join(home, "seed.md"), "seed");
|
|
383
|
-
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], { sessionFactory: successSession(), dreamerSettings: () => ({ warnings: [] }) }));
|
|
384
|
-
assert.equal(code, 0);
|
|
385
|
-
assert.equal(execFileSync("git", ["status", "--porcelain"], { cwd: home, encoding: "utf8" }).trim(), "", "the audited tree is clean after the lock is released");
|
|
386
|
-
const tracked = execFileSync("git", ["ls-files"], { cwd: home, encoding: "utf8" }).trim().split("\n");
|
|
387
|
-
assert.equal(tracked.some((file) => file.startsWith(".dream.lock")), false, "lock artifacts stay out of the snapshot");
|
|
388
|
-
assert.equal(tracked.includes("seed.md"), true);
|
|
389
|
-
});
|
|
390
|
-
test("CLI: --force does not bypass an existing lock", async () => {
|
|
391
|
-
const home = fixture();
|
|
392
|
-
writeFileSync(join(home, ".dream.lock"), "999999 dead-owner");
|
|
393
|
-
let started = false;
|
|
394
|
-
const { code } = await captureErrors(() => main(["--notes-home", home, "--force"], {
|
|
395
|
-
sessionFactory: async () => { started = true; return {}; },
|
|
396
|
-
dreamerSettings: () => ({ warnings: [] }),
|
|
397
|
-
}));
|
|
398
|
-
assert.equal(code, 0, "an existing lock skips the run rather than failing");
|
|
399
|
-
assert.equal(started, false, "--force never reaches the dreamer while the lock exists");
|
|
400
|
-
assert.equal(readFileSync(join(home, ".dream.lock"), "utf8"), "999999 dead-owner", "the existing lock is byte-identical");
|
|
401
|
-
assert.equal(existsSync(join(home, "dreams")), false, "no dream report is written");
|
|
402
|
-
});
|
|
403
|
-
test("dreamer setting is a non-empty string; project overrides global; invalid values warn and fall back", () => {
|
|
404
|
-
assert.deepEqual(deriveDreamer({}), { warnings: [] }, "absent setting falls back silently");
|
|
405
|
-
assert.deepEqual(deriveDreamer({ dreamer: "openai/gpt-x" }), { pattern: "openai/gpt-x", warnings: [] });
|
|
406
|
-
const merged = mergePiContextSettings({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "global/model" } }, { [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "project/model" } });
|
|
407
|
-
assert.equal(deriveDreamer(merged).pattern, "project/model", "project wins per key");
|
|
408
|
-
for (const invalid of ["", " ", 42, null]) {
|
|
409
|
-
const result = deriveDreamer({ dreamer: invalid });
|
|
410
|
-
assert.equal(result.pattern, undefined, `invalid ${JSON.stringify(invalid)} is ignored`);
|
|
411
|
-
assert.equal(result.warnings.length, 1, "one warning per invalid value");
|
|
412
|
-
assert.match(result.warnings[0], /dreamer/);
|
|
413
|
-
}
|
|
414
|
-
});
|
|
415
|
-
test("readDreamerSettings reads the project setting over the global one through SettingsManager", () => {
|
|
416
|
-
const cwd = mkdtempSync(join(tmpdir(), "dream-cwd-"));
|
|
417
|
-
const agentDir = mkdtempSync(join(tmpdir(), "dream-agent-"));
|
|
418
|
-
const prior = process.env.PI_CODING_AGENT_DIR;
|
|
419
|
-
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
420
|
-
try {
|
|
421
|
-
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "global/model" } }));
|
|
422
|
-
mkdirSync(join(cwd, ".pi"), { recursive: true });
|
|
423
|
-
writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ [PI_CONTEXT_SETTINGS_KEY]: { [PI_CONTEXT_DREAMER_KEY]: "project/model" } }));
|
|
424
|
-
assert.equal(readDreamerSettings(cwd).pattern, "project/model");
|
|
425
|
-
writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({}));
|
|
426
|
-
assert.equal(readDreamerSettings(cwd).pattern, "global/model", "global applies when the project does not set the key");
|
|
427
|
-
}
|
|
428
|
-
finally {
|
|
429
|
-
if (prior === undefined)
|
|
430
|
-
delete process.env.PI_CODING_AGENT_DIR;
|
|
431
|
-
else
|
|
432
|
-
process.env.PI_CODING_AGENT_DIR = prior;
|
|
433
|
-
}
|
|
434
|
-
});
|
|
435
|
-
test("CLI: --dreamer wins over settings, settings win over the automatic fallback", async () => {
|
|
436
|
-
const home = fixture();
|
|
437
|
-
const seen = [];
|
|
438
|
-
const success = successSession();
|
|
439
|
-
const run = (argv, settings) => main(["--notes-home", home, "--force", ...argv], {
|
|
440
|
-
sessionFactory: async (options) => { seen.push(options.modelPattern); return success({ cwd: home, tools: DREAMER_TOOLS }); },
|
|
441
|
-
dreamerSettings: () => settings,
|
|
442
|
-
});
|
|
443
|
-
await captureErrors(() => run([], { pattern: "settings/model", warnings: [] }));
|
|
444
|
-
await captureErrors(() => run(["--dreamer", "cli/model"], { pattern: "settings/model", warnings: [] }));
|
|
445
|
-
await captureErrors(() => run([], { warnings: [] }));
|
|
446
|
-
assert.deepEqual(seen, ["settings/model", "cli/model", undefined]);
|
|
447
|
-
});
|