@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 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` (`global/`, `project/`, `pi/session/`):
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/).
@@ -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 { acquireLock, failLock, releaseLock } from "./lock.js";
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
- export async function main(argv = process.argv.slice(2)) {
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>]\nDefault dreamer: in-process pi SDK session with jailed file tools. Default playbook: <installed package root>/playbook.md; --playbook overrides it.");
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(lockPath, minHours);
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 material = materialGate(home, existsSync(lockPath) ? statSync(lockPath).mtimeMs : 0, minSessions);
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
- gitCommit(home, `baseline ${stamp}`);
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
- const playbook = loadPlaybook(playbookPath);
66
- const result = await runDreamer(playbook, home, { modelPattern: a.dreamer ? String(a.dreamer) : undefined });
67
- const writes = result.writes.length ? result.writes.map((w) => `- ${w.tool}: ${w.path}`).join("\n") : "- no changes";
68
- mkdirSync(join(home, "dreams"), { recursive: true });
69
- writeFileSync(reportPath, `# Dream ${stamp}\n\n${result.report}\n\n${writes}\n`);
70
- gitCommit(home, `dream ${stamp}`);
71
- console.log(reportPath);
72
- return 0;
73
- }
74
- catch (e) {
75
- failLock(lock);
76
- console.error(e instanceof Error ? e.message : e);
77
- return 1;
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
- releaseLock(lock);
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
- main().then((code) => { process.exitCode = code; });
165
+ if (isEntryPoint())
166
+ main().then((code) => { process.exitCode = code; });
@@ -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
- export function timeGate(lockPath, minHours, now = Date.now()) {
5
- if (!existsSync(lockPath))
6
- return { ok: true, reason: "time gate: no prior lock" };
7
- const age = now - statSync(lockPath).mtimeMs;
8
- return age >= minHours * 3600000 ? { ok: true, reason: "time gate: stale" } : { ok: false, reason: "time gate: lock is too fresh" };
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, lockMtime, minSessions) {
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 > lockMtime))
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` };
@@ -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
- * (dream), so the human gate reviews `git show` instead of trusting a report,
7
- * and rollback is `git revert`. This layer is a garnish, never load-bearing:
8
- * every failure is logged and swallowed a notes home without git, or a
9
- * broken repo, still dreams. Nothing is committed when the tree is clean.
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
- return; // clean tree — no empty commit
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
- catch { /* staged changes exist — fall through to commit */ }
22
- execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
23
- console.log(`git: committed "${message}"`);
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
- console.log(`git audit layer skipped: ${error instanceof Error ? error.message : 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;
27
70
  }
28
71
  }
@@ -1,58 +1,99 @@
1
- import { existsSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
- const HOUR = 60 * 60 * 1000;
3
- function live(pid) {
4
- if (!Number.isInteger(pid) || pid <= 0)
5
- return false;
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
- process.kill(pid, 0);
8
- return true;
13
+ return readFileSync(path, "utf8");
9
14
  }
10
15
  catch {
11
- return false;
16
+ return undefined;
12
17
  }
13
18
  }
14
- export function acquireLock(path) {
15
- const now = Date.now();
16
- let priorMtime;
17
- if (existsSync(path)) {
18
- const stat = statSync(path);
19
- priorMtime = stat.mtimeMs;
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
- export function releaseLock(lock) {
38
- // The lock is also the durable last-dream timestamp. Leave the PID marker in place;
39
- // the next acquisition reclaims it once the PID is dead or it is older than an hour.
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
- export function restoreMtime(path, mtimeMs) {
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
- utimesSync(path, new Date(), new Date(mtimeMs));
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.priorMtime === undefined) {
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
- else
57
- restoreMtime(lock.path, lock.priorMtime);
98
+ lock.held = false;
58
99
  }
@@ -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
- await session.prompt(playbook);
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
- throw new Error(`dreamer failed: ${providerError}`);
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 @global/; bare names are the session home";
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("@global/")) {
17
- scope = "global";
18
- path = value.slice("@global/".length);
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", "global"];
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 : "global";
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;
@@ -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 === "global")
43
- return join(notesRoot(), "global");
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));
@@ -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", "global"];
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, global (got ${JSON.stringify(value)})`);
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) {
@@ -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 `@global/<vpath>` for the global home. `@` means leaving home. Any other `@` prefix, or `@` inside a vpath, is a hard error: legal prefixes are `@project/` and `@global/`; bare names are the session home. There is no cross-home fallback. Paths reject `..`, absolute paths, and backslashes.";
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
  }
@@ -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, POCKET_GLOBAL_LIMIT, POCKET_PROJECT_LIMIT, POCKET_SESSION_LIMIT, RESET_SUMMARY, PROTOCOL_BLOCK, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG } from "./protocol.js";
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 global and
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
- * POCKET_GLOBAL_LIMIT), most-recently-updated first within each home, one metadata line
22
- * each: address, line count, UTF-8 byte count, local ISO update time. Bodies never render
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 ["global", "project"]) {
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 global notes; maps never take pocket seats.
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: "global" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_GLOBAL_LIMIT),
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, ${POCKET_GLOBAL_LIMIT} from global). A note's content never appears here, so its name has to say what the note is about:`];
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 ${localIso(row.meta.updated_at)})`);
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 @global/<vpath> is global. @ means leaving home; there is no cross-home fallback. Any other note is a plain file — use the file tools.";
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