@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/README.md
CHANGED
|
@@ -20,7 +20,7 @@ pi -e npm:@astrosheep/pi-context
|
|
|
20
20
|
- **A boot block at every window head** — static once-per-window content (cache-stable) carrying the window identity, the recent-notes index, and a short protocol that teaches the model how to recover: notes for its own bookkeeping, history tools for everything before the reset.
|
|
21
21
|
- **Low-budget guidance** — one persisted early warning per window when the estimated remaining budget crosses the reminder line, so the model checkpoints before the lights go out.
|
|
22
22
|
- **`get_context_remaining`** — the live, reserve-adjusted estimate of the context budget left before Pi's compaction reserve.
|
|
23
|
-
- **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace; notes are real markdown files under `~/.agents/notes` (`
|
|
23
|
+
- **Nine history/notes tools** — Codex's History/Notes actions flattened into Pi's single tool namespace; notes are real markdown files under `~/.agents/notes` (`personal/`, `project/`, `pi/session/`):
|
|
24
24
|
|
|
25
25
|
| Codex action | Pi tool |
|
|
26
26
|
| --- | --- |
|
|
@@ -51,6 +51,31 @@ The reminder threshold is Pi's compaction reserve plus a margin, configured unde
|
|
|
51
51
|
|
|
52
52
|
`reminder = reserveTokens + reminderMarginTokens`; with the defaults the early warning fires 24,576 tokens above Pi's reset line.
|
|
53
53
|
|
|
54
|
+
The dreamer model is configured under the same key. `--dreamer <model pattern>` on the `dream` CLI wins; otherwise a non-empty `pi-context.dreamer` string from settings applies; otherwise the automatic model is used. An invalid value (empty or not a string) is ignored with one warning.
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"pi-context": { "reminderMarginTokens": 24576, "dreamer": "anthropic/claude-sonnet-4-5" }
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Check the notes store
|
|
63
|
+
|
|
64
|
+
Run `dream doctor` (or `dream doctor --notes-home <dir>`) to check home layout, note frontmatter, concrete backtick-quoted note addresses, MAP entries, and lock presence/format. It is read-only: no model, git commits, directory creation, or repairs. Exit status is 0 when clean and 1 when issues are found. References needing an unavailable project context are reported as unresolved; prose and example/glob addresses are not validated. A present lock is reported without inferring process liveness.
|
|
65
|
+
|
|
66
|
+
## The dream lock
|
|
67
|
+
|
|
68
|
+
The `dream` CLI takes an exclusive `.dream.lock` in the notes home with a single O_CREAT|O_EXCL creation. The lock is Git-style existence locking: an existing lock refuses a new run regardless of its contents, PID, or age, and `--force` bypasses only the scheduling and material gates, never the lock. A lock is released only by the run that acquired it (and repeated cleanup is harmless), so a live dream is never displaced.
|
|
69
|
+
|
|
70
|
+
If a dream process crashed, its lock remains and later runs refuse to start. There is no automatic recovery and no force-unlock command: after you have confirmed that no dream process is running, remove the stale lock by hand.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
# only when no dream is running
|
|
74
|
+
rm "${PI_NOTES_HOME:-$HOME/.agents/notes}/.dream.lock"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Removing a lock while a holder is running is outside the supported cooperative protocol and can let two dreams run at once.
|
|
78
|
+
|
|
54
79
|
## Documentation
|
|
55
80
|
|
|
56
81
|
Implementation architecture and the reset lifecycle live in [docs/](docs/).
|
package/dist/src/dream/cli.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
|
|
5
6
|
import { materialGate, timeGate } from "./gates.js";
|
|
6
7
|
import { loadPlaybook, runDreamer } from "./runner.js";
|
|
7
8
|
import { gitCommit } from "./git.js";
|
|
9
|
+
import { readDreamerSettings } from "../thresholds.js";
|
|
10
|
+
import { doctor } from "./doctor.js";
|
|
8
11
|
import { notesRoot } from "../notes/paths.js";
|
|
9
12
|
function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
|
|
10
13
|
const a = argv[i];
|
|
@@ -24,23 +27,76 @@ function packageRoot() {
|
|
|
24
27
|
dir = parent;
|
|
25
28
|
}
|
|
26
29
|
}
|
|
27
|
-
|
|
30
|
+
function writeList(writes) {
|
|
31
|
+
return writes.length ? writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
|
|
32
|
+
}
|
|
33
|
+
/** Best-effort text write; returns the failure message instead of throwing. */
|
|
34
|
+
function writeText(path, content) {
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
37
|
+
writeFileSync(path, content);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
return error instanceof Error ? error.message : String(error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function appendText(path, content) {
|
|
45
|
+
try {
|
|
46
|
+
appendFileSync(path, content);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return error instanceof Error ? error.message : String(error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Close one dream: record the report, then run the final audit commit. The audit always
|
|
55
|
+
* runs even when the report cannot be written, and a failed audit is appended to the
|
|
56
|
+
* report (when it exists) as well as named on stderr, so neither failure hides the other.
|
|
57
|
+
*/
|
|
58
|
+
function finishDream(home, stamp, reportPath, failed, body, writes) {
|
|
59
|
+
const header = failed ? `# Dream ${stamp} (failed)` : `# Dream ${stamp}`;
|
|
60
|
+
let reportError = writeText(reportPath, `${header}\n\n${body}\n\n${writeList(writes)}\n`);
|
|
61
|
+
const audit = gitCommit(home, `dream ${stamp}${failed ? " (failed)" : ""}`);
|
|
62
|
+
if (!audit.ok) {
|
|
63
|
+
console.error(`dream: final audit failed: ${audit.error}`);
|
|
64
|
+
reportError ??= appendText(reportPath, `\n## Final audit failed\n\n${audit.error}\n`);
|
|
65
|
+
}
|
|
66
|
+
if (reportError)
|
|
67
|
+
console.error(`dream: could not write report at ${reportPath}: ${reportError}`);
|
|
68
|
+
return failed || !audit.ok || reportError !== undefined ? 1 : 0;
|
|
69
|
+
}
|
|
70
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
71
|
+
if (argv[0] === "doctor") {
|
|
72
|
+
const options = args(argv.slice(1));
|
|
73
|
+
if (options.help) {
|
|
74
|
+
console.log("dream doctor [--notes-home <dir>] — read-only diagnostics; no model or repairs");
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
77
|
+
const home = resolve(String(options["notes-home"] ?? notesRoot()));
|
|
78
|
+
const issues = doctor(home);
|
|
79
|
+
console.log(issues.length ? issues.join("\n") : `dream doctor: OK (${home})`);
|
|
80
|
+
return issues.length ? 1 : 0;
|
|
81
|
+
}
|
|
28
82
|
const a = args(argv);
|
|
29
83
|
if (a.help) {
|
|
30
|
-
console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\
|
|
84
|
+
console.log("dream doctor [--notes-home <dir>] — read-only diagnostics\ndream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\nDreamer model: --dreamer wins, else pi-context.dreamer from settings, else the automatic model. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
|
|
31
85
|
return 0;
|
|
32
86
|
}
|
|
33
87
|
const home = resolve(String(a["notes-home"] ?? notesRoot()));
|
|
34
88
|
process.env.PI_NOTES_HOME = home;
|
|
35
89
|
mkdirSync(home, { recursive: true });
|
|
36
90
|
const lockPath = join(home, ".dream.lock");
|
|
91
|
+
const stampPath = lastRunPath(lockPath);
|
|
37
92
|
const minHours = Number(a["min-hours"] ?? 24);
|
|
38
93
|
const minSessions = Number(a["min-sessions"] ?? 3);
|
|
39
|
-
const time = timeGate(
|
|
94
|
+
const time = timeGate(stampPath, minHours);
|
|
40
95
|
console.log(time.reason);
|
|
41
96
|
if (!a.force && !time.ok)
|
|
42
97
|
return 0;
|
|
43
|
-
const
|
|
98
|
+
const since = existsSync(stampPath) ? statSync(stampPath).mtimeMs : 0;
|
|
99
|
+
const material = materialGate(home, since, minSessions);
|
|
44
100
|
console.log(material.reason);
|
|
45
101
|
if (!a.force && !material.ok)
|
|
46
102
|
return 0;
|
|
@@ -58,26 +114,65 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
58
114
|
}
|
|
59
115
|
const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-");
|
|
60
116
|
const reportPath = join(home, "dreams", `${stamp}.md`);
|
|
61
|
-
|
|
117
|
+
let succeeded = false;
|
|
62
118
|
try {
|
|
119
|
+
// CLI --dreamer wins over settings; settings win over the automatic model fallback.
|
|
120
|
+
let modelPattern;
|
|
121
|
+
if (a.dreamer)
|
|
122
|
+
modelPattern = String(a.dreamer);
|
|
123
|
+
else {
|
|
124
|
+
const settings = (deps.dreamerSettings ?? readDreamerSettings)();
|
|
125
|
+
for (const warning of settings.warnings)
|
|
126
|
+
console.error(warning);
|
|
127
|
+
modelPattern = settings.pattern;
|
|
128
|
+
}
|
|
129
|
+
// The baseline snapshot is required: without it the human gate has nothing to inspect.
|
|
130
|
+
const baseline = gitCommit(home, `baseline ${stamp}`);
|
|
131
|
+
if (!baseline.ok) {
|
|
132
|
+
console.error(`dream: baseline audit failed: ${baseline.error}`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
63
135
|
const defaultBook = join(packageRoot(), "playbook.md");
|
|
64
136
|
const playbookPath = String(a.playbook ?? defaultBook);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
137
|
+
let result;
|
|
138
|
+
try {
|
|
139
|
+
const playbook = loadPlaybook(playbookPath);
|
|
140
|
+
result = await (deps.runDreamer ?? runDreamer)(playbook, home, { modelPattern, sessionFactory: deps.sessionFactory });
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
144
|
+
console.error(message);
|
|
145
|
+
return finishDream(home, stamp, reportPath, true, message, []);
|
|
146
|
+
}
|
|
147
|
+
if (result.error) {
|
|
148
|
+
console.error(result.error);
|
|
149
|
+
return finishDream(home, stamp, reportPath, true, result.error, result.writes);
|
|
150
|
+
}
|
|
151
|
+
const code = finishDream(home, stamp, reportPath, false, result.report, result.writes);
|
|
152
|
+
if (code === 0) {
|
|
153
|
+
succeeded = true;
|
|
154
|
+
console.log(reportPath);
|
|
155
|
+
}
|
|
156
|
+
return code;
|
|
78
157
|
}
|
|
79
158
|
finally {
|
|
80
|
-
|
|
159
|
+
// Only the holder's own lock is released; a successor's lock is never touched.
|
|
160
|
+
if (succeeded)
|
|
161
|
+
releaseLock(lock);
|
|
162
|
+
else
|
|
163
|
+
failLock(lock);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function isEntryPoint() {
|
|
167
|
+
const entry = process.argv[1];
|
|
168
|
+
if (!entry)
|
|
169
|
+
return false;
|
|
170
|
+
try {
|
|
171
|
+
return realpathSync(resolve(entry)) === realpathSync(fileURLToPath(import.meta.url));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return false;
|
|
81
175
|
}
|
|
82
176
|
}
|
|
83
|
-
|
|
177
|
+
if (isEntryPoint())
|
|
178
|
+
main().then((code) => { process.exitCode = code; });
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { basename, join, relative } from "node:path";
|
|
3
|
+
import { assertAddress } from "../notes/address.js";
|
|
4
|
+
/** Read-only diagnostics. Never follows symlinks or acquires/removes a dream lock. */
|
|
5
|
+
export function doctor(home) {
|
|
6
|
+
const issues = [];
|
|
7
|
+
const report = (path, message) => issues.push(`${relative(home, path) || "."}: ${message}`);
|
|
8
|
+
const inspect = (path, action) => {
|
|
9
|
+
try {
|
|
10
|
+
action();
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
report(path, `cannot inspect: ${error instanceof Error ? error.message : String(error)}`);
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const directory = (path) => {
|
|
17
|
+
const stat = lstatSync(path);
|
|
18
|
+
if (stat.isDirectory())
|
|
19
|
+
return true;
|
|
20
|
+
report(path, "expected a directory (symlinks are not followed); check its location/type");
|
|
21
|
+
return false;
|
|
22
|
+
};
|
|
23
|
+
const checkNote = (path, root, project) => {
|
|
24
|
+
const raw = readFileSync(path, "utf8");
|
|
25
|
+
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
|
|
26
|
+
if (!match) {
|
|
27
|
+
report(path, "missing or unclosed frontmatter; add a valid metadata block");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const fields = new Map();
|
|
31
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
32
|
+
const field = /^([\w]+):\s*(.*?)\s*$/.exec(line);
|
|
33
|
+
if (!field)
|
|
34
|
+
continue;
|
|
35
|
+
if (fields.has(field[1]))
|
|
36
|
+
report(path, `duplicate metadata key ${field[1]}; keep one value`);
|
|
37
|
+
fields.set(field[1], field[2].replace(/^(["'])(.*)\1$/, "$2"));
|
|
38
|
+
}
|
|
39
|
+
for (const [key, valid] of Object.entries({ origin: /^(user|self|external)$/, status: /^(active|superseded|pending|archived)$/, stale: /^(true|false)$/, access_count: /^\d+$/ })) {
|
|
40
|
+
if (!valid.test(fields.get(key) ?? ""))
|
|
41
|
+
report(path, `missing/invalid ${key}; repair frontmatter`);
|
|
42
|
+
}
|
|
43
|
+
for (const key of ["created_at", "updated_at", "last_accessed"]) {
|
|
44
|
+
const value = fields.get(key);
|
|
45
|
+
if (!value || !Number.isFinite(Date.parse(value)))
|
|
46
|
+
report(path, `missing/invalid ${key}; use an ISO timestamp`);
|
|
47
|
+
}
|
|
48
|
+
if (fields.has("scope"))
|
|
49
|
+
report(path, "obsolete scope field; remove it (home determines scope)");
|
|
50
|
+
// Check concrete, code-formatted addresses; examples/globs and prose are not links.
|
|
51
|
+
for (const link of raw.slice(match[0].length).matchAll(/`([^`\n]+)`/g)) {
|
|
52
|
+
const address = link[1];
|
|
53
|
+
if (!address.endsWith(".md") || /[<>*?\s]/.test(address))
|
|
54
|
+
continue;
|
|
55
|
+
if (!address.startsWith("@") && basename(path) !== "MAP.md")
|
|
56
|
+
continue;
|
|
57
|
+
try {
|
|
58
|
+
const parsed = assertAddress(address);
|
|
59
|
+
const targetHome = parsed.scope === "personal" ? join(home, "personal") : parsed.scope === "project" ? project : root;
|
|
60
|
+
if (!targetHome) {
|
|
61
|
+
report(path, `${address}: project context unavailable; use a resolvable reference`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!existsSync(join(targetHome, parsed.path)))
|
|
65
|
+
report(path, `${address}: target missing; update or remove the reference`);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
report(path, `${address}: invalid address; use bare, @project/ or @personal/ addresses`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const walk = (dir, root, project) => {
|
|
73
|
+
for (const name of readdirSync(dir)) {
|
|
74
|
+
const path = join(dir, name);
|
|
75
|
+
inspect(path, () => {
|
|
76
|
+
const stat = lstatSync(path);
|
|
77
|
+
if (stat.isSymbolicLink())
|
|
78
|
+
report(path, "symlink not inspected; replace with a regular note/directory");
|
|
79
|
+
else if (stat.isDirectory())
|
|
80
|
+
walk(path, root, project);
|
|
81
|
+
else if (stat.isFile() && name.endsWith(".md"))
|
|
82
|
+
checkNote(path, root, project);
|
|
83
|
+
else
|
|
84
|
+
report(path, "unexpected file in note home; inspect and relocate it");
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
inspect(home, () => {
|
|
89
|
+
if (!directory(home))
|
|
90
|
+
return;
|
|
91
|
+
for (const name of readdirSync(home)) {
|
|
92
|
+
const path = join(home, name);
|
|
93
|
+
inspect(path, () => {
|
|
94
|
+
if (name === "global") {
|
|
95
|
+
report(path, "legacy home; manually migrate to personal/ without overwriting existing files");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (name === ".dream.lock") {
|
|
99
|
+
const valid = lstatSync(path).isFile() && /^[1-9]\d* [\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}\s*$/i.test(readFileSync(path, "utf8"));
|
|
100
|
+
report(path, `${valid ? "lock present" : "malformed lock"}; verify no dream is running before manual removal; liveness not inferred`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if ([".git", "dreams", "snapshots", "trash", ".dream.lock.last-run"].includes(name))
|
|
104
|
+
return;
|
|
105
|
+
if (name === "personal") {
|
|
106
|
+
if (directory(path))
|
|
107
|
+
walk(path, path);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (name === "project" || name === "pi") {
|
|
111
|
+
if (!directory(path))
|
|
112
|
+
return;
|
|
113
|
+
const homes = name === "pi" ? join(path, "session") : path;
|
|
114
|
+
if (name === "pi") {
|
|
115
|
+
for (const entry of readdirSync(path))
|
|
116
|
+
if (entry !== "session")
|
|
117
|
+
report(join(path, entry), "unexpected directory; expected pi/session/<id>/");
|
|
118
|
+
if (!existsSync(homes) || !directory(homes))
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
for (const id of readdirSync(homes)) {
|
|
122
|
+
const root = join(homes, id);
|
|
123
|
+
inspect(root, () => {
|
|
124
|
+
if (!directory(root))
|
|
125
|
+
return;
|
|
126
|
+
if (name === "project" && !/^.+-[\da-f]{8}$/.test(id))
|
|
127
|
+
report(root, "invalid project key; expected <name>-<8 hex>");
|
|
128
|
+
walk(root, root, name === "project" ? root : undefined);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
report(path, "unexpected root entry; expected personal/, project/, pi/session/ or dream artifacts");
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return issues;
|
|
138
|
+
}
|
package/dist/src/dream/gates.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { sessionHomesRoot } from "../notes/paths.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The scheduler reads the last-run sidecar, not the lock: the lock's lifetime says
|
|
6
|
+
* nothing about when the last dream ran, while the sidecar records exactly that.
|
|
7
|
+
*/
|
|
8
|
+
export function timeGate(stampPath, minHours, now = Date.now()) {
|
|
9
|
+
if (!existsSync(stampPath))
|
|
10
|
+
return { ok: true, reason: "time gate: no prior dream" };
|
|
11
|
+
const age = now - statSync(stampPath).mtimeMs;
|
|
12
|
+
return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: last dream is too recent" };
|
|
9
13
|
}
|
|
10
|
-
export function materialGate(home,
|
|
14
|
+
export function materialGate(home, sinceMtime, minSessions) {
|
|
11
15
|
const root = sessionHomesRoot(home);
|
|
12
16
|
let changed = 0;
|
|
13
17
|
if (existsSync(root))
|
|
@@ -15,7 +19,7 @@ export function materialGate(home, lockMtime, minSessions) {
|
|
|
15
19
|
if (!dir.isDirectory())
|
|
16
20
|
continue;
|
|
17
21
|
const files = readdirSync(join(root, dir.name), { withFileTypes: true });
|
|
18
|
-
if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs >
|
|
22
|
+
if (files.some((f) => f.isFile() && statSync(join(root, dir.name, f.name)).mtimeMs > sinceMtime))
|
|
19
23
|
changed++;
|
|
20
24
|
}
|
|
21
25
|
return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
|
package/dist/src/dream/git.js
CHANGED
|
@@ -1,28 +1,71 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
/** Lock runtime artifacts are not notes and must not appear in snapshots or `git status`. */
|
|
5
|
+
const RUNTIME_IGNORE = ".dream.lock*";
|
|
6
|
+
/** Add the runtime-artifact pattern to the repository's local exclude, once. */
|
|
7
|
+
function ensureRuntimeIgnored(home) {
|
|
8
|
+
try {
|
|
9
|
+
const exclude = join(home, ".git", "info", "exclude");
|
|
10
|
+
const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
|
|
11
|
+
if (current.split(/\r?\n/).includes(RUNTIME_IGNORE))
|
|
12
|
+
return;
|
|
13
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
14
|
+
const prefix = current.length > 0 && !current.endsWith("\n") ? `${current}\n` : current;
|
|
15
|
+
writeFileSync(exclude, `${prefix}${RUNTIME_IGNORE}\n`);
|
|
16
|
+
}
|
|
17
|
+
catch { /* best effort: an unignored lock only adds noise to the audit */ }
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
|
-
* Git audit layer for a dream run: one commit before (baseline) and one after
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
20
|
+
* Git audit layer for a dream run: one commit before (baseline) and one after (dream),
|
|
21
|
+
* so the human gate reviews `git show` instead of trusting a report, and rollback is
|
|
22
|
+
* `git revert`. The caller decides how loud a failure is; this function only reports it.
|
|
23
|
+
* A clean tree on an established repository commits nothing; a repository with no HEAD
|
|
24
|
+
* gets an empty baseline commit, because an audit run with no snapshot is not a success.
|
|
10
25
|
*/
|
|
11
26
|
export function gitCommit(home, message) {
|
|
12
27
|
try {
|
|
13
28
|
if (!existsSync(join(home, ".git"))) {
|
|
14
29
|
execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
|
|
15
30
|
}
|
|
31
|
+
ensureRuntimeIgnored(home);
|
|
16
32
|
execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
|
|
33
|
+
let clean = false;
|
|
17
34
|
try {
|
|
18
35
|
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
|
|
19
|
-
|
|
36
|
+
clean = true; // clean tree — no empty commit on an established repository
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
clean = false;
|
|
40
|
+
}
|
|
41
|
+
const before = headCommit(home);
|
|
42
|
+
if (!clean) {
|
|
43
|
+
execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
|
|
44
|
+
console.log(`git: committed "${message}"`);
|
|
20
45
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
46
|
+
else if (before === undefined) {
|
|
47
|
+
// A newly initialized repository has no snapshot at all; give the audit one.
|
|
48
|
+
execFileSync("git", ["commit", "-q", "--allow-empty", "-m", message], { cwd: home, stdio: "ignore" });
|
|
49
|
+
console.log(`git: committed "${message}"`);
|
|
50
|
+
}
|
|
51
|
+
const commit = headCommit(home);
|
|
52
|
+
if (commit === undefined)
|
|
53
|
+
return { ok: false, error: "audit commit produced no snapshot (no HEAD)" };
|
|
54
|
+
return { ok: true, commit, empty: clean };
|
|
24
55
|
}
|
|
25
56
|
catch (error) {
|
|
26
|
-
|
|
57
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
58
|
+
console.log(`git audit layer failed: ${reason}`);
|
|
59
|
+
return { ok: false, error: reason };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/** HEAD sha, or undefined when the repository has no commit yet. */
|
|
63
|
+
function headCommit(home) {
|
|
64
|
+
try {
|
|
65
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: home, encoding: "utf8" }).trim();
|
|
66
|
+
return head.length > 0 ? head : undefined;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
27
70
|
}
|
|
28
71
|
}
|
package/dist/src/dream/lock.js
CHANGED
|
@@ -1,58 +1,99 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
3
|
+
/**
|
|
4
|
+
* The scheduler's last-run timestamp lives in a sidecar beside the lock, never in the
|
|
5
|
+
* lock file itself: acquiring, releasing or cleaning up the lock touches only the PID
|
|
6
|
+
* marker, so lock lifecycle does not destroy the timestamp the time gate reads.
|
|
7
|
+
*/
|
|
8
|
+
export function lastRunPath(lockPath) {
|
|
9
|
+
return `${lockPath}.last-run`;
|
|
10
|
+
}
|
|
11
|
+
function readText(path) {
|
|
6
12
|
try {
|
|
7
|
-
|
|
8
|
-
return true;
|
|
13
|
+
return readFileSync(path, "utf8");
|
|
9
14
|
}
|
|
10
15
|
catch {
|
|
11
|
-
return
|
|
16
|
+
return undefined;
|
|
12
17
|
}
|
|
13
18
|
}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
let pid = 0;
|
|
21
|
-
try {
|
|
22
|
-
pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
23
|
-
}
|
|
24
|
-
catch { /* reclaim */ }
|
|
25
|
-
if (now - stat.mtimeMs <= HOUR && live(pid))
|
|
26
|
-
return { path, held: false, reason: "lock gate: live process holds the lock", startedAt: now };
|
|
27
|
-
try {
|
|
28
|
-
unlinkSync(path);
|
|
29
|
-
}
|
|
30
|
-
catch {
|
|
31
|
-
return { path, held: false, reason: "lock gate: lock could not be reclaimed", startedAt: now };
|
|
32
|
-
}
|
|
19
|
+
function stampMtime(stampPath) {
|
|
20
|
+
try {
|
|
21
|
+
return statSync(stampPath).mtimeMs;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return undefined;
|
|
33
25
|
}
|
|
34
|
-
writeFileSync(path, String(process.pid), { flag: "wx" });
|
|
35
|
-
return { path, held: true, startedAt: now, priorMtime };
|
|
36
26
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Cleanup ownership: only the exact marker this run wrote may be removed. The token is
|
|
29
|
+
* diagnostic and guards cleanup; it never grants permission to take an existing lock.
|
|
30
|
+
*/
|
|
31
|
+
function ownsLock(lock) {
|
|
32
|
+
if (!lock.held || !lock.token)
|
|
33
|
+
return false;
|
|
34
|
+
return readText(lock.path)?.trim() === `${process.pid} ${lock.token}`;
|
|
40
35
|
}
|
|
41
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Acquire the dream lock with Git-style exclusive existence locking: one O_CREAT|O_EXCL
|
|
38
|
+
* creation. An existing path refuses acquisition regardless of its contents, PID, or age,
|
|
39
|
+
* and is never read for permission, replaced, or removed. There is no automatic stale
|
|
40
|
+
* recovery; a crash-left lock is human cleanup after confirming no dream is running.
|
|
41
|
+
*/
|
|
42
|
+
export function acquireLock(path) {
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
const priorStampMtime = stampMtime(lastRunPath(path));
|
|
45
|
+
const token = randomUUID();
|
|
46
|
+
try {
|
|
47
|
+
writeFileSync(path, `${process.pid} ${token}`, { flag: "wx" });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return { path, held: false, reason: "lock gate: lock already exists", startedAt: now };
|
|
51
|
+
}
|
|
52
|
+
// Only a held lock advances the scheduler timestamp.
|
|
42
53
|
try {
|
|
43
|
-
|
|
54
|
+
writeFileSync(lastRunPath(path), new Date(now).toISOString());
|
|
44
55
|
}
|
|
45
56
|
catch { /* advisory */ }
|
|
57
|
+
return { path, held: true, startedAt: now, priorStampMtime, token };
|
|
58
|
+
}
|
|
59
|
+
/** Release only the lock this run acquired. Idempotent: repeated cleanup does nothing. */
|
|
60
|
+
export function releaseLock(lock) {
|
|
61
|
+
if (!lock.held)
|
|
62
|
+
return;
|
|
63
|
+
if (ownsLock(lock)) {
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(lock.path);
|
|
66
|
+
}
|
|
67
|
+
catch { /* best effort */ }
|
|
68
|
+
}
|
|
69
|
+
lock.held = false;
|
|
46
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* A failed run must not advance the scheduler: restore the previous timestamp, or remove
|
|
73
|
+
* the one this run wrote when there was none. Only this run's own marker is removed, and
|
|
74
|
+
* the state is marked released so a later cleanup attempt is harmless.
|
|
75
|
+
*/
|
|
47
76
|
export function failLock(lock) {
|
|
48
77
|
if (!lock.held)
|
|
49
78
|
return;
|
|
50
|
-
if (lock
|
|
79
|
+
if (ownsLock(lock)) {
|
|
80
|
+
const stampPath = lastRunPath(lock.path);
|
|
81
|
+
if (lock.priorStampMtime === undefined) {
|
|
82
|
+
try {
|
|
83
|
+
unlinkSync(stampPath);
|
|
84
|
+
}
|
|
85
|
+
catch { /* best effort */ }
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
try {
|
|
89
|
+
utimesSync(stampPath, new Date(), new Date(lock.priorStampMtime));
|
|
90
|
+
}
|
|
91
|
+
catch { /* best effort */ }
|
|
92
|
+
}
|
|
51
93
|
try {
|
|
52
94
|
unlinkSync(lock.path);
|
|
53
95
|
}
|
|
54
96
|
catch { /* best effort */ }
|
|
55
97
|
}
|
|
56
|
-
|
|
57
|
-
restoreMtime(lock.path, lock.priorMtime);
|
|
98
|
+
lock.held = false;
|
|
58
99
|
}
|
package/dist/src/dream/runner.js
CHANGED
|
@@ -83,6 +83,11 @@ export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools })
|
|
|
83
83
|
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
|
|
84
84
|
return session;
|
|
85
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* Run one dream turn. A dreamer failure is returned as `error` together with the partial
|
|
88
|
+
* writes observed so far, so the caller can record partial state instead of losing it;
|
|
89
|
+
* only a failure to even start the session throws.
|
|
90
|
+
*/
|
|
86
91
|
export async function runDreamer(playbook, cwd, options = {}) {
|
|
87
92
|
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
|
|
88
93
|
let answer = "";
|
|
@@ -102,9 +107,14 @@ export async function runDreamer(playbook, cwd, options = {}) {
|
|
|
102
107
|
answer = contentText(event.message.content);
|
|
103
108
|
});
|
|
104
109
|
try {
|
|
105
|
-
|
|
110
|
+
try {
|
|
111
|
+
await session.prompt(playbook);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
return { report: answer, writes, error: error instanceof Error ? error.message : String(error) };
|
|
115
|
+
}
|
|
106
116
|
if (providerError)
|
|
107
|
-
|
|
117
|
+
return { report: answer, writes, error: `dreamer failed: ${providerError}` };
|
|
108
118
|
return { report: answer, writes };
|
|
109
119
|
}
|
|
110
120
|
finally {
|