@astrosheep/pi-context 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/src/dream/cli.js +105 -22
- 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/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 +92 -14
- 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,27 @@ 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
|
+
## The dream lock
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
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.
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
# only when no dream is running
|
|
70
|
+
rm "${PI_NOTES_HOME:-$HOME/.agents/notes}/.dream.lock"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Removing a lock while a holder is running is outside the supported cooperative protocol and can let two dreams run at once.
|
|
74
|
+
|
|
54
75
|
## Documentation
|
|
55
76
|
|
|
56
77
|
Implementation architecture and the reset lifecycle live in [docs/](docs/).
|
package/dist/src/dream/cli.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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";
|
|
8
10
|
import { notesRoot } from "../notes/paths.js";
|
|
9
11
|
function args(argv) { const out = {}; for (let i = 0; i < argv.length; i++) {
|
|
10
12
|
const a = argv[i];
|
|
@@ -24,23 +26,65 @@ function packageRoot() {
|
|
|
24
26
|
dir = parent;
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
|
-
|
|
29
|
+
function writeList(writes) {
|
|
30
|
+
return writes.length ? writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
|
|
31
|
+
}
|
|
32
|
+
/** Best-effort text write; returns the failure message instead of throwing. */
|
|
33
|
+
function writeText(path, content) {
|
|
34
|
+
try {
|
|
35
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
36
|
+
writeFileSync(path, content);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return error instanceof Error ? error.message : String(error);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function appendText(path, content) {
|
|
44
|
+
try {
|
|
45
|
+
appendFileSync(path, content);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
return error instanceof Error ? error.message : String(error);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Close one dream: record the report, then run the final audit commit. The audit always
|
|
54
|
+
* runs even when the report cannot be written, and a failed audit is appended to the
|
|
55
|
+
* report (when it exists) as well as named on stderr, so neither failure hides the other.
|
|
56
|
+
*/
|
|
57
|
+
function finishDream(home, stamp, reportPath, failed, body, writes) {
|
|
58
|
+
const header = failed ? `# Dream ${stamp} (failed)` : `# Dream ${stamp}`;
|
|
59
|
+
let reportError = writeText(reportPath, `${header}\n\n${body}\n\n${writeList(writes)}\n`);
|
|
60
|
+
const audit = gitCommit(home, `dream ${stamp}${failed ? " (failed)" : ""}`);
|
|
61
|
+
if (!audit.ok) {
|
|
62
|
+
console.error(`dream: final audit failed: ${audit.error}`);
|
|
63
|
+
reportError ??= appendText(reportPath, `\n## Final audit failed\n\n${audit.error}\n`);
|
|
64
|
+
}
|
|
65
|
+
if (reportError)
|
|
66
|
+
console.error(`dream: could not write report at ${reportPath}: ${reportError}`);
|
|
67
|
+
return failed || !audit.ok || reportError !== undefined ? 1 : 0;
|
|
68
|
+
}
|
|
69
|
+
export async function main(argv = process.argv.slice(2), deps = {}) {
|
|
28
70
|
const a = args(argv);
|
|
29
71
|
if (a.help) {
|
|
30
|
-
console.log("dream --notes-home <dir> [--min-hours 24] [--min-sessions 3] [--force] [--dreamer <model pattern>] [--playbook <path>]\
|
|
72
|
+
console.log("dream --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
73
|
return 0;
|
|
32
74
|
}
|
|
33
75
|
const home = resolve(String(a["notes-home"] ?? notesRoot()));
|
|
34
76
|
process.env.PI_NOTES_HOME = home;
|
|
35
77
|
mkdirSync(home, { recursive: true });
|
|
36
78
|
const lockPath = join(home, ".dream.lock");
|
|
79
|
+
const stampPath = lastRunPath(lockPath);
|
|
37
80
|
const minHours = Number(a["min-hours"] ?? 24);
|
|
38
81
|
const minSessions = Number(a["min-sessions"] ?? 3);
|
|
39
|
-
const time = timeGate(
|
|
82
|
+
const time = timeGate(stampPath, minHours);
|
|
40
83
|
console.log(time.reason);
|
|
41
84
|
if (!a.force && !time.ok)
|
|
42
85
|
return 0;
|
|
43
|
-
const
|
|
86
|
+
const since = existsSync(stampPath) ? statSync(stampPath).mtimeMs : 0;
|
|
87
|
+
const material = materialGate(home, since, minSessions);
|
|
44
88
|
console.log(material.reason);
|
|
45
89
|
if (!a.force && !material.ok)
|
|
46
90
|
return 0;
|
|
@@ -58,26 +102,65 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
58
102
|
}
|
|
59
103
|
const stamp = new Date(lock.startedAt).toISOString().replace(/[:.]/g, "-");
|
|
60
104
|
const reportPath = join(home, "dreams", `${stamp}.md`);
|
|
61
|
-
|
|
105
|
+
let succeeded = false;
|
|
62
106
|
try {
|
|
107
|
+
// CLI --dreamer wins over settings; settings win over the automatic model fallback.
|
|
108
|
+
let modelPattern;
|
|
109
|
+
if (a.dreamer)
|
|
110
|
+
modelPattern = String(a.dreamer);
|
|
111
|
+
else {
|
|
112
|
+
const settings = (deps.dreamerSettings ?? readDreamerSettings)();
|
|
113
|
+
for (const warning of settings.warnings)
|
|
114
|
+
console.error(warning);
|
|
115
|
+
modelPattern = settings.pattern;
|
|
116
|
+
}
|
|
117
|
+
// The baseline snapshot is required: without it the human gate has nothing to inspect.
|
|
118
|
+
const baseline = gitCommit(home, `baseline ${stamp}`);
|
|
119
|
+
if (!baseline.ok) {
|
|
120
|
+
console.error(`dream: baseline audit failed: ${baseline.error}`);
|
|
121
|
+
return 1;
|
|
122
|
+
}
|
|
63
123
|
const defaultBook = join(packageRoot(), "playbook.md");
|
|
64
124
|
const playbookPath = String(a.playbook ?? defaultBook);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
125
|
+
let result;
|
|
126
|
+
try {
|
|
127
|
+
const playbook = loadPlaybook(playbookPath);
|
|
128
|
+
result = await (deps.runDreamer ?? runDreamer)(playbook, home, { modelPattern, sessionFactory: deps.sessionFactory });
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
132
|
+
console.error(message);
|
|
133
|
+
return finishDream(home, stamp, reportPath, true, message, []);
|
|
134
|
+
}
|
|
135
|
+
if (result.error) {
|
|
136
|
+
console.error(result.error);
|
|
137
|
+
return finishDream(home, stamp, reportPath, true, result.error, result.writes);
|
|
138
|
+
}
|
|
139
|
+
const code = finishDream(home, stamp, reportPath, false, result.report, result.writes);
|
|
140
|
+
if (code === 0) {
|
|
141
|
+
succeeded = true;
|
|
142
|
+
console.log(reportPath);
|
|
143
|
+
}
|
|
144
|
+
return code;
|
|
78
145
|
}
|
|
79
146
|
finally {
|
|
80
|
-
|
|
147
|
+
// Only the holder's own lock is released; a successor's lock is never touched.
|
|
148
|
+
if (succeeded)
|
|
149
|
+
releaseLock(lock);
|
|
150
|
+
else
|
|
151
|
+
failLock(lock);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function isEntryPoint() {
|
|
155
|
+
const entry = process.argv[1];
|
|
156
|
+
if (!entry)
|
|
157
|
+
return false;
|
|
158
|
+
try {
|
|
159
|
+
return realpathSync(resolve(entry)) === realpathSync(fileURLToPath(import.meta.url));
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return false;
|
|
81
163
|
}
|
|
82
164
|
}
|
|
83
|
-
|
|
165
|
+
if (isEntryPoint())
|
|
166
|
+
main().then((code) => { process.exitCode = code; });
|
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 {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertVirtualPath } from "./model.js";
|
|
2
|
-
const ADDRESS_FORMS = "legal prefixes are @project/ and @
|
|
2
|
+
const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
|
|
3
3
|
/**
|
|
4
4
|
* Decode the one public note address into its physical home and virtual path. This is a
|
|
5
5
|
* tool-boundary rule: replay paths keep using assertVirtualPath directly and are untouched.
|
|
@@ -13,9 +13,9 @@ export function assertAddress(value) {
|
|
|
13
13
|
scope = "project";
|
|
14
14
|
path = value.slice("@project/".length);
|
|
15
15
|
}
|
|
16
|
-
else if (value.startsWith("@
|
|
17
|
-
scope = "
|
|
18
|
-
path = value.slice("@
|
|
16
|
+
else if (value.startsWith("@personal/")) {
|
|
17
|
+
scope = "personal";
|
|
18
|
+
path = value.slice("@personal/".length);
|
|
19
19
|
}
|
|
20
20
|
else if (value.startsWith("@")) {
|
|
21
21
|
throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { localIso } from "./model.js";
|
|
2
|
-
const SCOPES = ["session", "project", "
|
|
2
|
+
const SCOPES = ["session", "project", "personal"];
|
|
3
3
|
const ORIGINS = ["user", "self", "external"];
|
|
4
4
|
const STATUSES = ["active", "superseded", "pending", "archived"];
|
|
5
5
|
const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"];
|
|
@@ -89,7 +89,7 @@ function parseFrontmatter(raw) {
|
|
|
89
89
|
export function parseNote(raw, now = Date.now()) {
|
|
90
90
|
const { fields, body } = parseFrontmatter(raw);
|
|
91
91
|
const meta = { ...fields };
|
|
92
|
-
meta.scope = isScope(meta.scope) ? meta.scope : "
|
|
92
|
+
meta.scope = isScope(meta.scope) ? meta.scope : "personal";
|
|
93
93
|
meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
|
|
94
94
|
meta.status = isStatus(meta.status) ? meta.status : "active";
|
|
95
95
|
meta.stale = meta.stale === true;
|
package/dist/src/notes/paths.js
CHANGED
|
@@ -39,8 +39,8 @@ function sessionId(ctx) {
|
|
|
39
39
|
}
|
|
40
40
|
/** Absolute directory holding every note of one scope. */
|
|
41
41
|
export function scopeDir(scope, ctx) {
|
|
42
|
-
if (scope === "
|
|
43
|
-
return join(notesRoot(), "
|
|
42
|
+
if (scope === "personal")
|
|
43
|
+
return join(notesRoot(), "personal");
|
|
44
44
|
if (scope === "project")
|
|
45
45
|
return join(notesRoot(), "project", projectKey(ctx.cwd));
|
|
46
46
|
return join(sessionHomesRoot(), sessionId(ctx));
|
package/dist/src/notes/store.js
CHANGED
|
@@ -21,10 +21,10 @@ export class NoteError extends Error {
|
|
|
21
21
|
this.edit_index = extra.edit_index;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
const SCOPE_ORDER = ["session", "project", "
|
|
24
|
+
const SCOPE_ORDER = ["session", "project", "personal"];
|
|
25
25
|
function assertScope(value) {
|
|
26
26
|
if (!isScope(value))
|
|
27
|
-
throw new NoteError("invalid_scope", `scope must be one of session, project,
|
|
27
|
+
throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
|
|
28
28
|
return value;
|
|
29
29
|
}
|
|
30
30
|
function assertOrigin(value) {
|
package/dist/src/notes/tools.js
CHANGED
|
@@ -9,7 +9,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
|
|
|
9
9
|
const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
|
|
10
10
|
description: "Where the note's content came from. user: written or dictated by the human. self: written by you, the agent (default). external: anything else — third-party text, tool output, fetched material.",
|
|
11
11
|
}));
|
|
12
|
-
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@
|
|
12
|
+
const ADDRESS_DESCRIPTION = "Address forms are bare `<vpath>` for this session, `@project/<vpath>` for this project's home, and `@personal/<vpath>` for the human's cross-project home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@personal/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
|
|
13
13
|
function wireMeta(meta) {
|
|
14
14
|
return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
|
|
15
15
|
}
|
package/dist/src/prompts.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { historyFromSession } from "./history.js";
|
|
2
|
-
import { localIso } from "./notes/model.js";
|
|
3
2
|
import { listNotes } from "./notes/store.js";
|
|
4
|
-
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG,
|
|
3
|
+
import { CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG, POCKET_PERSONAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
|
|
5
4
|
/** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
|
|
6
5
|
function identityBlock(agentName, firstWindowId, currentWindowId, previousWindowId) {
|
|
7
6
|
const lines = [
|
|
@@ -13,20 +12,27 @@ function identityBlock(agentName, firstWindowId, currentWindowId, previousWindow
|
|
|
13
12
|
lines.push(`Previous context window id: ${previousWindowId}`);
|
|
14
13
|
return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
|
|
15
14
|
}
|
|
15
|
+
function relativeTime(timestamp, now) {
|
|
16
|
+
const seconds = Math.trunc((timestamp - now) / 1000);
|
|
17
|
+
const [unit, size] = [["d", 86400], ["h", 3600], ["m", 60], ["s", 1]]
|
|
18
|
+
.find(([unit, size]) => Math.abs(seconds) >= size || unit === "s");
|
|
19
|
+
const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
|
|
20
|
+
return seconds > 0 ? `in ${amount}` : `${amount} ago`;
|
|
21
|
+
}
|
|
16
22
|
/**
|
|
17
|
-
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the
|
|
23
|
+
* Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
|
|
18
24
|
* project homes are both injected, broadest first; stale maps are skipped per home, and the
|
|
19
25
|
* session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
|
|
20
26
|
* recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
|
|
21
|
-
*
|
|
22
|
-
* each: address, line count, UTF-8 byte count,
|
|
27
|
+
* POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
|
|
28
|
+
* each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
|
|
23
29
|
* in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
|
|
24
30
|
*/
|
|
25
31
|
function notesIndex(ctx) {
|
|
26
32
|
const sections = [];
|
|
27
33
|
// Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
|
|
28
34
|
// A session MAP.md is an ordinary note, never resident; stale maps skip independently.
|
|
29
|
-
for (const scope of ["
|
|
35
|
+
for (const scope of ["personal", "project"]) {
|
|
30
36
|
const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
|
|
31
37
|
if (toc && !toc.meta.stale) {
|
|
32
38
|
if (toc.body.length > 0)
|
|
@@ -34,23 +40,24 @@ function notesIndex(ctx) {
|
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
// listNotes is most-recently-updated first within each home. Per-home quotas keep session
|
|
37
|
-
// churn from evicting project or
|
|
43
|
+
// churn from evicting project or personal notes; maps never take pocket seats.
|
|
38
44
|
const recentNotes = [
|
|
39
45
|
...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
|
|
40
46
|
...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
|
|
41
|
-
...listNotes(ctx, { scope: "
|
|
47
|
+
...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
|
|
42
48
|
];
|
|
43
49
|
if (recentNotes.length > 0) {
|
|
44
|
-
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${
|
|
50
|
+
const lines = [`You find ${recentNotes.length} crumpled note${recentNotes.length === 1 ? "" : "s"} in your pocket (by home, most recent first within each: up to ${POCKET_SESSION_LIMIT} from this session, ${POCKET_PROJECT_LIMIT} from this project, ${POCKET_PERSONAL_LIMIT} from personal). A note's content never appears here, so its name has to say what the note is about:`];
|
|
51
|
+
const now = Date.now();
|
|
45
52
|
for (const row of recentNotes) {
|
|
46
|
-
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${
|
|
53
|
+
lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
|
|
47
54
|
}
|
|
48
55
|
sections.push(lines.join("\n"));
|
|
49
56
|
}
|
|
50
57
|
return sections.join("\n\n");
|
|
51
58
|
}
|
|
52
59
|
function notesHomeBlock() {
|
|
53
|
-
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @
|
|
60
|
+
return "Notes_* addresses have three homes: bare <vpath> is this session, @project/<vpath> is this project, and @personal/<vpath> is the human's cross-project home. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
|
|
54
61
|
}
|
|
55
62
|
/**
|
|
56
63
|
* Assemble the static, once-per-window boot block: the reset line for resets, the
|