@astrosheep/pi-context 0.20.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/budget.js +10 -8
- package/dist/src/dream/cli.js +108 -24
- package/dist/src/dream/gates.js +13 -8
- package/dist/src/dream/git.js +71 -0
- package/dist/src/dream/lock.js +78 -37
- package/dist/src/dream/runner.js +90 -21
- package/dist/src/history-tools.js +5 -5
- package/dist/src/history.js +11 -6
- package/dist/src/index.js +14 -15
- package/dist/src/notes/address.js +31 -0
- package/dist/src/{memory → notes}/frontmatter.js +7 -5
- package/dist/src/{notes.js → notes/model.js} +1 -1
- package/dist/src/{memory → notes}/paths.js +7 -3
- package/dist/src/{memory → notes}/store.js +47 -74
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +38 -29
- package/dist/src/protocol.js +9 -4
- package/dist/src/thresholds.js +33 -3
- package/dist/src/tool-output.js +4 -1
- package/dist/src/warning.js +3 -3
- package/dist/test/agent-loop.test.js +6 -4
- package/dist/test/coherence.test.js +5 -1
- package/dist/test/dream.test.js +419 -35
- package/dist/test/history.test.js +6 -1
- package/dist/test/integration.test.js +107 -47
- package/dist/test/{memory.test.js → notes.test.js} +154 -50
- package/dist/test/pagination.property.test.js +1 -1
- package/package.json +5 -5
- package/playbook.md +30 -3
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +95 -17
- package/src/dream/gates.ts +14 -7
- package/src/dream/git.ts +73 -0
- package/src/dream/lock.ts +67 -24
- package/src/dream/runner.ts +87 -20
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +7 -5
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +8 -3
- package/src/{memory → notes}/store.ts +49 -79
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +39 -29
- package/src/protocol.ts +9 -4
- package/src/thresholds.ts +38 -6
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/dist/src/dream/apply.js +0 -87
- package/dist/src/dream/manifest.js +0 -16
- package/dist/src/memory/tools.js +0 -175
- package/src/dream/apply.ts +0 -47
- package/src/dream/manifest.ts +0 -21
- package/src/memory/tools.ts +0 -175
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/budget.js
CHANGED
|
@@ -5,7 +5,11 @@ import { thresholdsFor, resetThresholds } from "./thresholds.js";
|
|
|
5
5
|
import { currentWindowId, hasWindowMessage } from "./history.js";
|
|
6
6
|
import { tokenBudgetGuidance } from "./prompts.js";
|
|
7
7
|
import { output } from "./tool-output.js";
|
|
8
|
-
|
|
8
|
+
/** Remaining tokens in the current context window, or null when Pi has no usage estimate. */
|
|
9
|
+
export function remainingTokens(ctx) {
|
|
10
|
+
const usage = ctx.getContextUsage();
|
|
11
|
+
return !usage || usage.tokens === null ? null : Math.max(0, usage.contextWindow - usage.tokens);
|
|
12
|
+
}
|
|
9
13
|
export function registerBudget(pi, isEnabled) {
|
|
10
14
|
let guidancePersistedInWindow;
|
|
11
15
|
pi.on("session_start", (_event, ctx) => { guidancePersistedInWindow = undefined; resetThresholds(); thresholdsFor(ctx); });
|
|
@@ -15,10 +19,9 @@ export function registerBudget(pi, isEnabled) {
|
|
|
15
19
|
return undefined;
|
|
16
20
|
// The early reminder persists once per window the first time remaining crosses
|
|
17
21
|
// reserve+margin. It never edits the outgoing request.
|
|
18
|
-
const
|
|
19
|
-
if (
|
|
22
|
+
const remaining = remainingTokens(ctx);
|
|
23
|
+
if (remaining === null)
|
|
20
24
|
return undefined;
|
|
21
|
-
const remaining = Math.max(0, usage.contextWindow - usage.tokens);
|
|
22
25
|
const windowId = currentWindowId(ctx);
|
|
23
26
|
const { reminder, reserve, warning } = thresholdsFor(ctx);
|
|
24
27
|
// The final warning owns the deep band: when it has fired (or is due now),
|
|
@@ -26,7 +29,7 @@ export function registerBudget(pi, isEnabled) {
|
|
|
26
29
|
// the wipe, at a worse position. See warning.ts.
|
|
27
30
|
if (remaining <= warning || hasWindowMessage(ctx, WARNING_TYPE))
|
|
28
31
|
return undefined;
|
|
29
|
-
if (remaining <= reminder && guidancePersistedInWindow !== windowId
|
|
32
|
+
if (remaining <= reminder && guidancePersistedInWindow !== windowId) {
|
|
30
33
|
guidancePersistedInWindow = windowId;
|
|
31
34
|
// Persist once per window — no transient copy. A transient bridge would
|
|
32
35
|
// cover the crossing request, but history would record the reminder after
|
|
@@ -53,11 +56,10 @@ export function registerBudget(pi, isEnabled) {
|
|
|
53
56
|
description: "Return estimated context tokens left before your memory is wiped; null when Pi cannot estimate usage.",
|
|
54
57
|
parameters: Type.Object({}, { additionalProperties: false }),
|
|
55
58
|
async execute(_id, _params, _signal, _update, ctx) {
|
|
56
|
-
const usage = ctx.getContextUsage();
|
|
57
59
|
// The countdown the model sees ends at the warning line (reserve + runway);
|
|
58
60
|
// the runway below it is overdraft the model never sees. See protocol.ts.
|
|
59
|
-
const remaining =
|
|
60
|
-
return output({ remaining_tokens: remaining });
|
|
61
|
+
const remaining = remainingTokens(ctx);
|
|
62
|
+
return output({ remaining_tokens: remaining === null ? null : Math.max(0, remaining - thresholdsFor(ctx).warning) });
|
|
61
63
|
},
|
|
62
64
|
}));
|
|
63
65
|
}
|
package/dist/src/dream/cli.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
3
|
import { dirname, join, resolve } from "node:path";
|
|
5
|
-
import {
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { acquireLock, failLock, lastRunPath, releaseLock } from "./lock.js";
|
|
6
6
|
import { materialGate, timeGate } from "./gates.js";
|
|
7
7
|
import { loadPlaybook, runDreamer } from "./runner.js";
|
|
8
|
-
import {
|
|
8
|
+
import { gitCommit } from "./git.js";
|
|
9
|
+
import { readDreamerSettings } from "../thresholds.js";
|
|
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];
|
|
11
13
|
if (a === "--force" || a === "--help")
|
|
@@ -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 <
|
|
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
|
-
const home = resolve(String(a["notes-home"] ??
|
|
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,25 +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`);
|
|
105
|
+
let succeeded = false;
|
|
61
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
|
+
}
|
|
62
123
|
const defaultBook = join(packageRoot(), "playbook.md");
|
|
63
124
|
const playbookPath = String(a.playbook ?? defaultBook);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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;
|
|
77
145
|
}
|
|
78
146
|
finally {
|
|
79
|
-
|
|
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;
|
|
80
163
|
}
|
|
81
164
|
}
|
|
82
|
-
|
|
165
|
+
if (isEntryPoint())
|
|
166
|
+
main().then((code) => { process.exitCode = code; });
|
package/dist/src/dream/gates.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
import { sessionHomesRoot } from "../notes/paths.js";
|
|
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" };
|
|
8
13
|
}
|
|
9
|
-
export function materialGate(home,
|
|
10
|
-
const root =
|
|
14
|
+
export function materialGate(home, sinceMtime, minSessions) {
|
|
15
|
+
const root = sessionHomesRoot(home);
|
|
11
16
|
let changed = 0;
|
|
12
17
|
if (existsSync(root))
|
|
13
18
|
for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
14
19
|
if (!dir.isDirectory())
|
|
15
20
|
continue;
|
|
16
21
|
const files = readdirSync(join(root, dir.name), { withFileTypes: true });
|
|
17
|
-
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))
|
|
18
23
|
changed++;
|
|
19
24
|
}
|
|
20
25
|
return changed >= minSessions ? { ok: true, reason: `material gate: ${changed} changed sessions` } : { ok: false, reason: `material gate: only ${changed} changed sessions` };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
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
|
+
}
|
|
19
|
+
/**
|
|
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.
|
|
25
|
+
*/
|
|
26
|
+
export function gitCommit(home, message) {
|
|
27
|
+
try {
|
|
28
|
+
if (!existsSync(join(home, ".git"))) {
|
|
29
|
+
execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
|
|
30
|
+
}
|
|
31
|
+
ensureRuntimeIgnored(home);
|
|
32
|
+
execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
|
|
33
|
+
let clean = false;
|
|
34
|
+
try {
|
|
35
|
+
execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
|
|
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}"`);
|
|
45
|
+
}
|
|
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 };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
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;
|
|
70
|
+
}
|
|
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
|
@@ -1,8 +1,76 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
2
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
4
|
+
import { createAgentSession, createEditToolDefinition, createWriteToolDefinition, ModelRuntime, resolveModelScopeWithDiagnostics, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { contentText } from "../history.js";
|
|
6
|
+
export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
|
|
7
|
+
function isOutside(notesHome, target) {
|
|
8
|
+
const fromHome = relative(notesHome, target);
|
|
9
|
+
return fromHome === ".." || fromHome.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(fromHome);
|
|
10
|
+
}
|
|
11
|
+
async function jailWritePath(notesHome, path) {
|
|
12
|
+
let realNotesHome;
|
|
13
|
+
try {
|
|
14
|
+
realNotesHome = await realpath(notesHome);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error(`write jail: cannot resolve notes home ${notesHome}`);
|
|
18
|
+
}
|
|
19
|
+
const target = resolve(realNotesHome, path);
|
|
20
|
+
const targetParent = dirname(target);
|
|
21
|
+
if (isOutside(realNotesHome, targetParent))
|
|
22
|
+
throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
23
|
+
// write creates parent directories itself. Create only after the lexical check, then
|
|
24
|
+
// canonicalize the parent so a symlink cannot lead the underlying tool out of home.
|
|
25
|
+
await mkdir(targetParent, { recursive: true });
|
|
26
|
+
let realTargetParent;
|
|
27
|
+
try {
|
|
28
|
+
realTargetParent = await realpath(targetParent);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
throw new Error(`write jail: cannot resolve target parent in notes home ${notesHome}`);
|
|
32
|
+
}
|
|
33
|
+
if (isOutside(realNotesHome, realTargetParent))
|
|
34
|
+
throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
35
|
+
let targetStats;
|
|
36
|
+
try {
|
|
37
|
+
targetStats = await lstat(target);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error.code !== "ENOENT")
|
|
41
|
+
throw new Error(`write jail: cannot inspect target in notes home ${notesHome}`);
|
|
42
|
+
}
|
|
43
|
+
if (targetStats?.isSymbolicLink()) {
|
|
44
|
+
let realTarget;
|
|
45
|
+
try {
|
|
46
|
+
realTarget = await realpath(target);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
throw new Error(`write jail: cannot resolve target in notes home ${notesHome}`);
|
|
50
|
+
}
|
|
51
|
+
if (isOutside(realNotesHome, realTarget))
|
|
52
|
+
throw new Error(`write jail: ${path} is outside notes home ${notesHome}`);
|
|
53
|
+
}
|
|
54
|
+
if (targetStats && targetStats.nlink > 1)
|
|
55
|
+
throw new Error(`write jail: ${path} has hard links and is not allowed in notes home ${notesHome}`);
|
|
56
|
+
}
|
|
57
|
+
function jailToolDefinition(definition, notesHome) {
|
|
58
|
+
const execute = definition.execute;
|
|
59
|
+
return {
|
|
60
|
+
...definition,
|
|
61
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
62
|
+
await jailWritePath(notesHome, params.path);
|
|
63
|
+
return execute(toolCallId, params, signal, onUpdate, ctx);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The only custom definitions in the dream session replace the two built-ins with jailed versions. */
|
|
68
|
+
export function dreamerWriteToolDefinitions(notesHome) {
|
|
69
|
+
return [
|
|
70
|
+
jailToolDefinition(createWriteToolDefinition(notesHome), notesHome),
|
|
71
|
+
jailToolDefinition(createEditToolDefinition(notesHome), notesHome),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
6
74
|
export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools }) => {
|
|
7
75
|
let model;
|
|
8
76
|
if (modelPattern) {
|
|
@@ -12,41 +80,42 @@ export const defaultDreamerSessionFactory = async ({ cwd, modelPattern, tools })
|
|
|
12
80
|
if (!model)
|
|
13
81
|
throw new Error(`dreamer model pattern "${modelPattern}" did not resolve to an available model`);
|
|
14
82
|
}
|
|
15
|
-
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, noTools: "all", model });
|
|
83
|
+
const { session } = await createAgentSession({ cwd, sessionManager: SessionManager.inMemory(cwd), tools, customTools: dreamerWriteToolDefinitions(cwd), noTools: "all", model, thinkingLevel: "off" });
|
|
16
84
|
return session;
|
|
17
85
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
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
|
+
*/
|
|
24
91
|
export async function runDreamer(playbook, cwd, options = {}) {
|
|
25
|
-
|
|
26
|
-
return runExternalDreamer(options.command, playbook, cwd);
|
|
27
|
-
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: READ_ONLY_TOOLS });
|
|
92
|
+
const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
|
|
28
93
|
let answer = "";
|
|
29
94
|
let providerError;
|
|
95
|
+
const writes = [];
|
|
30
96
|
const unsubscribe = session.subscribe((event) => {
|
|
97
|
+
const tool = event.toolName ?? event.tool?.name;
|
|
98
|
+
const args = event.args ?? event.arguments ?? event.tool?.arguments;
|
|
99
|
+
if ((tool === "write" || tool === "edit") && args && typeof args === "object" && typeof args.path === "string")
|
|
100
|
+
writes.push({ tool, path: args.path });
|
|
31
101
|
if (event.type !== "message_end" || event.message?.role !== "assistant")
|
|
32
102
|
return;
|
|
33
103
|
if (event.message.stopReason === "error") {
|
|
34
104
|
providerError = event.message.errorMessage ?? "unknown provider error";
|
|
35
105
|
return;
|
|
36
106
|
}
|
|
37
|
-
|
|
38
|
-
answer = typeof content === "string" ? content : Array.isArray(content) ? content.filter((part) => part.type === "text").map((part) => part.text).join("") : "";
|
|
107
|
+
answer = contentText(event.message.content);
|
|
39
108
|
});
|
|
40
109
|
try {
|
|
41
|
-
await session.prompt(`${playbook}\n\nReturn exactly one JSON manifest matching this schema: { merge?, promote?, trash?, pending?, skillCandidates?, report }.`);
|
|
42
110
|
try {
|
|
43
|
-
|
|
111
|
+
await session.prompt(playbook);
|
|
44
112
|
}
|
|
45
113
|
catch (error) {
|
|
46
|
-
|
|
47
|
-
throw new Error(`dreamer failed: ${providerError}`);
|
|
48
|
-
throw error;
|
|
114
|
+
return { report: answer, writes, error: error instanceof Error ? error.message : String(error) };
|
|
49
115
|
}
|
|
116
|
+
if (providerError)
|
|
117
|
+
return { report: answer, writes, error: `dreamer failed: ${providerError}` };
|
|
118
|
+
return { report: answer, writes };
|
|
50
119
|
}
|
|
51
120
|
finally {
|
|
52
121
|
unsubscribe?.();
|