@wierdbytes/pi-dream 0.0.0-stage → 0.1.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/pipeline.ts ADDED
@@ -0,0 +1,1149 @@
1
+ /**
2
+ * One dream, end to end. Runs inside a headless pi process (`runner.ts`).
3
+ *
4
+ * 1. sync the memory repo; learn from the previous pull request (merged →
5
+ * accepted, closed → its notes are rejected)
6
+ * 2. read new review comments on the open pull request
7
+ * 3. rebuild digests of changed sessions (search corpus for everything)
8
+ * 4. summarize pending sessions into cards (cheap model, cached)
9
+ * 5. review pass: turn review comments into operations
10
+ * 6. per project: the curator proposes operations (strong model) with
11
+ * history hits from older sessions; cited evidence is looked up in the raw
12
+ * sessions; an independent model checks every operation against it
13
+ * 7. code applies what passed, regenerates the indexes within budget
14
+ * 8. commit on the review branch, push, open or update the pull request;
15
+ * AGENTS.md edits become pull requests in the projects' repositories
16
+ */
17
+
18
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { type AgentsEdit, findContextFile, openAgentsPr, publishAgentsEdits, renderEditsDiff } from "./agentsmd.ts";
22
+ import { type DreamConfig, loadConfig } from "./config.ts";
23
+ import { type AgentsMdOp, applyOp, applyVotes, type IndexStat, type Op, type ParsedOps, parseOps, regenerateIndexes, Workspace } from "./curate.ts";
24
+ import { buildDigest, clipHead, digestHeader, renderDigest, renderEntryFull } from "./digest.ts";
25
+ import { gh, ghJson, ghOk, git, githubRepoOf, gitOk } from "./gitops.ts";
26
+ import { type InboxItem, readInbox } from "./inbox.ts";
27
+ import { CHARTER_FILE, GLOBAL_SCOPE, keyToDir, type MemoryPaths, memoryPaths, newRunId, projectScope, SCOPE_META_FILE, scopeDir, today } from "./layout.ts";
28
+ import { Llm } from "./llm.ts";
29
+ import { acquireLock, releaseLock } from "./lock.ts";
30
+ import { buildIndex } from "./memindex.ts";
31
+ import type { Evidence, Note } from "./notes.ts";
32
+ import { resolveProject, shortKey } from "./project.ts";
33
+ import { ProjectMap } from "./projects.ts";
34
+ import { DEFAULT_GLOBAL_CHARTER, DEFAULT_PROJECT_CHARTER, systemPrompt } from "./prompts.ts";
35
+ import { prTitle, type ReportAgents, type ReportChange, type ReportData, type ReportRejected, type ReportSession, renderReport } from "./report.ts";
36
+ import { pendingInbox, pendingSessions } from "./scheduler.ts";
37
+ import { Bm25, chunkDigest, type Doc, snippet } from "./search.ts";
38
+ import { listSessionFiles, parseSession, SessionCache, type SessionFile, sessionIdFromPath } from "./sessions.ts";
39
+ import { type DreamState, freshState, loadState, saveState } from "./state.ts";
40
+
41
+ const MINUTE = 60_000;
42
+ const DAY = 86_400_000;
43
+
44
+ export interface RunOptions {
45
+ runId?: string;
46
+ dryRun?: boolean;
47
+ sinceDays?: number;
48
+ maxSessions?: number;
49
+ reindexOnly?: boolean;
50
+ /** Apply new review comments on the open pull request; no new sessions. */
51
+ reviewOnly?: boolean;
52
+ }
53
+
54
+ export function parseRunArgs(args: string): RunOptions {
55
+ const t = args.trim().split(/\s+/).filter(Boolean);
56
+ const o: RunOptions = {};
57
+ for (let i = 0; i < t.length; i++) {
58
+ const a = t[i];
59
+ if (a === "--run-id") o.runId = t[++i];
60
+ else if (a === "--dry-run" || a === "--dry") o.dryRun = true;
61
+ else if (a === "--reindex") o.reindexOnly = true;
62
+ else if (a === "--review-only") o.reviewOnly = true;
63
+ else if (a === "--since") o.sinceDays = Number(t[++i]);
64
+ else if (a === "--max") o.maxSessions = Number(t[++i]);
65
+ }
66
+ if (o.sinceDays !== undefined && !Number.isFinite(o.sinceDays)) delete o.sinceDays;
67
+ if (o.maxSessions !== undefined && !(o.maxSessions > 0)) delete o.maxSessions;
68
+ return o;
69
+ }
70
+
71
+ // ── digests ──────────────────────────────────────────────────────────────
72
+
73
+ /** Bump when the digest format or its record changes: every digest is rebuilt. */
74
+ export const DIGEST_VERSION = 3;
75
+
76
+ export interface DigestRecord {
77
+ v?: number;
78
+ size: number;
79
+ mtimeMs: number;
80
+ sessionId: string;
81
+ /** Project of the session's cwd. */
82
+ projectKey: string;
83
+ /** Root of that project (repo root or cwd). */
84
+ root?: string;
85
+ /** The root is a real project (git repo or project markers). */
86
+ project?: boolean;
87
+ dir: string;
88
+ cwd: string;
89
+ started: string;
90
+ name?: string;
91
+ userMessages: number;
92
+ toolCalls: number;
93
+ /** Paths the session worked with, most used first. */
94
+ paths?: Array<[string, number]>;
95
+ error?: string;
96
+ }
97
+
98
+ export type DigestIndex = Record<string, DigestRecord>;
99
+
100
+ export function loadDigestIndex(path: string): DigestIndex {
101
+ try {
102
+ return existsSync(path) ? (JSON.parse(readFileSync(path, "utf-8")) as DigestIndex) : {};
103
+ } catch {
104
+ return {};
105
+ }
106
+ }
107
+
108
+ function saveJson(path: string, value: unknown): void {
109
+ mkdirSync(join(path, ".."), { recursive: true });
110
+ writeFileSync(path, `${JSON.stringify(value, null, 1)}\n`, "utf-8");
111
+ }
112
+
113
+ export function digestFile(P: MemoryPaths, rec: Pick<DigestRecord, "dir" | "sessionId">): string {
114
+ return join(P.digests, rec.dir, `${rec.sessionId}.txt`);
115
+ }
116
+
117
+ /** Project roots known from every session's cwd (existing ones only). */
118
+ export function knownProjects(digests: DigestIndex): ProjectMap {
119
+ const byRoot = new Map<string, { key: string; project: boolean }>();
120
+ for (const rec of Object.values(digests))
121
+ if (rec.root && rec.projectKey && !byRoot.has(rec.root)) byRoot.set(rec.root, { key: rec.projectKey, project: rec.project === true });
122
+ return new ProjectMap([...byRoot.entries()].filter(([r]) => existsSync(r)).map(([root, v]) => ({ root, key: v.key, project: v.project })));
123
+ }
124
+
125
+ async function updateDigests(files: SessionFile[], P: MemoryPaths, log: (m: string) => void, signal: AbortSignal): Promise<DigestIndex> {
126
+ const idx = loadDigestIndex(P.digestIndex);
127
+ const live = new Set(files.map((f) => f.path));
128
+ for (const [k, rec] of Object.entries(idx)) {
129
+ if (live.has(k)) continue;
130
+ if (rec.dir) rmSync(digestFile(P, rec), { force: true });
131
+ delete idx[k];
132
+ }
133
+ const stale = files.filter((f) => idx[f.path]?.size !== f.size || idx[f.path]?.v !== DIGEST_VERSION);
134
+ if (stale.length) log(`digests: rebuilding ${stale.length} of ${files.length} sessions`);
135
+ let n = 0;
136
+ for (const f of stale) {
137
+ if (signal.aborted) break;
138
+ try {
139
+ const s = await parseSession(f.path);
140
+ if (!s) {
141
+ idx[f.path] = {
142
+ v: DIGEST_VERSION,
143
+ size: f.size,
144
+ mtimeMs: f.mtimeMs,
145
+ sessionId: sessionIdFromPath(f.path),
146
+ projectKey: "",
147
+ dir: "",
148
+ cwd: "",
149
+ started: "",
150
+ userMessages: 0,
151
+ toolCalls: 0,
152
+ error: "no header",
153
+ };
154
+ continue;
155
+ }
156
+ const proj = await resolveProject(s.header.cwd);
157
+ const d = buildDigest(s);
158
+ const dir = keyToDir(proj.key);
159
+ const prev = idx[f.path];
160
+ if (prev?.dir && prev.dir !== dir) rmSync(digestFile(P, prev), { force: true });
161
+ const rec: DigestRecord = {
162
+ v: DIGEST_VERSION,
163
+ size: f.size,
164
+ mtimeMs: f.mtimeMs,
165
+ sessionId: s.header.id,
166
+ projectKey: proj.key,
167
+ root: proj.exists ? proj.root : undefined,
168
+ project: proj.project,
169
+ dir,
170
+ cwd: s.header.cwd,
171
+ started: s.header.timestamp,
172
+ name: s.name,
173
+ userMessages: d.meta.userMessages,
174
+ toolCalls: d.meta.toolCalls,
175
+ paths: d.meta.paths,
176
+ };
177
+ mkdirSync(join(P.digests, dir), { recursive: true });
178
+ writeFileSync(digestFile(P, rec), `${digestHeader(d.meta, proj.key)}\n${renderDigest(d)}\n`, "utf-8");
179
+ idx[f.path] = rec;
180
+ } catch (err) {
181
+ log(` ! digest ${f.path}: ${err instanceof Error ? err.message : String(err)}`);
182
+ }
183
+ if (++n % 200 === 0) {
184
+ saveJson(P.digestIndex, idx);
185
+ log(` digests ${n}/${stale.length}`);
186
+ }
187
+ }
188
+ saveJson(P.digestIndex, idx);
189
+ return idx;
190
+ }
191
+
192
+ // ── cards ────────────────────────────────────────────────────────────────
193
+
194
+ export interface Learning {
195
+ n: string;
196
+ kind: string;
197
+ scope: "global" | "project";
198
+ statement: string;
199
+ details?: string;
200
+ evidence: string[];
201
+ source: "user" | "observed";
202
+ confidence: string;
203
+ why?: string;
204
+ }
205
+
206
+ export interface Card {
207
+ sessionId: string;
208
+ file: string;
209
+ size: number;
210
+ projectKey: string;
211
+ date: string;
212
+ title: string;
213
+ summary: string;
214
+ outcome: { status: string; signals: string[] };
215
+ feedback: Array<{ entry: string; kind: string; quote: string }>;
216
+ learnings: Learning[];
217
+ memoryFeedback: Array<{ id: string; effect: string; entry?: string; note?: string }>;
218
+ }
219
+
220
+ const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
221
+ const s = (v: unknown, max = 2000): string => (typeof v === "string" ? v.trim().slice(0, max) : "");
222
+
223
+ export function validateCard(raw: unknown, ids: Set<string>, base: Pick<Card, "sessionId" | "file" | "size" | "projectKey" | "date">, fallbackTitle: string): Card {
224
+ if (!isObj(raw)) throw new Error("expected a JSON object");
225
+ const cleanId = (x: unknown) => s(x, 80).replace(/^\[|\]$/g, "");
226
+ const outcome = isObj(raw.outcome) ? raw.outcome : {};
227
+ const learnings: Learning[] = [];
228
+ (Array.isArray(raw.learnings) ? raw.learnings : []).forEach((l) => {
229
+ if (!isObj(l) || !s(l.statement)) return;
230
+ const evidence = (Array.isArray(l.evidence) ? l.evidence : []).map(cleanId).filter((id) => ids.has(id));
231
+ if (!evidence.length) return;
232
+ learnings.push({
233
+ n: `L${learnings.length + 1}`,
234
+ kind: s(l.kind, 30) || "fact",
235
+ scope: l.scope === "global" ? "global" : "project",
236
+ statement: s(l.statement, 600),
237
+ details: s(l.details, 1500) || undefined,
238
+ evidence,
239
+ source: l.source === "user" ? "user" : "observed",
240
+ confidence: s(l.confidence, 10) || "medium",
241
+ why: s(l.why, 400) || undefined,
242
+ });
243
+ });
244
+ return {
245
+ ...base,
246
+ title: s(raw.title, 120) || fallbackTitle,
247
+ summary: s(raw.summary, 800),
248
+ outcome: {
249
+ status: s(outcome.status, 20) || "unclear",
250
+ signals: (Array.isArray(outcome.signals) ? outcome.signals : []).map((x) => s(x, 300)).filter(Boolean),
251
+ },
252
+ feedback: (Array.isArray(raw.feedback) ? raw.feedback : [])
253
+ .filter(isObj)
254
+ .map((f) => ({ entry: cleanId(f.entry), kind: s(f.kind, 20), quote: s(f.quote, 400) }))
255
+ .filter((f) => ids.has(f.entry) && f.quote),
256
+ learnings,
257
+ memoryFeedback: (Array.isArray(raw.memory_feedback) ? raw.memory_feedback : [])
258
+ .filter(isObj)
259
+ .map((f) => ({ id: s(f.id, 20), effect: s(f.effect, 10), entry: cleanId(f.entry) || undefined, note: s(f.note, 300) || undefined }))
260
+ .filter((f) => /^m-[0-9a-f]{6,12}$/.test(f.id) && ["helpful", "harmful", "stale"].includes(f.effect)),
261
+ };
262
+ }
263
+
264
+ async function pool<T, R>(items: T[], n: number, fn: (t: T, i: number) => Promise<R>): Promise<R[]> {
265
+ const out: R[] = new Array(items.length);
266
+ let next = 0;
267
+ const worker = async () => {
268
+ while (next < items.length) {
269
+ const i = next++;
270
+ out[i] = await fn(items[i], i);
271
+ }
272
+ };
273
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
274
+ return out;
275
+ }
276
+
277
+ // ── evidence & verification ──────────────────────────────────────────────
278
+
279
+ interface ReviewComment {
280
+ id: string;
281
+ author: string;
282
+ at: string;
283
+ body: string;
284
+ path?: string;
285
+ line?: number;
286
+ }
287
+
288
+ interface EvidenceDeps {
289
+ sessions: SessionCache;
290
+ fileBySession: Map<string, string>;
291
+ inboxById: Map<string, InboxItem>;
292
+ comments: Map<string, ReviewComment>;
293
+ pr?: number;
294
+ }
295
+
296
+ interface Resolved {
297
+ op: Op;
298
+ evidence: Evidence[];
299
+ excerpts: string;
300
+ error?: string;
301
+ }
302
+
303
+ function findSessionFile(deps: EvidenceDeps, id: string): string | undefined {
304
+ const hit = deps.fileBySession.get(id);
305
+ if (hit || id.length < 8) return hit;
306
+ for (const [sid, file] of deps.fileBySession) if (sid.startsWith(id)) return file;
307
+ return undefined;
308
+ }
309
+
310
+ async function resolveOp(op: Op, deps: EvidenceDeps): Promise<Resolved> {
311
+ const evidence: Evidence[] = [];
312
+ const parts: string[] = [];
313
+ const fail = (error: string): Resolved => ({ op, evidence, excerpts: "", error });
314
+ if (!op.evidence.length) return fail("no evidence cited");
315
+ for (const ref of op.evidence) {
316
+ if (ref.s.startsWith("inbox:")) {
317
+ const item = deps.inboxById.get(ref.s.slice(6));
318
+ if (!item) return fail(`unknown inbox item ${ref.s}`);
319
+ const who = item.source === "user" ? "written by the user with /remember" : "saved by the agent with memory_inbox";
320
+ parts.push(`--- ${ref.s} (${who}, ${item.ts}, cwd ${item.cwd}) ---`, item.text);
321
+ if (item.sessionFile && item.entryId && existsSync(item.sessionFile)) {
322
+ const sess = await deps.sessions.get(item.sessionFile);
323
+ const i = sess?.branch.findIndex((e) => e.id === item.entryId) ?? -1;
324
+ if (sess && i >= 0) {
325
+ parts.push("(session context before the item was saved)");
326
+ for (let k = Math.max(0, i - 5); k <= i; k++) parts.push(renderEntryFull(sess.branch[k], 1200));
327
+ }
328
+ }
329
+ evidence.push({ s: ref.s, e: [], date: item.ts.slice(0, 10), kind: item.source === "user" ? "user" : "inbox" });
330
+ continue;
331
+ }
332
+ if (ref.s.startsWith("pr#")) {
333
+ const comment = ref.e.map((id) => deps.comments.get(id)).find(Boolean);
334
+ if (!comment) return fail(`unknown review comment ${ref.e.join(",")}`);
335
+ parts.push(`--- review comment ${comment.id} by ${comment.author} (${comment.at}) ---`, comment.body);
336
+ evidence.push({ s: ref.s, e: [comment.id], date: comment.at.slice(0, 10), kind: "reviewer" });
337
+ continue;
338
+ }
339
+ const file = findSessionFile(deps, ref.s);
340
+ if (!file) return fail(`unknown session ${ref.s}`);
341
+ const sess = await deps.sessions.get(file);
342
+ if (!sess) return fail(`unreadable session ${ref.s}`);
343
+ if (!ref.e.length) return fail(`no entry ids for session ${ref.s.slice(0, 8)}`);
344
+ const pos = new Map(sess.branch.map((e, i) => [e.id, i]));
345
+ const shown = new Set<number>();
346
+ let isUser = false;
347
+ let date: string | undefined;
348
+ parts.push(`--- session ${sess.header.id} (${sess.header.timestamp.slice(0, 10)}, cwd ${sess.header.cwd}) ---`);
349
+ for (const id of ref.e) {
350
+ const i = pos.get(id);
351
+ if (i === undefined) return fail(`entry ${id} is not in session ${ref.s.slice(0, 8)}`);
352
+ const e = sess.branch[i];
353
+ const isUserEntry = e.message?.role === "user";
354
+ for (let k = Math.max(0, i - (isUserEntry ? 2 : 1)); k <= i; k++) {
355
+ if (shown.has(k)) continue;
356
+ shown.add(k);
357
+ parts.push(renderEntryFull(sess.branch[k], k === i ? 4000 : 1500));
358
+ }
359
+ if (isUserEntry) isUser = true;
360
+ date ??= e.timestamp?.slice(0, 10);
361
+ }
362
+ evidence.push({ s: sess.header.id, e: [...new Set(ref.e)], date: date ?? sess.header.timestamp.slice(0, 10), kind: isUser ? "user" : "observed" });
363
+ }
364
+ return { op, evidence, excerpts: clipHead(parts.join("\n"), 16_000) };
365
+ }
366
+
367
+ interface Verdict {
368
+ ref: string;
369
+ verdict: "supported" | "partial" | "unsupported" | "contradicted";
370
+ unsafe: boolean;
371
+ reason: string;
372
+ fix?: { title?: string; body?: string; scope?: string };
373
+ }
374
+
375
+ function validateVerdicts(raw: unknown): Verdict[] {
376
+ if (!isObj(raw) || !Array.isArray(raw.verdicts)) throw new Error('expected {"verdicts": [...]}');
377
+ const kinds = ["supported", "partial", "unsupported", "contradicted"];
378
+ return raw.verdicts.filter(isObj).flatMap((v) => {
379
+ const verdict = s(v.verdict, 20);
380
+ if (!s(v.ref, 40) || !kinds.includes(verdict)) return [];
381
+ const scope = s(v.fix && isObj(v.fix) ? v.fix.scope : undefined, 200);
382
+ const fix = isObj(v.fix)
383
+ ? {
384
+ title: s(v.fix.title, 300) || undefined,
385
+ body: s(v.fix.body, 4000) || undefined,
386
+ scope: scope === "global" || scope === "project" || scope.startsWith("project:") ? scope : undefined,
387
+ }
388
+ : undefined;
389
+ return [{ ref: s(v.ref, 40), verdict: verdict as Verdict["verdict"], unsafe: v.unsafe === true, reason: s(v.reason, 600), fix }];
390
+ });
391
+ }
392
+
393
+ function noteForPrompt(n: Note) {
394
+ return {
395
+ id: n.id,
396
+ type: n.type,
397
+ index: n.index,
398
+ title: n.title,
399
+ ...(n.when ? { when: n.when } : {}),
400
+ body: clipHead(n.body, 700),
401
+ evidence: n.evidence.length,
402
+ updated: n.updated,
403
+ ...(n.pinned ? { pinned: true } : {}),
404
+ ...(n.stats.helpful || n.stats.harmful ? { stats: n.stats } : {}),
405
+ };
406
+ }
407
+
408
+ function opTitle(op: Op): string {
409
+ switch (op.op) {
410
+ case "add":
411
+ return op.title;
412
+ case "update":
413
+ return op.title ?? op.id;
414
+ case "supersede":
415
+ return op.replacement.title;
416
+ case "skill":
417
+ return op.name;
418
+ case "agents_md":
419
+ return `AGENTS.md (${op.edits.length} edit(s))`;
420
+ default:
421
+ return op.id;
422
+ }
423
+ }
424
+
425
+ function proposalForCheck(op: Op, ws: Workspace) {
426
+ const current = "id" in op ? ws.find(op.id) : undefined;
427
+ const base = { ref: op.ref, op: op.op, rationale: op.rationale, ...(current ? { current_note: noteForPrompt(current) } : {}) };
428
+ switch (op.op) {
429
+ case "add":
430
+ return { ...base, scope: op.scope, proposal: { type: op.type, title: op.title, body: op.body, when: op.when, index: op.index } };
431
+ case "update":
432
+ return { ...base, ...(op.scope ? { scope: op.scope } : {}), proposal: { type: op.type, title: op.title, body: op.body, when: op.when, index: op.index } };
433
+ case "supersede":
434
+ return { ...base, proposal: op.replacement };
435
+ case "skill":
436
+ return { ...base, scope: op.scope, proposal: { name: op.name, description: op.description, body: op.body } };
437
+ case "agents_md":
438
+ return { ...base, proposal: { edits: op.edits } };
439
+ default:
440
+ return base;
441
+ }
442
+ }
443
+
444
+ async function verifyOps(
445
+ items: Resolved[],
446
+ ws: Workspace,
447
+ llm: Llm,
448
+ cfg: DreamConfig,
449
+ root: string,
450
+ save: (name: string, v: unknown) => void,
451
+ tag: string,
452
+ scopes: { project: string; other_projects: string[] },
453
+ ) {
454
+ const verdicts = new Map<string, Verdict>();
455
+ const batches: Resolved[][] = [];
456
+ let cur: Resolved[] = [];
457
+ let size = 0;
458
+ for (const r of items) {
459
+ const sz = r.excerpts.length + 1500;
460
+ if (cur.length && (cur.length >= 10 || size + sz > 110_000)) {
461
+ batches.push(cur);
462
+ cur = [];
463
+ size = 0;
464
+ }
465
+ cur.push(r);
466
+ size += sz;
467
+ }
468
+ if (cur.length) batches.push(cur);
469
+ const system = systemPrompt("verify", { language: cfg.language }, root);
470
+ for (const [i, batch] of batches.entries()) {
471
+ const input = { scopes, proposals: batch.map((r) => ({ ...proposalForCheck(r.op, ws), excerpts: r.excerpts })) };
472
+ const out = await llm.json("verify", cfg.models.verify, system, JSON.stringify(input, null, 1), validateVerdicts, { maxTokens: 32_000, timeoutMs: 12 * MINUTE });
473
+ save(`verify-${tag}-${i + 1}.json`, { input, out });
474
+ for (const v of out) verdicts.set(v.ref, v);
475
+ }
476
+ return verdicts;
477
+ }
478
+
479
+ function decide(v: Verdict | undefined): { apply: boolean; fix?: Verdict["fix"]; reason: string } {
480
+ if (!v) return { apply: false, reason: "no verdict from the fact check" };
481
+ if (v.unsafe) return { apply: false, reason: `unsafe — ${v.reason}` };
482
+ if (v.verdict === "supported") return { apply: true, reason: v.reason };
483
+ if (v.verdict === "partial" && (v.fix?.title || v.fix?.body || v.fix?.scope)) return { apply: true, fix: v.fix, reason: v.reason };
484
+ return { apply: false, reason: `${v.verdict} — ${v.reason}` };
485
+ }
486
+
487
+ // ── GitHub ───────────────────────────────────────────────────────────────
488
+
489
+ async function fetchReviewComments(repo: string, pr: number, since: string | undefined): Promise<ReviewComment[]> {
490
+ const out: ReviewComment[] = [];
491
+ const view = await ghJson<{
492
+ comments?: Array<{ id: string; author?: { login?: string }; body?: string; createdAt?: string }>;
493
+ reviews?: Array<{ id: string; author?: { login?: string }; body?: string; submittedAt?: string }>;
494
+ }>(["pr", "view", String(pr), "--repo", repo, "--json", "comments,reviews"]);
495
+ for (const c of view.comments ?? []) out.push({ id: String(c.id), author: c.author?.login ?? "?", at: c.createdAt ?? "", body: c.body ?? "" });
496
+ for (const r of view.reviews ?? []) out.push({ id: String(r.id), author: r.author?.login ?? "?", at: r.submittedAt ?? "", body: r.body ?? "" });
497
+ const inline = await gh(["api", `repos/${repo}/pulls/${pr}/comments`, "--paginate", "--jq", ".[] | @json"]);
498
+ if (inline.code === 0) {
499
+ for (const line of inline.stdout.split("\n")) {
500
+ if (!line.trim()) continue;
501
+ try {
502
+ const c = JSON.parse(line) as { id: number; path?: string; line?: number; body?: string; created_at?: string; user?: { login?: string } };
503
+ out.push({ id: String(c.id), author: c.user?.login ?? "?", at: c.created_at ?? "", body: c.body ?? "", path: c.path, line: c.line });
504
+ } catch {
505
+ // not a comment object
506
+ }
507
+ }
508
+ }
509
+ return out
510
+ .filter((c) => c.body.trim() && !c.body.includes("<!-- pi-dream:") && (!since || c.at > since))
511
+ .sort((a, b) => a.at.localeCompare(b.at));
512
+ }
513
+
514
+ /** Ids of the notes committed at `ref` (e.g. the approved `origin/main`). */
515
+ async function noteIdsAt(root: string, ref: string): Promise<Set<string>> {
516
+ const ids = new Set<string>();
517
+ const ls = await git(root, ["ls-tree", "-r", "--name-only", ref, "scopes"]);
518
+ if (ls.code !== 0) return ids;
519
+ for (const path of ls.stdout.split("\n").filter((p) => /\/notes\/[^/]+\.md$/.test(p))) {
520
+ const show = await git(root, ["show", `${ref}:${path}`]);
521
+ const m = /^id:\s*(m-[0-9a-f]{6,12})\s*$/m.exec(show.stdout);
522
+ if (m) ids.add(m[1]);
523
+ }
524
+ return ids;
525
+ }
526
+
527
+ /** Remove worktrees left by dry runs or crashed dreams (older than a day). */
528
+ async function cleanupWorktrees(root: string, P: MemoryPaths, log: (m: string) => void): Promise<void> {
529
+ if (existsSync(P.worktrees)) {
530
+ for (const name of readdirSync(P.worktrees)) {
531
+ const dir = join(P.worktrees, name);
532
+ try {
533
+ if (Date.now() - statSync(dir).mtimeMs < DAY) continue;
534
+ } catch {
535
+ continue;
536
+ }
537
+ await git(root, ["worktree", "remove", "--force", dir]);
538
+ rmSync(dir, { recursive: true, force: true });
539
+ await git(root, ["branch", "-D", `dream-${name}`]);
540
+ log(` removed old worktree ${name}`);
541
+ }
542
+ }
543
+ await git(root, ["worktree", "prune"]);
544
+ }
545
+
546
+ async function ffBase(root: string, base: string, log: (m: string) => void): Promise<void> {
547
+ const branch = (await git(root, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
548
+ if (branch !== base) return log(` memory repo is on ${branch}, not ${base}: not fast-forwarding`);
549
+ if ((await git(root, ["status", "--porcelain"])).stdout.trim()) return log(" memory repo has local changes: not fast-forwarding");
550
+ const r = await git(root, ["merge", "--ff-only", `origin/${base}`]);
551
+ if (r.code !== 0) log(` fast-forward of ${base} failed: ${r.stderr.trim()}`);
552
+ }
553
+
554
+ // ── the dream ────────────────────────────────────────────────────────────
555
+
556
+ export async function runDream(ctx: ExtensionContext, opts: RunOptions): Promise<void> {
557
+ const cfg = loadConfig();
558
+ const root = cfg.memoryDir;
559
+ const P = memoryPaths(root);
560
+ const runId = opts.runId ?? newRunId();
561
+ const runDir = join(P.runs, runId);
562
+ mkdirSync(runDir, { recursive: true });
563
+ const logFile = join(runDir, "log.txt");
564
+ const log = (m: string) => {
565
+ const line = `[${new Date().toISOString()}] ${m}`;
566
+ console.log(line);
567
+ appendFileSync(logFile, `${line}\n`);
568
+ };
569
+ if (!existsSync(join(root, ".git"))) {
570
+ log(`memory repo not initialized at ${root}: run /dream init`);
571
+ return;
572
+ }
573
+ const maxMs = cfg.dream.maxRuntimeMinutes * MINUTE;
574
+ if (!acquireLock(P.lock, runId, maxMs * 1.5)) {
575
+ log("another dream is running; exiting");
576
+ return;
577
+ }
578
+ const controller = new AbortController();
579
+ const timer = setTimeout(() => controller.abort(), maxMs);
580
+ const state = loadState(P.state) ?? freshState(opts.sinceDays ?? cfg.dream.firstRunLookbackDays);
581
+ if (opts.sinceDays !== undefined) state.cutoffMs = Math.min(state.cutoffMs, Date.now() - opts.sinceDays * DAY);
582
+ // Dry runs and reindexing leave the dream state alone: they are not dreams.
583
+ // Review-only runs save what they consumed but do not move the dream schedule.
584
+ const persist = !opts.dryRun && !opts.reindexOnly;
585
+ const track = persist && !opts.reviewOnly;
586
+ if (track) {
587
+ state.lastRun = { id: runId, startedAt: new Date().toISOString(), status: "running" };
588
+ saveState(P.state, state);
589
+ }
590
+ log(`dream ${runId}${opts.dryRun ? " (dry run)" : ""}${opts.reindexOnly ? " (reindex only)" : ""} — memory ${root}`);
591
+ try {
592
+ const result = await dream(ctx, cfg, P, state, opts, runId, runDir, log, controller.signal);
593
+ if (track) state.lastRun = { id: runId, startedAt: state.lastRun?.startedAt ?? new Date().toISOString(), finishedAt: new Date().toISOString(), ...result };
594
+ log(`done: ${result.status}${result.pr ? ` — ${result.pr}` : ""}`);
595
+ } catch (err) {
596
+ const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
597
+ log(`ERROR: ${msg}`);
598
+ if (track)
599
+ state.lastRun = { id: runId, startedAt: state.lastRun?.startedAt ?? new Date().toISOString(), finishedAt: new Date().toISOString(), status: "error", error: msg.split("\n")[0] };
600
+ } finally {
601
+ clearTimeout(timer);
602
+ if (persist) saveState(P.state, state);
603
+ releaseLock(P.lock);
604
+ }
605
+ }
606
+
607
+ type DreamResult = { status: "ok" | "noop"; pr?: string; sessions?: number; cost?: number };
608
+
609
+ async function dream(
610
+ ctx: ExtensionContext,
611
+ cfg: DreamConfig,
612
+ P: MemoryPaths,
613
+ state: DreamState,
614
+ opts: RunOptions,
615
+ runId: string,
616
+ runDir: string,
617
+ log: (m: string) => void,
618
+ signal: AbortSignal,
619
+ ): Promise<DreamResult> {
620
+ const root = cfg.memoryDir;
621
+ const date = today();
622
+ const save = (name: string, v: unknown) => saveJson(join(runDir, name), v);
623
+ const problems: string[] = [];
624
+
625
+ // 1. sync, previous pull request
626
+ const remoteUrl = (await git(root, ["remote", "get-url", "origin"])).stdout.trim();
627
+ const repo = githubRepoOf(remoteUrl);
628
+ if (remoteUrl) await gitOk(root, ["fetch", "origin", "--prune", "--quiet"], 120_000);
629
+ let openPr = state.openPr;
630
+ let mergedPr: number | undefined;
631
+ if (repo && openPr) {
632
+ const v = await ghJson<{ state: string; url: string }>(["pr", "view", String(openPr.number), "--repo", repo, "--json", "state,url"]);
633
+ if (v.state === "MERGED") {
634
+ log(`PR #${openPr.number} was merged`);
635
+ mergedPr = openPr.number;
636
+ openPr = undefined;
637
+ } else if (v.state === "CLOSED") {
638
+ log(`PR #${openPr.number} was closed without merging: its notes count as rejected`);
639
+ for (const run of state.runs.filter((r) => r.pr === openPr?.number))
640
+ for (const a of run.added) state.rejected.push({ title: a.title, at: date, pr: run.pr });
641
+ state.runs = state.runs.filter((r) => r.pr !== openPr?.number);
642
+ openPr = undefined;
643
+ }
644
+ }
645
+ if (repo && !openPr) {
646
+ const list = await ghJson<Array<{ number: number; url: string; headRefName: string }>>([
647
+ "pr", "list", "--repo", repo, "--state", "open", "--label", cfg.github.label, "--json", "number,url,headRefName",
648
+ ]);
649
+ if (list[0]) openPr = { number: list[0].number, url: list[0].url, branch: list[0].headRefName };
650
+ }
651
+ if (!opts.dryRun) state.openPr = openPr;
652
+ if (remoteUrl) await ffBase(root, cfg.github.base, log);
653
+
654
+ // 2. review comments
655
+ let feedback: ReviewComment[] = [];
656
+ if (repo && openPr) {
657
+ feedback = await fetchReviewComments(repo, openPr.number, state.reviewSeen[String(openPr.number)]);
658
+ if (feedback.length) log(`${feedback.length} new review comment(s) on PR #${openPr.number}`);
659
+ }
660
+
661
+ // 3. digests
662
+ const files = listSessionFiles();
663
+ const digests = await updateDigests(files, P, log, signal);
664
+ if (opts.reindexOnly) return { status: "noop" };
665
+ if (opts.reviewOnly && !feedback.length) {
666
+ log("review only: no new review comments");
667
+ return { status: "noop" };
668
+ }
669
+ const fileBySession = new Map<string, string>();
670
+ for (const [file, rec] of Object.entries(digests)) if (rec.sessionId) fileBySession.set(rec.sessionId, file);
671
+ const projects = knownProjects(digests);
672
+ const primaryOf = (rec: DigestRecord | undefined) => (rec ? projects.primary(rec.projectKey, rec.paths ?? []) : undefined);
673
+
674
+ // 4. cards
675
+ const now = Date.now();
676
+ const pending = opts.reviewOnly ? [] : pendingSessions(files, state, cfg, now).slice(0, opts.maxSessions ?? cfg.dream.maxSessionsPerRun);
677
+ const inbox = opts.reviewOnly ? [] : pendingInbox(readInbox(root), state);
678
+ log(`${pending.length} session(s) to summarize, ${inbox.length} inbox item(s)`);
679
+ const sessions = new SessionCache(cfg.dream.concurrency * 2);
680
+ const llm = new Llm(ctx, log, signal);
681
+ const marks: Record<string, DreamState["sessions"][string]> = {};
682
+ const cardsSystem = systemPrompt("cards", { language: cfg.language }, root);
683
+ const cards = (
684
+ await pool(pending, cfg.dream.concurrency, async (f): Promise<Card | undefined> => {
685
+ const rec = digests[f.path];
686
+ const mark = (ok: boolean) => {
687
+ const prev = state.sessions[f.path];
688
+ marks[f.path] = { size: f.size, mtimeMs: f.mtimeMs, at: new Date().toISOString(), ok, failures: ok ? 0 : (prev?.size === f.size ? (prev.failures ?? 0) : 0) + 1 };
689
+ };
690
+ if (!rec || rec.error || rec.userMessages === 0) {
691
+ mark(true);
692
+ return undefined;
693
+ }
694
+ const cachePath = join(P.cards, `${rec.sessionId}.json`);
695
+ try {
696
+ if (existsSync(cachePath)) {
697
+ const cached = JSON.parse(readFileSync(cachePath, "utf-8")) as Card;
698
+ if (cached.size === f.size) {
699
+ mark(true);
700
+ return cached;
701
+ }
702
+ }
703
+ const sess = await sessions.get(f.path);
704
+ if (!sess) throw new Error("unreadable session");
705
+ const d = buildDigest(sess);
706
+ const ids = new Set(d.lines.map((l) => l.id));
707
+ const user = `${digestHeader(d.meta, rec.projectKey)}\n\n${renderDigest(d, 160_000)}`;
708
+ const base = { sessionId: rec.sessionId, file: f.path, size: f.size, projectKey: rec.projectKey, date: (rec.started || "").slice(0, 10) };
709
+ const card = await llm.json("cards", cfg.models.cards, cardsSystem, user, (raw) => validateCard(raw, ids, base, rec.name ?? ""), {
710
+ maxTokens: 16_000,
711
+ timeoutMs: 6 * MINUTE,
712
+ });
713
+ saveJson(cachePath, card);
714
+ mark(true);
715
+ log(` card ${rec.sessionId.slice(0, 8)} ${shortKey(rec.projectKey)}: ${card.title} — ${card.learnings.length} learning(s)`);
716
+ return card;
717
+ } catch (err) {
718
+ mark(false);
719
+ log(` ! card ${rec.sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`);
720
+ return undefined;
721
+ }
722
+ })
723
+ ).filter((c): c is Card => !!c);
724
+ save("cards.json", cards);
725
+
726
+ // 5. workspace on the review branch
727
+ const base = openPr ? `origin/${openPr.branch}` : remoteUrl ? `origin/${cfg.github.base}` : cfg.github.base;
728
+ const branch = openPr ? openPr.branch : `pi-dream/${date}-${runId.slice(-4)}`;
729
+ const wt = join(P.worktrees, runId);
730
+ await cleanupWorktrees(root, P, log);
731
+ mkdirSync(P.worktrees, { recursive: true });
732
+ await gitOk(root, ["worktree", "add", "-B", `dream-${runId}`, wt, base], 120_000);
733
+ try {
734
+ const ws = new Workspace(wt, date);
735
+ if (openPr && remoteUrl) {
736
+ const approved = await noteIdsAt(root, `origin/${cfg.github.base}`);
737
+ for (const id of ws.ids) if (!approved.has(id)) ws.pending.add(id);
738
+ }
739
+ if (mergedPr !== undefined) {
740
+ for (const run of state.runs.filter((r) => r.pr === mergedPr))
741
+ for (const a of run.added) if (!ws.ids.has(a.id)) state.rejected.push({ title: a.title, at: date, pr: mergedPr });
742
+ if (!opts.dryRun) state.runs = state.runs.filter((r) => r.pr !== mergedPr);
743
+ }
744
+ const normalized = ws.normalize();
745
+ const changes: ReportChange[] = [];
746
+ const rejected: ReportRejected[] = [];
747
+ const unhandled: Array<{ comment: string; reason: string }> = [];
748
+ const agentsOps: Array<{ key: string; repoRoot: string; rel: string; op: AgentsMdOp }> = [];
749
+ const scopeTitles: Record<string, string> = { global: "global" };
750
+ const deps: EvidenceDeps = {
751
+ sessions,
752
+ fileBySession,
753
+ inboxById: new Map(inbox.map((i) => [i.id, i])),
754
+ comments: new Map(feedback.map((c) => [c.id, c])),
755
+ pr: openPr?.number,
756
+ };
757
+ const scopeTitle = (scope: string): string => {
758
+ if (scopeTitles[scope]) return scopeTitles[scope];
759
+ try {
760
+ const meta = JSON.parse(readFileSync(join(scopeDir(wt, scope), SCOPE_META_FILE), "utf-8")) as { key?: string };
761
+ if (meta.key) return (scopeTitles[scope] = meta.key);
762
+ } catch {
763
+ // no meta yet
764
+ }
765
+ return scope;
766
+ };
767
+ const readText = (path: string) => (existsSync(path) ? readFileSync(path, "utf-8").trim() : "");
768
+ const globalCharter = readText(join(scopeDir(wt, GLOBAL_SCOPE), CHARTER_FILE)) || DEFAULT_GLOBAL_CHARTER;
769
+
770
+ // 6a. review pass
771
+ if (feedback.length && openPr) {
772
+ const input = {
773
+ pull_request: openPr.number,
774
+ comments: feedback.map((c) => ({ id: c.id, author: c.author, at: c.at, ...(c.path ? { file: c.path, line: c.line } : {}), body: c.body })),
775
+ scopes: Object.fromEntries(
776
+ [...ws.notes].map(([sc, ns]) => [
777
+ sc,
778
+ {
779
+ title: scopeTitle(sc),
780
+ notes: ns.filter((n) => n.status === "active").map((n) => ({ ...noteForPrompt(n), ...(ws.pending.has(n.id) ? { pending: true } : {}) })),
781
+ },
782
+ ]),
783
+ ),
784
+ };
785
+ const parsed = await llm.json<ParsedOps>("analyze", cfg.models.analyze, systemPrompt("review", { language: cfg.language }, wt), JSON.stringify(input, null, 1), parseOps, {
786
+ maxTokens: 48_000,
787
+ timeoutMs: 15 * MINUTE,
788
+ });
789
+ save("review.json", { input, parsed });
790
+ problems.push(...parsed.problems.map((p) => `review: ${p}`));
791
+ unhandled.push(...parsed.unhandled);
792
+ for (const op of parsed.ops) {
793
+ if (op.op === "agents_md") {
794
+ unhandled.push({ comment: op.evidence[0]?.e[0] ?? "?", reason: "AGENTS.md changes from review comments are not supported yet" });
795
+ continue;
796
+ }
797
+ const evidence: Evidence[] = (op.evidence.length ? op.evidence : [{ s: `pr#${openPr.number}`, e: [] }]).map((r) => ({
798
+ s: r.s.startsWith("pr#") ? r.s : `pr#${openPr?.number}`,
799
+ e: r.e,
800
+ date,
801
+ kind: "reviewer" as const,
802
+ }));
803
+ const res = applyOp(ws, op, { evidence });
804
+ if (!res.ok) rejected.push({ ref: op.ref, op: op.op, title: opTitle(op), reason: res.reason });
805
+ else changes.push({ ...res.change, rationale: op.rationale, evidence, source: "reviewer" });
806
+ }
807
+ }
808
+
809
+ // 6b. project passes
810
+ // Sessions are grouped by the project they were about (not where pi was started).
811
+ const primaryKey = (file: string, fallback: string) => primaryOf(digests[file])?.key ?? fallback;
812
+ const groups = new Map<string, { cards: Card[]; inbox: InboxItem[] }>();
813
+ const group = (key: string) => {
814
+ let g = groups.get(key);
815
+ if (!g) groups.set(key, (g = { cards: [], inbox: [] }));
816
+ return g;
817
+ };
818
+ for (const c of cards) if (c.learnings.length || c.feedback.length || c.memoryFeedback.length) group(primaryKey(c.file, c.projectKey)).cards.push(c);
819
+ for (const i of inbox) group(i.sessionFile ? primaryKey(i.sessionFile, i.projectKey) : i.projectKey).inbox.push(i);
820
+ type HitMeta = { sessionId: string; projectKey: string; entryId?: string; date: string };
821
+ let corpus: Bm25<HitMeta> | undefined;
822
+ const history = (c: Card, l: Learning, passKey: string) => {
823
+ if (!corpus) {
824
+ const docs: Doc<HitMeta>[] = [];
825
+ for (const [file, rec] of Object.entries(digests)) {
826
+ if (!rec.dir || rec.userMessages === 0) continue;
827
+ let text: string;
828
+ try {
829
+ text = readFileSync(digestFile(P, rec), "utf-8");
830
+ } catch {
831
+ continue;
832
+ }
833
+ const projectKey = primaryKey(file, rec.projectKey);
834
+ for (const [k, ch] of chunkDigest(text).entries())
835
+ docs.push({ id: `${rec.sessionId}#${k}`, text: ch.text, meta: { sessionId: rec.sessionId, projectKey, entryId: ch.entryId, date: rec.started.slice(0, 10) } });
836
+ }
837
+ corpus = new Bm25(docs);
838
+ log(`history corpus: ${docs.length} chunks`);
839
+ }
840
+ const query = `${l.statement} ${l.details ?? ""}`;
841
+ return corpus
842
+ .search(query, 3, (d) => d.meta.sessionId !== c.sessionId && (l.scope === "global" || d.meta.projectKey === passKey))
843
+ .filter((h) => h.score > 2)
844
+ .map((h) => ({
845
+ s: h.doc.meta.sessionId,
846
+ e: h.doc.meta.entryId ? [h.doc.meta.entryId] : [],
847
+ date: h.doc.meta.date,
848
+ project: h.doc.meta.projectKey,
849
+ snippet: snippet(h.doc.text, query, 360),
850
+ }));
851
+ };
852
+ /** Projects of the sessions an op cites, and whether the user (or a deliberate inbox item) stands behind it. */
853
+ const evidenceProjects = (ev: Evidence[]) => {
854
+ const keys = new Set<string>();
855
+ let explicit = false;
856
+ for (const e of ev) {
857
+ if (e.kind === "user" || e.kind === "inbox" || e.kind === "reviewer") explicit = true;
858
+ const file = fileBySession.get(e.s);
859
+ const key = file ? primaryKey(file, digests[file]?.projectKey ?? "") : "";
860
+ if (key && projects.isProject(key)) keys.add(key);
861
+ }
862
+ return { keys, explicit };
863
+ };
864
+ const curatorBase = systemPrompt(
865
+ "curator",
866
+ { language: cfg.language, charter: "{{charter}}", globalBudget: cfg.budgets.globalTokens, projectBudget: cfg.budgets.projectTokens },
867
+ wt,
868
+ );
869
+ const ordered = [...groups.entries()].sort((a, b) => b[1].cards.length + b[1].inbox.length - (a[1].cards.length + a[1].inbox.length));
870
+ const votes: Array<{ id: string; effect: string }> = [];
871
+ for (const [key, g] of ordered) {
872
+ if (signal.aborted) throw new Error("dream aborted (time limit)");
873
+ const scope = projectScope(key);
874
+ const knownRoot = projects.rootOf(key)?.root;
875
+ const proj = await resolveProject(
876
+ knownRoot ?? (key.startsWith("local:") ? key.slice(6) : g.cards[0] ? (digests[g.cards[0].file]?.cwd ?? "") : (g.inbox[0]?.cwd ?? "")),
877
+ );
878
+ ws.ensureScope(scope, { key, root: proj.root });
879
+ scopeTitles[scope] = key;
880
+ // Other projects these sessions worked in or that history ties the topic to.
881
+ const candidates = new Map<string, Set<string>>();
882
+ const addCandidate = (k: string, why: string) => {
883
+ if (!k || k === key || !projects.isProject(k)) return;
884
+ if (!candidates.has(k)) candidates.set(k, new Set());
885
+ candidates.get(k)?.add(why);
886
+ };
887
+ const sessionInfo = new Map<string, { cwd: string; workspace: boolean; touched: Array<{ project: string; n: number }> }>();
888
+ for (const c of g.cards) {
889
+ const rec = digests[c.file];
890
+ const p = primaryOf(rec);
891
+ for (const t of p?.touched ?? []) addCandidate(t.key, "touched by these sessions");
892
+ if (rec && !projects.isBroad(rec.projectKey)) addCandidate(rec.projectKey, "cwd of a session");
893
+ sessionInfo.set(c.sessionId, {
894
+ cwd: rec?.cwd ?? "",
895
+ workspace: p?.workspace ?? false,
896
+ touched: (p?.touched ?? []).slice(0, 5).map((t) => ({ project: t.key, n: t.n })),
897
+ });
898
+ }
899
+ const sessionsInput = g.cards.map((c) => ({
900
+ session: c.sessionId,
901
+ date: c.date,
902
+ ...sessionInfo.get(c.sessionId),
903
+ title: c.title,
904
+ summary: c.summary,
905
+ outcome: c.outcome,
906
+ feedback: c.feedback,
907
+ learnings: c.learnings.map((l) => {
908
+ const hist = history(c, l, key);
909
+ for (const h of hist) addCandidate(h.project, "history hits");
910
+ return { ...l, history: hist };
911
+ }),
912
+ memory_feedback: c.memoryFeedback,
913
+ }));
914
+ const otherProjects = [...candidates.entries()].slice(0, 8).map(([k, why]) => {
915
+ const sc = projectScope(k);
916
+ ws.ensureScope(sc, { key: k, root: projects.rootOf(k)?.root });
917
+ scopeTitles[sc] = k;
918
+ const active = ws.scope(sc).filter((n) => n.status === "active");
919
+ return { scope: `project:${k}`, why: [...why], notes: active.slice(0, 12).map((n) => ({ id: n.id, title: n.title })) };
920
+ });
921
+ for (const c of g.cards) for (const f of c.memoryFeedback) votes.push({ id: f.id, effect: f.effect });
922
+ const projectCharter =
923
+ readText(join(scopeDir(wt, scope), CHARTER_FILE)) || readText(join(wt, "scopes", "projects", "_charter.md")) || DEFAULT_PROJECT_CHARTER;
924
+ const ctxFile = cfg.agentsMd && proj.exists ? findContextFile(proj.checkout ?? proj.root) : undefined;
925
+ // One AGENTS.md proposal at a time: while one awaits review, do not stack another.
926
+ const agentsPending = ctxFile ? await openAgentsPr(proj.root) : undefined;
927
+ const used = (sc: string, budget: number) => buildIndex(ws.scope(sc), sc, budget).tokens;
928
+ const input = {
929
+ project: { key, scope: "project", root: proj.root },
930
+ other_projects: otherProjects,
931
+ budgets: {
932
+ global: { budget: cfg.budgets.globalTokens, used: used(GLOBAL_SCOPE, cfg.budgets.globalTokens) },
933
+ project: { budget: cfg.budgets.projectTokens, used: used(scope, cfg.budgets.projectTokens) },
934
+ },
935
+ notes: {
936
+ global: ws.scope(GLOBAL_SCOPE).filter((n) => n.status === "active").map(noteForPrompt),
937
+ project: ws.scope(scope).filter((n) => n.status === "active").map(noteForPrompt),
938
+ },
939
+ sessions: sessionsInput,
940
+ inbox: g.inbox.map((i) => ({ s: `inbox:${i.id}`, at: i.ts, source: i.source, kind: i.kind, scope: i.scope, text: i.text })),
941
+ context_file: ctxFile
942
+ ? {
943
+ path: ctxFile.rel,
944
+ content: clipHead(readFileSync(ctxFile.path, "utf-8"), 40_000),
945
+ ...(agentsPending ? { pending_pr: agentsPending } : {}),
946
+ }
947
+ : null,
948
+ rejected_before: state.rejected.slice(-40).map((r) => r.title),
949
+ };
950
+ const charter = `Global: ${globalCharter}\n\nThis project (${key}): ${projectCharter}`;
951
+ const tag = keyToDir(key).slice(0, 60);
952
+ const parsed = await llm.json<ParsedOps>("analyze", cfg.models.analyze, curatorBase.replaceAll("{{charter}}", charter), JSON.stringify(input, null, 1), parseOps, {
953
+ maxTokens: 64_000,
954
+ timeoutMs: 20 * MINUTE,
955
+ });
956
+ save(`curator-${tag}.json`, { input, parsed });
957
+ problems.push(...parsed.problems.map((p) => `${shortKey(key)}: ${p}`));
958
+ log(`curator ${shortKey(key)}: ${parsed.ops.length} op(s)`);
959
+ if (!parsed.ops.length) continue;
960
+ const resolved = await Promise.all(parsed.ops.map((op) => resolveOp(op, deps)));
961
+ for (const r of resolved.filter((x) => x.error)) rejected.push({ ref: r.op.ref, op: r.op.op, title: opTitle(r.op), reason: r.error ?? "" });
962
+ const valid = resolved.filter((x) => !x.error);
963
+ const scopeChoices = { project: key, other_projects: otherProjects.map((o) => o.scope.slice(8)) };
964
+ const verdicts = valid.length ? await verifyOps(valid, ws, llm, cfg, wt, save, tag, scopeChoices) : new Map<string, Verdict>();
965
+ for (const r of valid) {
966
+ const v = verdicts.get(r.op.ref);
967
+ const d = decide(v);
968
+ if (!d.apply) {
969
+ rejected.push({ ref: r.op.ref, op: r.op.op, title: opTitle(r.op), reason: d.reason });
970
+ continue;
971
+ }
972
+ // Global only with the user behind it or evidence from more than one project.
973
+ let guardNote: string | undefined;
974
+ if ((r.op.op === "add" || r.op.op === "update") && (d.fix?.scope ?? r.op.scope) === "global") {
975
+ const ep = evidenceProjects(r.evidence);
976
+ if (!ep.explicit && ep.keys.size === 1) {
977
+ const only = [...ep.keys][0];
978
+ d.fix = { ...d.fix, scope: `project:${only}` };
979
+ ws.ensureScope(projectScope(only), { key: only, root: projects.rootOf(only)?.root });
980
+ scopeTitles[projectScope(only)] = only;
981
+ guardNote = `global → ${shortKey(only)}: all evidence comes from this one project`;
982
+ }
983
+ }
984
+ if (r.op.op === "agents_md") {
985
+ if (!ctxFile) rejected.push({ ref: r.op.ref, op: r.op.op, title: opTitle(r.op), reason: "project has no context file" });
986
+ else if (agentsPending)
987
+ rejected.push({ ref: r.op.ref, op: r.op.op, title: opTitle(r.op), reason: `a pi-dream AGENTS.md PR is still open: ${agentsPending}` });
988
+ else agentsOps.push({ key, repoRoot: proj.root, rel: ctxFile.rel, op: r.op });
989
+ continue;
990
+ }
991
+ const res = applyOp(ws, r.op, { projectScope: scope, evidence: r.evidence, fix: d.fix });
992
+ if (!res.ok) {
993
+ rejected.push({ ref: r.op.ref, op: r.op.op, title: opTitle(r.op), reason: res.reason });
994
+ continue;
995
+ }
996
+ changes.push({
997
+ ...res.change,
998
+ note: [res.change.note, guardNote].filter(Boolean).join(" · ") || undefined,
999
+ rationale: r.op.rationale,
1000
+ evidence: r.evidence,
1001
+ source: "curator",
1002
+ verdict: v ? { verdict: v.verdict, reason: v.reason, corrected: !!(d.fix?.title || d.fix?.body || (d.fix?.scope && !guardNote)) } : undefined,
1003
+ proposedTitle: d.fix?.title ? opTitle(r.op) : undefined,
1004
+ });
1005
+ }
1006
+ }
1007
+ const voteCount = applyVotes(ws, votes);
1008
+
1009
+ // 7. write notes, indexes
1010
+ ws.flush();
1011
+ const indexes: IndexStat[] = regenerateIndexes(ws, cfg.budgets, scopeTitle);
1012
+ ws.flush();
1013
+
1014
+ // AGENTS.md proposals
1015
+ const agents: ReportAgents[] = [];
1016
+ const byRepo = new Map<string, { key: string; repoRoot: string; rel: string; edits: AgentsEdit[]; rationale: string[]; summaries: string[] }>();
1017
+ for (const a of agentsOps) {
1018
+ const k = `${a.repoRoot}::${a.rel}`;
1019
+ let entry = byRepo.get(k);
1020
+ if (!entry) byRepo.set(k, (entry = { key: a.key, repoRoot: a.repoRoot, rel: a.rel, edits: [], rationale: [], summaries: [] }));
1021
+ entry.edits.push(...a.op.edits);
1022
+ if (a.op.rationale) entry.rationale.push(a.op.rationale);
1023
+ if (a.op.summary) entry.summaries.push(a.op.summary);
1024
+ }
1025
+ for (const e of byRepo.values()) {
1026
+ if (opts.dryRun) {
1027
+ agents.push({ project: e.key, rel: e.rel, error: "dry run", patch: renderEditsDiff(e.rel, e.edits), rationale: e.rationale });
1028
+ continue;
1029
+ }
1030
+ const res = await publishAgentsEdits({
1031
+ repoRoot: e.repoRoot,
1032
+ rel: e.rel,
1033
+ edits: e.edits,
1034
+ runId,
1035
+ worktreesDir: P.worktrees,
1036
+ title: e.summaries.length === 1 ? e.summaries[0] : `${e.edits.length} edit(s) from the memory review`,
1037
+ details: e.rationale,
1038
+ log,
1039
+ });
1040
+ agents.push({ project: e.key, rel: e.rel, url: res.url, error: res.error, patch: res.patch, rationale: e.rationale });
1041
+ }
1042
+
1043
+ // 8. report, commit, pull request
1044
+ const sessionTitles: Record<string, string> = {};
1045
+ for (const c of cards) sessionTitles[c.sessionId] = c.title;
1046
+ for (const rec of Object.values(digests)) if (rec.name && !sessionTitles[rec.sessionId]) sessionTitles[rec.sessionId] = rec.name;
1047
+ const reportSessions: ReportSession[] = cards.map((c) => {
1048
+ const primary = primaryKey(c.file, c.projectKey);
1049
+ return {
1050
+ sessionId: c.sessionId,
1051
+ date: c.date,
1052
+ project: primary === c.projectKey ? shortKey(primary) : `${shortKey(primary)} ← ${shortKey(c.projectKey)}`,
1053
+ title: c.title,
1054
+ outcome: c.outcome.status,
1055
+ learnings: c.learnings.length,
1056
+ };
1057
+ });
1058
+ const data: ReportData = {
1059
+ runId,
1060
+ date,
1061
+ language: cfg.language,
1062
+ models: { cards: cfg.models.cards.model, analyze: cfg.models.analyze.model, verify: cfg.models.verify.model },
1063
+ usage: llm.usage,
1064
+ sessions: reportSessions,
1065
+ sessionTitles,
1066
+ inbox: inbox.length,
1067
+ feedback: feedback.length,
1068
+ unhandled,
1069
+ changes,
1070
+ rejected,
1071
+ agents,
1072
+ indexes,
1073
+ votes: voteCount,
1074
+ normalized: normalized.length,
1075
+ problems,
1076
+ scopeTitles,
1077
+ };
1078
+ const report = renderReport(data);
1079
+ writeFileSync(join(runDir, "report.md"), report, "utf-8");
1080
+ const memoryChanged = (await git(wt, ["status", "--porcelain"])).stdout.trim().length > 0;
1081
+ const result: DreamResult = { status: "noop", sessions: pending.length, cost: llm.totalCost() };
1082
+ if (opts.dryRun) {
1083
+ log(`dry run: report ${join(runDir, "report.md")}, worktree ${wt}`);
1084
+ return result;
1085
+ }
1086
+
1087
+ if (memoryChanged) {
1088
+ mkdirSync(join(wt, "reports"), { recursive: true });
1089
+ writeFileSync(join(wt, "reports", `${runId}.md`), report, "utf-8");
1090
+ await gitOk(wt, ["add", "-A"]);
1091
+ await gitOk(wt, ["commit", "-m", prTitle(data)]);
1092
+ if (repo) {
1093
+ await gitOk(wt, ["push", "origin", `HEAD:refs/heads/${branch}`], 120_000);
1094
+ const body = report.length > 60_000 ? `${report.slice(0, 60_000)}\n\n…(truncated: reports/${runId}.md)` : report;
1095
+ const bodyFile = join(runDir, "pr-body.md");
1096
+ writeFileSync(bodyFile, body, "utf-8");
1097
+ let stillOpen = false;
1098
+ if (openPr) {
1099
+ const v = await gh(["pr", "view", String(openPr.number), "--repo", repo, "--json", "state", "--jq", ".state"]);
1100
+ stillOpen = v.code === 0 && v.stdout.trim() === "OPEN";
1101
+ }
1102
+ if (openPr && stillOpen) {
1103
+ await ghOk(["pr", "comment", String(openPr.number), "--repo", repo, "--body-file", bodyFile]);
1104
+ result.pr = openPr.url;
1105
+ log(`updated PR #${openPr.number}: ${openPr.url}`);
1106
+ } else {
1107
+ await gh(["label", "create", cfg.github.label, "--repo", repo, "--color", "7AA2F7", "--description", "pi-dream memory review", "--force"]);
1108
+ const created = await ghOk(
1109
+ ["pr", "create", "--repo", repo, "--base", cfg.github.base, "--head", branch, "--title", prTitle(data), "--body-file", bodyFile, "--label", cfg.github.label],
1110
+ { timeoutMs: 120_000 },
1111
+ );
1112
+ const url = created.split("\n").pop()?.trim() ?? created;
1113
+ const number = Number(url.split("/").pop());
1114
+ openPr = { number, url, branch };
1115
+ state.openPr = openPr;
1116
+ result.pr = url;
1117
+ log(`opened PR #${number}: ${url}`);
1118
+ }
1119
+ }
1120
+ result.status = "ok";
1121
+ state.runs.push({
1122
+ id: runId,
1123
+ pr: openPr?.number,
1124
+ added: changes.filter((c) => c.kind === "added" || c.kind === "superseded").map((c) => ({ id: c.newId ?? c.noteId ?? "", title: c.title })),
1125
+ });
1126
+ state.runs = state.runs.slice(-60);
1127
+ } else if (repo && openPr && (feedback.length || unhandled.length)) {
1128
+ const bodyFile = join(runDir, "pr-body.md");
1129
+ writeFileSync(bodyFile, report, "utf-8");
1130
+ await ghOk(["pr", "comment", String(openPr.number), "--repo", repo, "--body-file", bodyFile]);
1131
+ result.pr = openPr.url;
1132
+ }
1133
+ if (!memoryChanged && agents.length) result.status = "ok";
1134
+
1135
+ // state: what this dream has consumed
1136
+ Object.assign(state.sessions, marks);
1137
+ state.inboxDone.push(...inbox.map((i) => i.id));
1138
+ state.rejected = state.rejected.slice(-200);
1139
+ if (openPr && feedback.length) state.reviewSeen[String(openPr.number)] = feedback[feedback.length - 1].at;
1140
+ return result;
1141
+ } finally {
1142
+ if (!opts.dryRun) {
1143
+ await git(root, ["worktree", "remove", "--force", wt]);
1144
+ await git(root, ["branch", "-D", `dream-${runId}`]);
1145
+ }
1146
+ await git(root, ["worktree", "prune"]);
1147
+ }
1148
+ }
1149
+