@wierdbytes/pi-dream 0.0.0-stage → 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - **Fix:** a note is no longer superseded or deprecated on evidence older than its own — the curator and the fact check see when each note was last observed, and the report flags replacements of notes with newer evidence.
6
+ - **Fix:** on Linux, paths under `/home/<user>` in shell commands now count when a session is matched to its project.
7
+
8
+ ## 0.1.0
9
+
10
+ - **New:** dreaming — a background pi run distills new sessions into evidence-backed memory notes: per-session cards (Sonnet 5.5), a curator that proposes add / update / supersede / deprecate / confirm operations (Opus 5.5), and an independent fact check of every operation against the original transcript entries (GPT-6.1).
11
+ - **New:** every change goes to a pull request in your private memory repo (`~/.pi/memory` → GitHub); comments on the PR are applied by the next dream, closing it rejects its notes.
12
+ - **New:** approved memory is injected into new sessions as a small `<memory>` index (global + current project, token budgets enforced), frozen per session so the prompt cache keeps hitting.
13
+ - **New:** `memory_search` (notes and past sessions), `memory_read` (notes and transcript entries) and `memory_inbox` (keep something for the next dream) tools.
14
+ - **New:** notes go to the project a session was about — sessions started in a workspace folder (`~/me/dev`, `~`) belong to the project whose files they worked with; `global` is kept for general preferences and machine-wide setup, and a single project's findings never land there without your own words.
15
+ - **New:** notes can be moved between scopes (by the curator, the fact checker or a PR comment).
16
+ - **New:** AGENTS.md / CLAUDE.md change proposals as pull requests in the project's own repository, one open proposal per repository.
17
+ - **New:** `/dream` (status, run, review, dry, init, log, index), `/memory` (status, sync), `/remember`, and the `--no-memory` flag.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leo Henon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,103 @@
1
- # Temporary Holding Version
1
+ # @wierdbytes/pi-dream
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Offline memory consolidation — "dreaming" — for the [pi](https://pi.dev) coding agent.
4
+
5
+ While you work, pi sessions pile up in `~/.pi/agent/sessions`. Every so often pi-dream "falls asleep" in a background pi process, reads the new sessions, and distills what future sessions will need into a few small, evidence-backed memory notes. **Every change goes to a pull request in your private memory repository**; only what you merge is loaded into new sessions — as a small index, within a token budget, frozen per session so the prompt cache keeps hitting.
6
+
7
+ Built after Anthropic's [The components of agent memory](https://www.anthropic.com/research/components-of-agent-memory) and the evidence on what makes agent memory hurt: wrong or stale notes, full-text rewrites, bloated always-on context.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install npm:@wierdbytes/pi-dream
13
+ ```
14
+
15
+ Then, in pi:
16
+
17
+ ```text
18
+ /dream init
19
+ ```
20
+
21
+ This creates `~/.pi/memory` (a git repo), a **private** GitHub repository `<you>/pi-memory` as its `origin` (needs the `gh` CLI, logged in), a `pi-dream` label, and `~/.pi/agent/@wierdbytes/pi-dream/settings.json`.
22
+
23
+ ## How a dream works
24
+
25
+ 1. **Digests.** Every session (active branch) becomes a compact digest: user messages, assistant text, one line per tool call, tool-result heads, errors; no images, no thinking; credentials scrubbed. Digests are also the corpus for `memory_search`.
26
+ 2. **Cards** (`anthropic/claude-sonnet-5-5`). Each new or grown session gets a card: what happened, how it ended, the user's feedback (verbatim), candidate learnings — each citing digest entry ids.
27
+ 3. **Curator** (`anthropic/claude-opus-5-5`, per project). Sees the current notes (global + project), the cards, *history hits* (older sessions that mention a candidate, to judge recurrence), other projects the evidence points to, inbox items, the project's AGENTS.md, and what you rejected before. Proposes operations: `add`, `update` (also moves a note to another scope), `supersede`, `deprecate`, `confirm`, `skill`, `agents_md`.
28
+
29
+ **Scope.** A session belongs to the project it was about, not to the folder pi was started in: when pi runs in a workspace folder (`~/me/dev`, `~` — a folder that contains other projects), the session goes to the project whose files it worked with most. Only real projects count (git repositories or folders with markers such as `.pi`, `AGENTS.md`, `package.json`); `~/Downloads` or `/tmp` never become one. `global` is only for your general preferences and machine-wide setup; a topic used in several projects goes to each of them. The fact checker can move a note out of global, and code refuses global notes whose evidence comes from a single project without your own words.
30
+ 4. **Fact check** (`openai-codex/gpt-6.1-sol`). Every cited entry is looked up in the raw session; operations citing entries that do not exist are dropped. An independent model checks each operation against those excerpts: supported, partial (with a fix), unsupported, contradicted, unsafe.
31
+ 5. **Code applies** what passed — item by item, never a full rewrite; near-duplicates merge into the existing note; superseded notes stay with `valid_until`; credentials and prompt-injection patterns are refused. `MEMORY.md` indexes are regenerated from note front matter within the token budgets.
32
+ 6. **Pull request.** Changes are committed on a `pi-dream/…` branch with a report — every change with its rationale, evidence and verdict, plus what the fact check rejected. While a PR is open, later dreams add commits to it.
33
+ 7. **AGENTS.md / CLAUDE.md** edits become pull requests in the project's own repository (from a temporary worktree, your working copy is never touched; public repos get no session details). One open proposal per repository: no new one is made while it awaits review.
34
+
35
+ Reviewing:
36
+
37
+ - **Merge** — the changes become memory.
38
+ - **Edit notes in the PR** — the next dream continues from your version.
39
+ - **Comment on the PR** — an instruction for the dream (edit, drop, move to another project); `/dream review` applies new comments right away without processing new sessions.
40
+ - **Close without merging** — the notes count as rejected and are not proposed again without new evidence.
41
+
42
+ ## In a session
43
+
44
+ - A `<memory>` section with the approved global index and the current project's index (built on the first turn, frozen for the session, replayed on resume). Nothing is injected while memory is empty.
45
+ - `memory_search` — notes and past-session digests (BM25, Russian + English).
46
+ - `memory_read` — a note (`m-xxxxxx`) with its evidence, or a past session entry (`s:<session>#<entry>`).
47
+ - `memory_inbox` — keep something for the next dream; it becomes memory only after review.
48
+ - `/remember <text>` — the same, from you.
49
+ - `/memory` — what this session got; `/memory sync` — pull merged memory now.
50
+ - `/dream` — status; `/dream run [--since <days>] [--max <sessions>]`, `/dream review` (apply new PR comments now), `/dream dry` (no push, report and worktree stay local), `/dream index` (rebuild digests), `/dream log`, `/dream init`.
51
+ - `--no-memory` — no injection and no memory tools for this run (`PI_DREAM_DISABLE=1` does the same).
52
+
53
+ ## When it dreams
54
+
55
+ pi is the scheduler: when a session starts and a dream is due — at least `schedule.minHoursBetween` hours (20) since the last one and `schedule.minNewSessions` (3) new sessions or inbox items — a detached headless pi process runs it:
56
+
57
+ ```text
58
+ pi -p --no-session --no-extensions -e <childExtensions…> -e runner.ts "/pi-dream-run …"
59
+ ```
60
+
61
+ It survives the interactive pi exiting, holds a lock (one dream at a time across all pi processes; stale locks are taken over), and loads only the extensions listed in `dream.childExtensions` (default: `@wierdbytes/pi-anthropic`, so Claude Pro/Max OAuth behaves as in your sessions). Sessions written to in the last `dream.minIdleMinutes` (30) are left for the next dream.
62
+
63
+ ## Memory repository
64
+
65
+ ```text
66
+ ~/.pi/memory/
67
+ scopes/global/{charter.md, MEMORY.md, notes/, skills/}
68
+ scopes/projects/<key>/{charter.md?, MEMORY.md, notes/, skills/, scope.json}
69
+ scopes/projects/_charter.md default purpose of project memory
70
+ reports/<run>.md
71
+ prompts/{cards,curator,review,verify}.md optional prompt overrides
72
+ .local/ gitignored: inbox, digests, cards, runs, logs, state
73
+ ```
74
+
75
+ Projects are keyed by their `origin` remote (`github.com/owner/repo`) or by path; worktrees map to their main repository. `charter.md` says what a scope's memory is *for* — it steers the dream and is never injected.
76
+
77
+ ## Settings
78
+
79
+ `~/.pi/agent/@wierdbytes/pi-dream/settings.json`:
80
+
81
+ | Key | Default | |
82
+ |---|---|---|
83
+ | `memoryDir` | `~/.pi/memory` | memory repository (`PI_DREAM_DIR` overrides) |
84
+ | `language` | `Russian` | language of notes and reports |
85
+ | `inject` | `true` | inject the approved index into sessions |
86
+ | `models.cards` / `analyze` / `verify` | Sonnet 5.5 low / Opus 5.5 high / GPT-6.1 sol medium | `{ "model": "provider/id", "thinking": "…" }` |
87
+ | `schedule` | `{ enabled: true, minHoursBetween: 20, minNewSessions: 3 }` | when a dream is due |
88
+ | `dream.firstRunLookbackDays` | `14` | how far back the first dream looks |
89
+ | `dream.maxSessionsPerRun` | `60` | the rest waits for the next dream |
90
+ | `dream.minIdleMinutes` | `30` | sessions written more recently are skipped |
91
+ | `dream.concurrency` | `4` | parallel card calls |
92
+ | `dream.maxRuntimeMinutes` | `180` | hard limit of one dream |
93
+ | `dream.childExtensions` | `["npm:@wierdbytes/pi-anthropic"]` | extensions the dream process loads |
94
+ | `budgets.globalTokens` / `projectTokens` | `1000` / `1500` | index budgets |
95
+ | `github` | `{ repo: "pi-memory", base: "main", label: "pi-dream" }` | |
96
+ | `agentsMd` | `true` | propose AGENTS.md / CLAUDE.md changes |
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ cd packages/dream && bun run test
102
+ pi -e ./index.ts # try in a session
103
+ ```
package/agentsmd.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * AGENTS.md / CLAUDE.md proposals.
3
+ *
4
+ * Edits are exact text replacements. They are applied in a temporary git
5
+ * worktree checked out from the project's default branch on `origin` — the
6
+ * user's working copy is never touched — committed on a `pi-dream/…` branch,
7
+ * pushed, and opened as a pull request in the project's own repository.
8
+ * Without a GitHub remote the edits are returned as a patch for the report.
9
+ * Public repositories get a minimal PR body: no rationale from private sessions.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { gh, ghOk, git, githubRepoOf, gitOk } from "./gitops.ts";
15
+
16
+ export interface AgentsEdit {
17
+ old: string;
18
+ new: string;
19
+ }
20
+
21
+ export const CONTEXT_FILES = ["AGENTS.md", "CLAUDE.md", "AGENTS.MD", "CLAUDE.MD"];
22
+
23
+ export function findContextFile(dir: string | undefined): { path: string; rel: string } | undefined {
24
+ if (!dir) return undefined;
25
+ for (const rel of CONTEXT_FILES) {
26
+ const path = join(dir, rel);
27
+ if (existsSync(path)) return { path, rel };
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ export function applyEdits(content: string, edits: AgentsEdit[]): { content: string; skipped: number } | { error: string } {
33
+ let out = content;
34
+ let skipped = 0;
35
+ for (const [i, e] of edits.entries()) {
36
+ if (e.old === "") {
37
+ if (e.new.trim() && out.includes(e.new.trim())) {
38
+ skipped++;
39
+ continue;
40
+ }
41
+ out = `${out.replace(/\s*$/, "")}\n\n${e.new.trim()}\n`;
42
+ continue;
43
+ }
44
+ const first = out.indexOf(e.old);
45
+ if (first < 0) {
46
+ // Already applied (e.g. proposed again while the first pull request is open).
47
+ if (e.new && out.includes(e.new)) {
48
+ skipped++;
49
+ continue;
50
+ }
51
+ return { error: `edit ${i + 1}: text not found` };
52
+ }
53
+ if (out.indexOf(e.old, first + 1) >= 0) return { error: `edit ${i + 1}: text is not unique` };
54
+ out = out.slice(0, first) + e.new + out.slice(first + e.old.length);
55
+ }
56
+ return { content: out, skipped };
57
+ }
58
+
59
+ export function renderEditsDiff(rel: string, edits: AgentsEdit[]): string {
60
+ const blocks = edits.map((e) => {
61
+ const minus = e.old ? e.old.split("\n").map((l) => `- ${l}`) : ["- (append at the end)"];
62
+ const plus = e.new.split("\n").map((l) => `+ ${l}`);
63
+ return [...minus, ...plus].join("\n");
64
+ });
65
+ return `\`${rel}\`\n\n\`\`\`diff\n${blocks.join("\n\n")}\n\`\`\``;
66
+ }
67
+
68
+ /** URL of an open pi-dream AGENTS.md pull request in the repository at `repoRoot`, if any. */
69
+ export async function openAgentsPr(repoRoot: string): Promise<string | undefined> {
70
+ const remote = await git(repoRoot, ["remote", "get-url", "origin"]);
71
+ const repo = remote.code === 0 ? githubRepoOf(remote.stdout.trim()) : undefined;
72
+ if (!repo) return undefined;
73
+ const r = await gh(["pr", "list", "--repo", repo, "--state", "open", "--json", "url,headRefName"]);
74
+ if (r.code !== 0) return undefined;
75
+ try {
76
+ return (JSON.parse(r.stdout || "[]") as Array<{ url: string; headRefName: string }>).find((p) => p.headRefName.startsWith("pi-dream/agents-"))?.url;
77
+ } catch {
78
+ return undefined;
79
+ }
80
+ }
81
+
82
+ export interface AgentsPublishInput {
83
+ /** Main repository root of the project. */
84
+ repoRoot: string;
85
+ rel: string;
86
+ edits: AgentsEdit[];
87
+ runId: string;
88
+ worktreesDir: string;
89
+ title: string;
90
+ /** Rationale lines, shown only for private repositories. */
91
+ details: string[];
92
+ log: (msg: string) => void;
93
+ }
94
+
95
+ export interface AgentsPublishResult {
96
+ url?: string;
97
+ error?: string;
98
+ patch: string;
99
+ }
100
+
101
+ async function defaultBranch(root: string, repo: string): Promise<string> {
102
+ const head = await git(root, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
103
+ if (head.code === 0 && head.stdout.trim().startsWith("origin/")) return head.stdout.trim().slice("origin/".length);
104
+ const r = await gh(["repo", "view", repo, "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]);
105
+ return r.code === 0 && r.stdout.trim() ? r.stdout.trim() : "main";
106
+ }
107
+
108
+ export async function publishAgentsEdits(input: AgentsPublishInput): Promise<AgentsPublishResult> {
109
+ const patch = renderEditsDiff(input.rel, input.edits);
110
+ const remote = await git(input.repoRoot, ["remote", "get-url", "origin"]);
111
+ const repo = remote.code === 0 ? githubRepoOf(remote.stdout.trim()) : undefined;
112
+ if (!repo) return { patch, error: "no GitHub remote: patch only" };
113
+ const wt = join(input.worktreesDir, `agents-${input.runId}-${repo.replace("/", "__")}`);
114
+ let branch = `pi-dream/agents-${input.runId}`;
115
+ try {
116
+ await gitOk(input.repoRoot, ["fetch", "origin", "--quiet"], 120_000);
117
+ const base = await defaultBranch(input.repoRoot, repo);
118
+ // One open pi-dream pull request per repository: later proposals are added to it.
119
+ const open = await gh(["pr", "list", "--repo", repo, "--state", "open", "--json", "number,url,headRefName"]);
120
+ const existing = open.code === 0
121
+ ? (JSON.parse(open.stdout || "[]") as Array<{ number: number; url: string; headRefName: string }>).find((p) => p.headRefName.startsWith("pi-dream/agents-"))
122
+ : undefined;
123
+ if (existing) {
124
+ branch = existing.headRefName;
125
+ await gitOk(input.repoRoot, ["fetch", "origin", branch, "--quiet"], 120_000);
126
+ }
127
+ mkdirSync(input.worktreesDir, { recursive: true });
128
+ await gitOk(input.repoRoot, ["worktree", "add", "--detach", wt, existing ? `origin/${branch}` : `origin/${base}`], 120_000);
129
+ const file = join(wt, input.rel);
130
+ if (!existsSync(file)) return { patch, error: `${input.rel} does not exist on origin/${base}` };
131
+ const before = readFileSync(file, "utf-8");
132
+ const res = applyEdits(before, input.edits);
133
+ if ("error" in res) return { patch, error: `${res.error} on ${existing ? branch : `origin/${base}`}` };
134
+ if (res.content === before) {
135
+ input.log(` agents_md: already proposed in ${existing?.url ?? repo}`);
136
+ return { url: existing?.url, patch, error: existing ? undefined : "nothing to change" };
137
+ }
138
+ writeFileSync(file, res.content, "utf-8");
139
+ if (!existing) await gitOk(wt, ["checkout", "-b", branch]);
140
+ await gitOk(wt, ["add", input.rel]);
141
+ await gitOk(wt, ["commit", "--no-verify", "-m", `${input.rel}: ${input.title}\n\nProposed by pi-dream.`]);
142
+ await gitOk(wt, ["push", "origin", `HEAD:refs/heads/${branch}`], 120_000);
143
+ if (existing) {
144
+ input.log(` agents_md: added to ${existing.url}`);
145
+ return { url: existing.url, patch };
146
+ }
147
+ const vis = await gh(["repo", "view", repo, "--json", "visibility", "--jq", ".visibility"]);
148
+ const isPublic = vis.code !== 0 || vis.stdout.trim().toUpperCase() !== "PRIVATE";
149
+ const body = [
150
+ `Proposed by [pi-dream](https://github.com/wierdbytes/pi-wierd-stuff/tree/master/packages/dream) from the owner's pi session history.`,
151
+ "",
152
+ ...(isPublic ? ["Details are in the owner's private memory review."] : input.details.map((d) => `- ${d}`)),
153
+ "",
154
+ "<!-- pi-dream:agents -->",
155
+ ].join("\n");
156
+ const url = await ghOk(["pr", "create", "--repo", repo, "--base", base, "--head", branch, "--title", `${input.rel}: ${input.title}`, "--body", body], {
157
+ timeoutMs: 120_000,
158
+ });
159
+ input.log(` agents_md PR: ${url}`);
160
+ return { url: url.split("\n").pop()?.trim(), patch };
161
+ } catch (err) {
162
+ return { patch, error: err instanceof Error ? err.message : String(err) };
163
+ } finally {
164
+ if (existsSync(wt)) {
165
+ await git(input.repoRoot, ["worktree", "remove", "--force", wt]);
166
+ rmSync(wt, { recursive: true, force: true });
167
+ }
168
+ await git(input.repoRoot, ["worktree", "prune"]);
169
+ if (branch.startsWith("pi-dream/agents-")) await git(input.repoRoot, ["branch", "-D", branch]);
170
+ }
171
+ }
package/commands.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * /dream · /memory · /remember
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { initMemory } from "./setup.ts";
7
+ import type { Runtime } from "./runtime.ts";
8
+
9
+ const DREAM_SUB = [
10
+ { value: "status", label: "status", description: "Memory, last dream, open review PR" },
11
+ { value: "run", label: "run", description: "Dream now in the background (--since <days> --max <sessions>)" },
12
+ { value: "review", label: "review", description: "Apply new comments on the review PR now (no new sessions)" },
13
+ { value: "dry", label: "dry", description: "Dream without pushing: report + worktree stay local" },
14
+ { value: "index", label: "index", description: "Rebuild session digests (search corpus) in the background" },
15
+ { value: "log", label: "log", description: "Tail of the latest dream log" },
16
+ { value: "init", label: "init", description: "Create the memory repo, its private GitHub remote and settings" },
17
+ ];
18
+
19
+ const MEMORY_SUB = [
20
+ { value: "status", label: "status", description: "What this session got from memory" },
21
+ { value: "sync", label: "sync", description: "Pull approved memory from GitHub now" },
22
+ ];
23
+
24
+ function completions(list: typeof DREAM_SUB) {
25
+ return (prefix: string) => {
26
+ const p = prefix.trim().toLowerCase();
27
+ const items = list.filter((c) => c.value.startsWith(p));
28
+ return items.length ? items : null;
29
+ };
30
+ }
31
+
32
+ export function registerCommands(pi: ExtensionAPI, rt: Runtime): void {
33
+ pi.registerCommand("dream", {
34
+ description: "pi-dream: status, run, review, dry, index, log, init",
35
+ getArgumentCompletions: completions(DREAM_SUB),
36
+ handler: async (args, ctx) => {
37
+ const [cmd = "status", ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
38
+ switch (cmd) {
39
+ case "status":
40
+ return ctx.ui.notify(rt.status(ctx), "info");
41
+ case "log":
42
+ return ctx.ui.notify(rt.latestLog(), "info");
43
+ case "init": {
44
+ const lines: string[] = [];
45
+ try {
46
+ const res = await initMemory(rt.cfg, (m) => lines.push(m));
47
+ lines.push(res.repo ? `ready: https://github.com/${res.repo}` : "ready (no GitHub remote)");
48
+ ctx.ui.notify(`pi-dream init\n${lines.join("\n")}`, "info");
49
+ } catch (err) {
50
+ ctx.ui.notify(`pi-dream init failed: ${err instanceof Error ? err.message : String(err)}\n${lines.join("\n")}`, "error");
51
+ }
52
+ return;
53
+ }
54
+ case "run":
55
+ case "review":
56
+ case "dry":
57
+ case "index": {
58
+ if (rt.dreaming()) return ctx.ui.notify("pi-dream: a dream is already running (/dream log)", "warning");
59
+ const extra = cmd === "dry" ? ["--dry-run", ...rest] : cmd === "index" ? ["--reindex"] : cmd === "review" ? ["--review-only"] : rest;
60
+ const { runId, log } = rt.startDream(extra);
61
+ return ctx.ui.notify(`pi-dream: ${cmd} started (${runId})\nlog: ${log}`, "info");
62
+ }
63
+ default:
64
+ return ctx.ui.notify("Usage: /dream [status|run|review|dry|index|log|init]", "info");
65
+ }
66
+ },
67
+ });
68
+
69
+ pi.registerCommand("memory", {
70
+ description: "pi-dream memory: status, sync",
71
+ getArgumentCompletions: completions(MEMORY_SUB),
72
+ handler: async (args, ctx) => {
73
+ const cmd = (args ?? "").trim().toLowerCase() || "status";
74
+ if (cmd === "sync") {
75
+ const res = await rt.sync(true);
76
+ return ctx.ui.notify(`pi-dream sync: ${res}${res === "updated" ? " — new sessions get the merged memory" : ""}`, "info");
77
+ }
78
+ const section = await rt.memorySection(ctx);
79
+ return ctx.ui.notify(`${rt.status(ctx)}${section ? `\n\n<memory>\n${section}\n</memory>` : ""}`, "info");
80
+ },
81
+ });
82
+
83
+ pi.registerCommand("remember", {
84
+ description: "Save a note to the pi-dream inbox for the next dream to review",
85
+ handler: async (args, ctx) => {
86
+ const text = (args ?? "").trim();
87
+ if (!text) return ctx.ui.notify("Usage: /remember <what future sessions should know>", "info");
88
+ const id = await rt.inbox(ctx, text, "user");
89
+ ctx.ui.notify(`pi-dream: saved to the inbox (${id}) — the next dream will propose it for review`, "info");
90
+ },
91
+ });
92
+ }
package/config.ts ADDED
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Settings for @wierdbytes/pi-dream.
3
+ *
4
+ * Stored at `~/.pi/agent/@wierdbytes/pi-dream/settings.json` (under
5
+ * `PI_CODING_AGENT_DIR` when pi is pointed elsewhere). Missing, unknown or
6
+ * malformed fields fall back to defaults, so a hand-edited file can never
7
+ * break the extension. `PI_DREAM_DIR` overrides `memoryDir`.
8
+ */
9
+
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
14
+
15
+ /** Mirrors pi-ai's thinking levels; kept local so this module has no pi-ai runtime import. */
16
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
17
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
18
+
19
+ export type Stage = "cards" | "analyze" | "verify";
20
+ export const STAGES: readonly Stage[] = ["cards", "analyze", "verify"];
21
+
22
+ export interface StageModel {
23
+ /** `<provider>/<model-id>` */
24
+ model: string;
25
+ thinking: ThinkingLevel;
26
+ }
27
+
28
+ export interface DreamConfig {
29
+ /** Root of the memory git repository. */
30
+ memoryDir: string;
31
+ /** Language the dream writes notes and reports in. */
32
+ language: string;
33
+ /** Inject the approved memory index into sessions. */
34
+ inject: boolean;
35
+ models: Record<Stage, StageModel>;
36
+ schedule: {
37
+ /** Start a background dream from pi when one is due. */
38
+ enabled: boolean;
39
+ minHoursBetween: number;
40
+ /** New or grown sessions (or inbox items) needed before a dream is due. */
41
+ minNewSessions: number;
42
+ };
43
+ dream: {
44
+ /** How far back the very first dream looks. */
45
+ firstRunLookbackDays: number;
46
+ /** Sessions summarized per dream; the rest wait for the next one. */
47
+ maxSessionsPerRun: number;
48
+ /** Sessions written to more recently than this are treated as still in use. */
49
+ minIdleMinutes: number;
50
+ /** Parallel session-card calls. */
51
+ concurrency: number;
52
+ maxRuntimeMinutes: number;
53
+ /**
54
+ * Extensions loaded into the headless dream process (it runs with
55
+ * `--no-extensions`). Use it for provider/auth extensions. `npm:<name>`
56
+ * resolves to pi's installed copy; missing entries are skipped.
57
+ */
58
+ childExtensions: string[];
59
+ };
60
+ budgets: {
61
+ /** Token budget of the global index injected into every session. */
62
+ globalTokens: number;
63
+ /** Token budget of a project's index injected into its sessions. */
64
+ projectTokens: number;
65
+ };
66
+ github: {
67
+ /** Repository name `/dream init` creates for the memory remote. */
68
+ repo: string;
69
+ base: string;
70
+ label: string;
71
+ };
72
+ /** Propose AGENTS.md / CLAUDE.md changes as pull requests in project repositories. */
73
+ agentsMd: boolean;
74
+ }
75
+
76
+ export function defaults(): DreamConfig {
77
+ return {
78
+ memoryDir: join(homedir(), ".pi", "memory"),
79
+ language: "Russian",
80
+ inject: true,
81
+ models: {
82
+ cards: { model: "anthropic/claude-sonnet-5-5", thinking: "low" },
83
+ analyze: { model: "anthropic/claude-opus-5-5", thinking: "high" },
84
+ verify: { model: "openai-codex/gpt-6.1-sol", thinking: "medium" },
85
+ },
86
+ schedule: { enabled: true, minHoursBetween: 20, minNewSessions: 3 },
87
+ dream: {
88
+ firstRunLookbackDays: 14,
89
+ maxSessionsPerRun: 60,
90
+ minIdleMinutes: 30,
91
+ concurrency: 4,
92
+ maxRuntimeMinutes: 180,
93
+ childExtensions: ["npm:@wierdbytes/pi-anthropic"],
94
+ },
95
+ budgets: { globalTokens: 1000, projectTokens: 1500 },
96
+ github: { repo: "pi-memory", base: "main", label: "pi-dream" },
97
+ agentsMd: true,
98
+ };
99
+ }
100
+
101
+ const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
102
+ const oneOf = <T extends string>(list: readonly T[], v: unknown): v is T =>
103
+ typeof v === "string" && (list as readonly string[]).includes(v);
104
+ const num = (v: unknown, min: number, max: number, fallback: number): number =>
105
+ typeof v === "number" && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : fallback;
106
+ const bool = (v: unknown, fallback: boolean): boolean => (typeof v === "boolean" ? v : fallback);
107
+ const str = (v: unknown, fallback: string): string => (typeof v === "string" && v.trim() ? v.trim() : fallback);
108
+
109
+ export function expandHome(p: string): string {
110
+ if (p === "~") return homedir();
111
+ if (p.startsWith("~/")) return join(homedir(), p.slice(2));
112
+ return resolve(p);
113
+ }
114
+
115
+ function stageModel(raw: unknown, fallback: StageModel): StageModel {
116
+ if (!isObj(raw)) return { ...fallback };
117
+ const model = typeof raw.model === "string" && raw.model.includes("/") ? raw.model.trim() : fallback.model;
118
+ const thinking = oneOf(THINKING_LEVELS, raw.thinking) ? raw.thinking : fallback.thinking;
119
+ return { model, thinking };
120
+ }
121
+
122
+ export function sanitize(raw: unknown): DreamConfig {
123
+ const d = defaults();
124
+ if (!isObj(raw)) return d;
125
+ const cfg = d;
126
+ cfg.memoryDir = expandHome(str(raw.memoryDir, d.memoryDir));
127
+ cfg.language = str(raw.language, d.language);
128
+ cfg.inject = bool(raw.inject, d.inject);
129
+ if (isObj(raw.models)) {
130
+ for (const stage of STAGES) cfg.models[stage] = stageModel(raw.models[stage], d.models[stage]);
131
+ }
132
+ if (isObj(raw.schedule)) {
133
+ const s = raw.schedule;
134
+ cfg.schedule = {
135
+ enabled: bool(s.enabled, d.schedule.enabled),
136
+ minHoursBetween: num(s.minHoursBetween, 0, 24 * 30, d.schedule.minHoursBetween),
137
+ minNewSessions: Math.round(num(s.minNewSessions, 1, 1000, d.schedule.minNewSessions)),
138
+ };
139
+ }
140
+ if (isObj(raw.dream)) {
141
+ const s = raw.dream;
142
+ cfg.dream = {
143
+ firstRunLookbackDays: num(s.firstRunLookbackDays, 0, 3650, d.dream.firstRunLookbackDays),
144
+ maxSessionsPerRun: Math.round(num(s.maxSessionsPerRun, 1, 1000, d.dream.maxSessionsPerRun)),
145
+ minIdleMinutes: num(s.minIdleMinutes, 0, 24 * 60, d.dream.minIdleMinutes),
146
+ concurrency: Math.round(num(s.concurrency, 1, 16, d.dream.concurrency)),
147
+ maxRuntimeMinutes: num(s.maxRuntimeMinutes, 5, 24 * 60, d.dream.maxRuntimeMinutes),
148
+ childExtensions: Array.isArray(s.childExtensions)
149
+ ? s.childExtensions.filter((x): x is string => typeof x === "string" && !!x.trim())
150
+ : d.dream.childExtensions,
151
+ };
152
+ }
153
+ if (isObj(raw.budgets)) {
154
+ cfg.budgets = {
155
+ globalTokens: Math.round(num(raw.budgets.globalTokens, 0, 20000, d.budgets.globalTokens)),
156
+ projectTokens: Math.round(num(raw.budgets.projectTokens, 0, 20000, d.budgets.projectTokens)),
157
+ };
158
+ }
159
+ if (isObj(raw.github)) {
160
+ cfg.github = {
161
+ repo: str(raw.github.repo, d.github.repo),
162
+ base: str(raw.github.base, d.github.base),
163
+ label: str(raw.github.label, d.github.label),
164
+ };
165
+ }
166
+ cfg.agentsMd = bool(raw.agentsMd, d.agentsMd);
167
+ return cfg;
168
+ }
169
+
170
+ export function configPath(): string {
171
+ return join(getAgentDir(), "@wierdbytes", "pi-dream", "settings.json");
172
+ }
173
+
174
+ export function loadConfig(path: string = configPath()): DreamConfig {
175
+ let cfg: DreamConfig;
176
+ try {
177
+ cfg = existsSync(path) ? sanitize(JSON.parse(readFileSync(path, "utf-8"))) : defaults();
178
+ } catch {
179
+ cfg = defaults();
180
+ }
181
+ if (process.env.PI_DREAM_DIR) cfg.memoryDir = expandHome(process.env.PI_DREAM_DIR);
182
+ return cfg;
183
+ }
184
+
185
+ export function saveConfig(cfg: DreamConfig, path: string = configPath()): void {
186
+ mkdirSync(dirname(path), { recursive: true });
187
+ const out = { ...cfg, memoryDir: cfg.memoryDir.replace(homedir(), "~") };
188
+ writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, "utf-8");
189
+ }
190
+
191
+ /** `true` when the user disabled pi-dream for this process. */
192
+ export function disabledByEnv(): boolean {
193
+ return process.env.PI_DREAM_DISABLE === "1" || process.env.PI_DREAM_CHILD === "1";
194
+ }