@astrosheep/pi-context 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/dream/git.ts CHANGED
@@ -1,27 +1,73 @@
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
4
 
5
5
  /**
6
- * Git audit layer for a dream run: one commit before (baseline) and one after
7
- * (dream), so the human gate reviews `git show` instead of trusting a report,
8
- * and rollback is `git revert`. This layer is a garnish, never load-bearing:
9
- * every failure is logged and swallowed — a notes home without git, or a
10
- * broken repo, still dreams. Nothing is committed when the tree is clean.
6
+ * Outcome of one audit commit. `ok: true` always carries a real `commit` snapshot;
7
+ * `empty: true` only means no files changed (an empty baseline commit or no commit was
8
+ * needed). `ok: false` means the audit layer could not guarantee a snapshot.
11
9
  */
12
- export function gitCommit(home: string, message: string): void {
10
+ export type AuditResult =
11
+ | { ok: true; commit: string; empty: boolean }
12
+ | { ok: false; error: string };
13
+
14
+ /** Lock runtime artifacts are not notes and must not appear in snapshots or `git status`. */
15
+ const RUNTIME_IGNORE = ".dream.lock*";
16
+
17
+ /** Add the runtime-artifact pattern to the repository's local exclude, once. */
18
+ function ensureRuntimeIgnored(home: string): void {
19
+ try {
20
+ const exclude = join(home, ".git", "info", "exclude");
21
+ const current = existsSync(exclude) ? readFileSync(exclude, "utf8") : "";
22
+ if (current.split(/\r?\n/).includes(RUNTIME_IGNORE)) return;
23
+ mkdirSync(dirname(exclude), { recursive: true });
24
+ const prefix = current.length > 0 && !current.endsWith("\n") ? `${current}\n` : current;
25
+ writeFileSync(exclude, `${prefix}${RUNTIME_IGNORE}\n`);
26
+ } catch { /* best effort: an unignored lock only adds noise to the audit */ }
27
+ }
28
+
29
+ /**
30
+ * Git audit layer for a dream run: one commit before (baseline) and one after (dream),
31
+ * so the human gate reviews `git show` instead of trusting a report, and rollback is
32
+ * `git revert`. The caller decides how loud a failure is; this function only reports it.
33
+ * A clean tree on an established repository commits nothing; a repository with no HEAD
34
+ * gets an empty baseline commit, because an audit run with no snapshot is not a success.
35
+ */
36
+ export function gitCommit(home: string, message: string): AuditResult {
13
37
  try {
14
38
  if (!existsSync(join(home, ".git"))) {
15
39
  execFileSync("git", ["init", "-q"], { cwd: home, stdio: "ignore" });
16
40
  }
41
+ ensureRuntimeIgnored(home);
17
42
  execFileSync("git", ["add", "-A"], { cwd: home, stdio: "ignore" });
43
+ let clean = false;
18
44
  try {
19
45
  execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: home, stdio: "ignore" });
20
- return; // clean tree — no empty commit
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
+ clean = true; // clean tree — no empty commit on an established repository
47
+ } catch { clean = false; }
48
+ const before = headCommit(home);
49
+ if (!clean) {
50
+ execFileSync("git", ["commit", "-q", "-m", message], { cwd: home, stdio: "ignore" });
51
+ console.log(`git: committed "${message}"`);
52
+ } else if (before === undefined) {
53
+ // A newly initialized repository has no snapshot at all; give the audit one.
54
+ execFileSync("git", ["commit", "-q", "--allow-empty", "-m", message], { cwd: home, stdio: "ignore" });
55
+ console.log(`git: committed "${message}"`);
56
+ }
57
+ const commit = headCommit(home);
58
+ if (commit === undefined) return { ok: false, error: "audit commit produced no snapshot (no HEAD)" };
59
+ return { ok: true, commit, empty: clean };
24
60
  } catch (error) {
25
- console.log(`git audit layer skipped: ${error instanceof Error ? error.message : error}`);
61
+ const reason = error instanceof Error ? error.message : String(error);
62
+ console.log(`git audit layer failed: ${reason}`);
63
+ return { ok: false, error: reason };
26
64
  }
27
65
  }
66
+
67
+ /** HEAD sha, or undefined when the repository has no commit yet. */
68
+ function headCommit(home: string): string | undefined {
69
+ try {
70
+ const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: home, encoding: "utf8" }).trim();
71
+ return head.length > 0 ? head : undefined;
72
+ } catch { return undefined; }
73
+ }
package/src/dream/lock.ts CHANGED
@@ -1,39 +1,82 @@
1
- import { existsSync, readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync, statSync, unlinkSync, utimesSync, writeFileSync } from "node:fs";
2
3
 
3
- export type LockState = { path: string; held: boolean; reason?: string; startedAt: number; priorMtime?: number };
4
- const HOUR = 60 * 60 * 1000;
4
+ export type LockState = {
5
+ path: string;
6
+ held: boolean;
7
+ reason?: string;
8
+ startedAt: number;
9
+ /** mtime of the last-run sidecar before this run took the lock; failLock restores it. */
10
+ priorStampMtime?: number;
11
+ /** Random identity written into the lock file; cleanup only removes the lock it wrote. */
12
+ token?: string;
13
+ };
5
14
 
6
- function live(pid: number): boolean {
7
- if (!Number.isInteger(pid) || pid <= 0) return false;
8
- try { process.kill(pid, 0); return true; } catch { return false; }
15
+ /**
16
+ * The scheduler's last-run timestamp lives in a sidecar beside the lock, never in the
17
+ * lock file itself: acquiring, releasing or cleaning up the lock touches only the PID
18
+ * marker, so lock lifecycle does not destroy the timestamp the time gate reads.
19
+ */
20
+ export function lastRunPath(lockPath: string): string {
21
+ return `${lockPath}.last-run`;
9
22
  }
10
23
 
24
+ function readText(path: string): string | undefined {
25
+ try { return readFileSync(path, "utf8"); } catch { return undefined; }
26
+ }
27
+
28
+ function stampMtime(stampPath: string): number | undefined {
29
+ try { return statSync(stampPath).mtimeMs; } catch { return undefined; }
30
+ }
31
+
32
+ /**
33
+ * Cleanup ownership: only the exact marker this run wrote may be removed. The token is
34
+ * diagnostic and guards cleanup; it never grants permission to take an existing lock.
35
+ */
36
+ function ownsLock(lock: LockState): boolean {
37
+ if (!lock.held || !lock.token) return false;
38
+ return readText(lock.path)?.trim() === `${process.pid} ${lock.token}`;
39
+ }
40
+
41
+ /**
42
+ * Acquire the dream lock with Git-style exclusive existence locking: one O_CREAT|O_EXCL
43
+ * creation. An existing path refuses acquisition regardless of its contents, PID, or age,
44
+ * and is never read for permission, replaced, or removed. There is no automatic stale
45
+ * recovery; a crash-left lock is human cleanup after confirming no dream is running.
46
+ */
11
47
  export function acquireLock(path: string): LockState {
12
48
  const now = Date.now();
13
- let priorMtime: number | undefined;
14
- if (existsSync(path)) {
15
- const stat = statSync(path);
16
- priorMtime = stat.mtimeMs;
17
- let pid = 0;
18
- try { pid = Number.parseInt(readFileSync(path, "utf8").trim(), 10); } catch { /* reclaim */ }
19
- if (now - stat.mtimeMs <= HOUR && live(pid)) return { path, held: false, reason: "lock gate: live process holds the lock", startedAt: now };
20
- try { unlinkSync(path); } catch { return { path, held: false, reason: "lock gate: lock could not be reclaimed", startedAt: now }; }
49
+ const priorStampMtime = stampMtime(lastRunPath(path));
50
+ const token = randomUUID();
51
+ try {
52
+ writeFileSync(path, `${process.pid} ${token}`, { flag: "wx" });
53
+ } catch {
54
+ return { path, held: false, reason: "lock gate: lock already exists", startedAt: now };
21
55
  }
22
- writeFileSync(path, String(process.pid), { flag: "wx" });
23
- return { path, held: true, startedAt: now, priorMtime };
56
+ // Only a held lock advances the scheduler timestamp.
57
+ try { writeFileSync(lastRunPath(path), new Date(now).toISOString()); } catch { /* advisory */ }
58
+ return { path, held: true, startedAt: now, priorStampMtime, token };
24
59
  }
25
60
 
61
+ /** Release only the lock this run acquired. Idempotent: repeated cleanup does nothing. */
26
62
  export function releaseLock(lock: LockState): void {
27
- // The lock is also the durable last-dream timestamp. Leave the PID marker in place;
28
- // the next acquisition reclaims it once the PID is dead or it is older than an hour.
29
- }
30
-
31
- export function restoreMtime(path: string, mtimeMs: number): void {
32
- try { utimesSync(path, new Date(), new Date(mtimeMs)); } catch { /* advisory */ }
63
+ if (!lock.held) return;
64
+ if (ownsLock(lock)) { try { unlinkSync(lock.path); } catch { /* best effort */ } }
65
+ lock.held = false;
33
66
  }
34
67
 
68
+ /**
69
+ * A failed run must not advance the scheduler: restore the previous timestamp, or remove
70
+ * the one this run wrote when there was none. Only this run's own marker is removed, and
71
+ * the state is marked released so a later cleanup attempt is harmless.
72
+ */
35
73
  export function failLock(lock: LockState): void {
36
74
  if (!lock.held) return;
37
- if (lock.priorMtime === undefined) { try { unlinkSync(lock.path); } catch { /* best effort */ } }
38
- else restoreMtime(lock.path, lock.priorMtime);
75
+ if (ownsLock(lock)) {
76
+ const stampPath = lastRunPath(lock.path);
77
+ if (lock.priorStampMtime === undefined) { try { unlinkSync(stampPath); } catch { /* best effort */ } }
78
+ else { try { utimesSync(stampPath, new Date(), new Date(lock.priorStampMtime)); } catch { /* best effort */ } }
79
+ try { unlinkSync(lock.path); } catch { /* best effort */ }
80
+ }
81
+ lock.held = false;
39
82
  }
@@ -6,7 +6,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
6
6
  import { contentText } from "../history.js";
7
7
 
8
8
  export type DreamWrite = { tool: "write" | "edit"; path: string };
9
- export type DreamResult = { report: string; writes: DreamWrite[] };
9
+ export type DreamResult = { report: string; writes: DreamWrite[]; error?: string };
10
10
  export type DreamerSession = Pick<AgentSession, "prompt" | "subscribe" | "dispose">;
11
11
  export type DreamerSessionFactory = (options: { cwd: string; modelPattern?: string; tools: string[] }) => Promise<DreamerSession>;
12
12
  export const DREAMER_TOOLS = ["read", "grep", "find", "ls", "write", "edit"];
@@ -85,6 +85,11 @@ export const defaultDreamerSessionFactory: DreamerSessionFactory = async ({ cwd,
85
85
  return session;
86
86
  };
87
87
 
88
+ /**
89
+ * Run one dream turn. A dreamer failure is returned as `error` together with the partial
90
+ * writes observed so far, so the caller can record partial state instead of losing it;
91
+ * only a failure to even start the session throws.
92
+ */
88
93
  export async function runDreamer(playbook: string, cwd: string, options: { modelPattern?: string; sessionFactory?: DreamerSessionFactory } = {}): Promise<DreamResult> {
89
94
  const session = await (options.sessionFactory ?? defaultDreamerSessionFactory)({ cwd, modelPattern: options.modelPattern, tools: DREAMER_TOOLS });
90
95
  let answer = "";
@@ -99,8 +104,12 @@ export async function runDreamer(playbook: string, cwd: string, options: { model
99
104
  answer = contentText(event.message.content);
100
105
  });
101
106
  try {
102
- await session.prompt(playbook);
103
- if (providerError) throw new Error(`dreamer failed: ${providerError}`);
107
+ try {
108
+ await session.prompt(playbook);
109
+ } catch (error) {
110
+ return { report: answer, writes, error: error instanceof Error ? error.message : String(error) };
111
+ }
112
+ if (providerError) return { report: answer, writes, error: `dreamer failed: ${providerError}` };
104
113
  return { report: answer, writes };
105
114
  } finally {
106
115
  unsubscribe?.();
@@ -3,7 +3,7 @@ import type { Scope } from "./paths.js";
3
3
 
4
4
  export type NoteAddress = { scope: Scope; path: string };
5
5
 
6
- const ADDRESS_FORMS = "legal prefixes are @project/ and @global/; bare names are the session home";
6
+ const ADDRESS_FORMS = "legal prefixes are @project/ and @personal/; bare names are the session home";
7
7
 
8
8
  /**
9
9
  * Decode the one public note address into its physical home and virtual path. This is a
@@ -16,9 +16,9 @@ export function assertAddress(value: unknown): NoteAddress {
16
16
  if (value.startsWith("@project/")) {
17
17
  scope = "project";
18
18
  path = value.slice("@project/".length);
19
- } else if (value.startsWith("@global/")) {
20
- scope = "global";
21
- path = value.slice("@global/".length);
19
+ } else if (value.startsWith("@personal/")) {
20
+ scope = "personal";
21
+ path = value.slice("@personal/".length);
22
22
  } else if (value.startsWith("@")) {
23
23
  throw new Error(`invalid note address: ${ADDRESS_FORMS}`);
24
24
  }
@@ -25,7 +25,7 @@ export type NoteMeta = {
25
25
  [key: string]: unknown;
26
26
  };
27
27
 
28
- const SCOPES: readonly Scope[] = ["session", "project", "global"];
28
+ const SCOPES: readonly Scope[] = ["session", "project", "personal"];
29
29
  const ORIGINS: readonly Origin[] = ["user", "self", "external"];
30
30
  const STATUSES: readonly NoteStatus[] = ["active", "superseded", "pending", "archived"];
31
31
  const TIMESTAMP_KEYS = ["created_at", "updated_at", "last_accessed"] as const;
@@ -112,7 +112,7 @@ function parseFrontmatter(raw: string): { fields: Record<string, unknown>; body:
112
112
  export function parseNote(raw: string, now = Date.now()): { meta: NoteMeta; body: string } {
113
113
  const { fields, body } = parseFrontmatter(raw);
114
114
  const meta = { ...fields } as Record<string, unknown>;
115
- meta.scope = isScope(meta.scope) ? meta.scope : "global";
115
+ meta.scope = isScope(meta.scope) ? meta.scope : "personal";
116
116
  meta.origin = isOrigin(meta.origin) ? meta.origin : "self";
117
117
  meta.status = isStatus(meta.status) ? meta.status : "active";
118
118
  meta.stale = meta.stale === true;
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { basename, dirname, join, resolve } from "node:path";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
 
7
- export type Scope = "session" | "project" | "global";
7
+ export type Scope = "session" | "project" | "personal";
8
8
 
9
9
  /** Physical home of the on-disk note store: $PI_NOTES_HOME or ~/.agents/notes. */
10
10
  export function notesRoot(): string {
@@ -46,7 +46,7 @@ function sessionId(ctx: ExtensionContext): string {
46
46
 
47
47
  /** Absolute directory holding every note of one scope. */
48
48
  export function scopeDir(scope: Scope, ctx: ExtensionContext): string {
49
- if (scope === "global") return join(notesRoot(), "global");
49
+ if (scope === "personal") return join(notesRoot(), "personal");
50
50
  if (scope === "project") return join(notesRoot(), "project", projectKey(ctx.cwd));
51
51
  return join(sessionHomesRoot(), sessionId(ctx));
52
52
  }
@@ -32,10 +32,10 @@ export type NoteRow = { address: string; scope: Scope; path: string; meta: NoteM
32
32
  export type NoteMatch = { line: number; text: string; offsetChars: number };
33
33
  export type NoteSearchRow = { address: string; scope: Scope; path: string; meta: NoteMeta; matches: NoteMatch[] };
34
34
 
35
- const SCOPE_ORDER: readonly Scope[] = ["session", "project", "global"];
35
+ const SCOPE_ORDER: readonly Scope[] = ["session", "project", "personal"];
36
36
 
37
37
  function assertScope(value: unknown): Scope {
38
- if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, global (got ${JSON.stringify(value)})`);
38
+ if (!isScope(value)) throw new NoteError("invalid_scope", `scope must be one of session, project, personal (got ${JSON.stringify(value)})`);
39
39
  return value;
40
40
  }
41
41
 
@@ -10,7 +10,7 @@ import { NoteError, editNote, listNotes, readNote, searchNotes, writeNote } from
10
10
  const ORIGIN = Type.Optional(Type.Union([Type.Literal("user"), Type.Literal("self"), Type.Literal("external")], {
11
11
  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.",
12
12
  }));
13
- 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.";
13
+ 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.";
14
14
 
15
15
  function wireMeta(meta: NoteMeta): Record<string, unknown> {
16
16
  return { ...meta, created_at: localIso(meta.created_at), updated_at: localIso(meta.updated_at), last_accessed: localIso(meta.last_accessed) };
package/src/prompts.ts CHANGED
@@ -1,8 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { historyFromSession } from "./history.js";
3
- import { localIso } from "./notes/model.js";
4
3
  import { listNotes } from "./notes/store.js";
5
- 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";
4
+ 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";
6
5
 
7
6
  /** Codex-style <context_window> identity block: agent name and first/current/previous window ids only. */
8
7
  function identityBlock(agentName: string, firstWindowId: string, currentWindowId: string, previousWindowId?: string): string {
@@ -15,36 +14,45 @@ function identityBlock(agentName: string, firstWindowId: string, currentWindowId
15
14
  return `${CONTEXT_WINDOW_OPEN_TAG}\n${lines.join("\n")}\n${CONTEXT_WINDOW_CLOSE_TAG}`;
16
15
  }
17
16
 
17
+ function relativeTime(timestamp: number, now: number): string {
18
+ const seconds = Math.trunc((timestamp - now) / 1000);
19
+ const [unit, size] = ([["d", 86400], ["h", 3600], ["m", 60], ["s", 1]] as const)
20
+ .find(([unit, size]) => Math.abs(seconds) >= size || unit === "s")!;
21
+ const amount = `${Math.abs(Math.trunc(seconds / size))}${unit}`;
22
+ return seconds > 0 ? `in ${amount}` : `${amount} ago`;
23
+ }
24
+
18
25
  /**
19
- * Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the global and
26
+ * Boot notes index. Map residency ("地图在场"): fresh MAP.md bodies from the personal and
20
27
  * project homes are both injected, broadest first; stale maps are skipped per home, and the
21
28
  * session home is never peeked — a session MAP.md is an ordinary note. The pocket then lists
22
29
  * recent fresh notes under per-home quotas (POCKET_SESSION_LIMIT / POCKET_PROJECT_LIMIT /
23
- * POCKET_GLOBAL_LIMIT), most-recently-updated first within each home, one metadata line
24
- * each: address, line count, UTF-8 byte count, local ISO update time. Bodies never render
30
+ * POCKET_PERSONAL_LIMIT), most-recently-updated first within each home, one metadata line
31
+ * each: address, line count, UTF-8 byte count, relative update time at window open. Bodies never render
25
32
  * in the pocket; stale notes are excluded; MAP.md itself never takes a pocket seat.
26
33
  */
27
34
  function notesIndex(ctx: ExtensionContext): string {
28
35
  const sections: string[] = [];
29
36
  // Map residency ("地图在场"): scope-native maps, both fresh ones injected broadest-first.
30
37
  // A session MAP.md is an ordinary note, never resident; stale maps skip independently.
31
- for (const scope of ["global", "project"] as const) {
38
+ for (const scope of ["personal", "project"] as const) {
32
39
  const toc = listNotes(ctx, { scope }).find((row) => row.path === "MAP.md");
33
40
  if (toc && !toc.meta.stale) {
34
41
  if (toc.body.length > 0) sections.push(toc.body);
35
42
  }
36
43
  }
37
44
  // listNotes is most-recently-updated first within each home. Per-home quotas keep session
38
- // churn from evicting project or global notes; maps never take pocket seats.
45
+ // churn from evicting project or personal notes; maps never take pocket seats.
39
46
  const recentNotes = [
40
47
  ...listNotes(ctx, { scope: "session" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_SESSION_LIMIT),
41
48
  ...listNotes(ctx, { scope: "project" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PROJECT_LIMIT),
42
- ...listNotes(ctx, { scope: "global" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_GLOBAL_LIMIT),
49
+ ...listNotes(ctx, { scope: "personal" }).filter((row) => !row.meta.stale && row.path !== "MAP.md").slice(0, POCKET_PERSONAL_LIMIT),
43
50
  ];
44
51
  if (recentNotes.length > 0) {
45
- 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:`];
52
+ 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:`];
53
+ const now = Date.now();
46
54
  for (const row of recentNotes) {
47
- lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${localIso(row.meta.updated_at)})`);
55
+ lines.push(`- ${row.address} (${row.body.split("\n").length} lines, ${row.sizeBytes} UTF-8 bytes, updated ${relativeTime(row.meta.updated_at, now)})`);
48
56
  }
49
57
  sections.push(lines.join("\n"));
50
58
  }
@@ -52,7 +60,7 @@ function notesIndex(ctx: ExtensionContext): string {
52
60
  }
53
61
 
54
62
  function notesHomeBlock(): string {
55
- 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.";
63
+ 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.";
56
64
  }
57
65
 
58
66
  /**
package/src/protocol.ts CHANGED
@@ -9,7 +9,7 @@ export const RESET_V2 = "reset-v2";
9
9
  export const MAX_NOTE_BYTES = 1_000_000;
10
10
  export const POCKET_SESSION_LIMIT = 5;
11
11
  export const POCKET_PROJECT_LIMIT = 2;
12
- export const POCKET_GLOBAL_LIMIT = 2;
12
+ export const POCKET_PERSONAL_LIMIT = 2;
13
13
  // Write-time cap on a virtual note path. Deliberately NOT enforced by assertVirtualPath:
14
14
  // notesFromSession replays already-persisted operations, which must keep loading sessions
15
15
  // that contain a longer legacy path. Reads and replay stay un-capped.
@@ -21,6 +21,8 @@ export const CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG = "</context_window_protocol>";
21
21
  export const GUIDANCE_OPEN_TAG = "<context_window_guidance>";
22
22
  export const GUIDANCE_CLOSE_TAG = "</context_window_guidance>";
23
23
  export const PI_CONTEXT_SETTINGS_KEY = "pi-context";
24
+ /** Nested under "pi-context": the default dreamer model pattern, overridden by CLI --dreamer. */
25
+ export const PI_CONTEXT_DREAMER_KEY = "dreamer";
24
26
  export const DEFAULT_RESERVE_TOKENS = 16_384;
25
27
  export const DEFAULT_REMINDER_MARGIN_TOKENS = 24_576;
26
28
  /**
@@ -49,11 +51,10 @@ Use get_context_remaining to see how much of the window is left. When it runs ou
49
51
 
50
52
  If <context_window> lists a Previous context window id, a reset just happened and the old conversation is not included. Read your note checkpoint first, then recover details through history_*: history_read directly when you know the window and item IDs, history_list or history_search to find them when you don't.
51
53
 
52
- Your notes live in three homes: this session (bare names), this repo (@project/<vpath>), everywhere you go (@global/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
53
- Notes carry what exists nowhere elsewhat the human told you, what you discovered, where you stand.
54
- Session notes belong to this tripthe goal, the progress, the loose ends, packed for the road. The next window of THIS trip wakes to them; once the trip is over, nobody does.
55
- @project notes hold what you learned by working here the things you only know because you were here for whoever works here next.
56
- @global notes travel with you. Every window. Every conversation. Every trip. So before you drop anything in there, ask yourself: does this deserve to stare you in the face every single time you talk to the human? No? Then keep your weird junk OUT.
54
+ Your notes live in three homes: this session (bare names), this project (@project/<vpath>), the human across projects (@personal/<vpath>). @ means leaving home — and homes don't visit each other: there is no cross-home fallback.
55
+ Session notes belong to this trip — the goal, the progress, the loose ends. The next window of THIS trip wakes to them; once the trip is over, nobody does.
56
+ @project notes hold facts about this projectarchitecture, conventions, workflows, deployment and environment details for whoever works here next.
57
+ @personal notes hold the human's durable preferences and standing rules, plus lessons that apply across projects. Duration does not make a note personal; its stated scope must already be broader than the project or conversation at hand. When the intended scope is unclear, keep the note in the narrowest stated scope rather than widening it.
57
58
  ${CONTEXT_WINDOW_PROTOCOL_CLOSE_TAG}`;
58
59
 
59
60
  export const WARNING_PROMPT =
package/src/thresholds.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { SettingsManager, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
2
+ import { PI_CONTEXT_SETTINGS_KEY, PI_CONTEXT_DREAMER_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
3
3
 
4
4
  export type ResolvedThresholds = { reminder: number; reserve: number; warning: number };
5
- type PiContextMargins = { reminderMarginTokens?: unknown };
5
+ type PiContextSettings = { reminderMarginTokens?: unknown; dreamer?: unknown };
6
6
 
7
7
  function isSettingsObject(value: unknown): value is Record<string, unknown> {
8
8
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -16,9 +16,9 @@ function piContextSettings(settings: unknown): Record<string, unknown> {
16
16
  }
17
17
 
18
18
  /** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
19
- export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextMargins {
19
+ export function mergePiContextSettings(globalSettings: unknown, projectSettings: unknown): PiContextSettings {
20
20
  const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
21
- return { reminderMarginTokens: merged.reminderMarginTokens };
21
+ return { reminderMarginTokens: merged.reminderMarginTokens, dreamer: merged.dreamer };
22
22
  }
23
23
 
24
24
  /** A margin is usable only as a positive integer; anything else is ignored. */
@@ -33,7 +33,7 @@ function validMargin(raw: unknown): number | undefined {
33
33
  * An invalid margin degrades to the default and reports one warning. Pi's automatic
34
34
  * threshold/overflow compaction itself resets immediately, with no model turn.
35
35
  */
36
- export function deriveThresholds(reserveTokens: number, margins: PiContextMargins): { thresholds: ResolvedThresholds; warnings: string[] } {
36
+ export function deriveThresholds(reserveTokens: number, margins: PiContextSettings): { thresholds: ResolvedThresholds; warnings: string[] } {
37
37
  const warnings: string[] = [];
38
38
  const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
39
39
  let reminderMargin: number;
@@ -48,6 +48,35 @@ export function deriveThresholds(reserveTokens: number, margins: PiContextMargin
48
48
  return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
49
49
  }
50
50
 
51
+ export type DreamerSetting = { pattern?: string; warnings: string[] };
52
+
53
+ /**
54
+ * `pi-context.dreamer` is a non-empty model pattern. Anything else present is ignored
55
+ * with one warning; absent means no configured pattern, so the automatic model applies.
56
+ */
57
+ export function deriveDreamer(settings: PiContextSettings): DreamerSetting {
58
+ const raw = settings.dreamer;
59
+ if (raw === undefined) return { warnings: [] };
60
+ if (typeof raw !== "string" || raw.trim().length === 0) {
61
+ return { warnings: [`pi-context: ${PI_CONTEXT_SETTINGS_KEY}.${PI_CONTEXT_DREAMER_KEY} must be a non-empty string; ignoring it.`] };
62
+ }
63
+ return { pattern: raw.trim(), warnings: [] };
64
+ }
65
+
66
+ /**
67
+ * Resolve the configurable dreamer model from Pi settings for a CLI invocation: global
68
+ * `~/.pi/agent/settings.json` merged with the project's `.pi/settings.json`, project
69
+ * values winning per key. A settings read failure degrades to no pattern with one warning.
70
+ */
71
+ export function readDreamerSettings(cwd = process.cwd()): DreamerSetting {
72
+ try {
73
+ const settingsManager = SettingsManager.create(cwd, undefined, { projectTrusted: true });
74
+ return deriveDreamer(mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
75
+ } catch (error) {
76
+ return { warnings: [`pi-context: could not read settings; using the automatic dreamer model (${String(error)}).`] };
77
+ }
78
+ }
79
+
51
80
  let cached: ResolvedThresholds | undefined;
52
81
 
53
82
  /**