@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 +17 -0
- package/LICENSE +21 -0
- package/README.md +102 -2
- package/agentsmd.ts +171 -0
- package/commands.ts +92 -0
- package/config.ts +194 -0
- package/curate.test.ts +138 -0
- package/curate.ts +610 -0
- package/digest.test.ts +62 -0
- package/digest.ts +329 -0
- package/gitops.ts +69 -0
- package/inbox.ts +56 -0
- package/index.ts +33 -0
- package/layout.ts +99 -0
- package/llm.ts +145 -0
- package/lock.ts +74 -0
- package/memindex.test.ts +74 -0
- package/memindex.ts +126 -0
- package/misc.test.ts +141 -0
- package/notes.test.ts +90 -0
- package/notes.ts +272 -0
- package/package.json +54 -4
- package/pipeline.ts +1256 -0
- package/project.ts +115 -0
- package/projects.test.ts +79 -0
- package/projects.ts +74 -0
- package/prompts.ts +160 -0
- package/report.ts +301 -0
- package/runner.ts +22 -0
- package/runtime.ts +428 -0
- package/scheduler.ts +133 -0
- package/search.test.ts +34 -0
- package/search.ts +153 -0
- package/secrets.ts +74 -0
- package/sessions.ts +176 -0
- package/setup.ts +148 -0
- package/state.ts +84 -0
- package/tools.ts +75 -0
package/runtime.ts
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-session side of pi-dream: what a normal pi session sees.
|
|
3
|
+
*
|
|
4
|
+
* - The approved memory (`main` of the memory repo) is read once per session
|
|
5
|
+
* and frozen: the `<memory>` section stays byte-identical turn after turn,
|
|
6
|
+
* so the provider's prompt cache keeps hitting. Resumed sessions replay the
|
|
7
|
+
* recorded section instead of reading disk again.
|
|
8
|
+
* - A background `git fetch` + fast-forward keeps `main` current (merged pull
|
|
9
|
+
* requests show up in the next session).
|
|
10
|
+
* - Search, read and inbox back the memory tools; status backs `/memory` and
|
|
11
|
+
* `/dream`; a due dream is started in the background.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync, utimesSync, writeFileSync, mkdirSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import { type DreamConfig, disabledByEnv, loadConfig } from "./config.ts";
|
|
19
|
+
import { clipHead, renderEntryFull } from "./digest.ts";
|
|
20
|
+
import { git } from "./gitops.ts";
|
|
21
|
+
import { appendInbox, type InboxKind, readInbox } from "./inbox.ts";
|
|
22
|
+
import { GLOBAL_SCOPE, INDEX_FILE, keyToDir, memoryPaths, newRunId, projectScope, scopeDir } from "./layout.ts";
|
|
23
|
+
import { lockIsLive } from "./lock.ts";
|
|
24
|
+
import { estimateTokens, indexBody, indexIds, renderInjection } from "./memindex.ts";
|
|
25
|
+
import { listScopes, loadNotes, type Note } from "./notes.ts";
|
|
26
|
+
import { type ProjectInfo, resolveProject, shortKey } from "./project.ts";
|
|
27
|
+
import { checkDue, pendingInbox, pendingSessions, spawnDream } from "./scheduler.ts";
|
|
28
|
+
import { Bm25, chunkDigest, type Doc, snippet } from "./search.ts";
|
|
29
|
+
import { listSessionFiles, parseSession, sessionsRoot } from "./sessions.ts";
|
|
30
|
+
import { loadState } from "./state.ts";
|
|
31
|
+
|
|
32
|
+
export const EVENT = "pi-dream";
|
|
33
|
+
const SYNC_INTERVAL_MS = 5 * 60_000;
|
|
34
|
+
|
|
35
|
+
interface Snapshot {
|
|
36
|
+
text?: string;
|
|
37
|
+
ids: string[];
|
|
38
|
+
tokens: number;
|
|
39
|
+
memver?: string;
|
|
40
|
+
projectKey: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface InjectRecord {
|
|
44
|
+
event: "inject";
|
|
45
|
+
text?: string;
|
|
46
|
+
ids: string[];
|
|
47
|
+
tokens: number;
|
|
48
|
+
memver?: string;
|
|
49
|
+
projectKey: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const ru = (cfg: DreamConfig) => cfg.language.toLowerCase().startsWith("ru");
|
|
53
|
+
|
|
54
|
+
export class Runtime {
|
|
55
|
+
cfg: DreamConfig = loadConfig();
|
|
56
|
+
private snapshot: Snapshot | undefined;
|
|
57
|
+
private syncing: Promise<string> | undefined;
|
|
58
|
+
private noteIndex: { key: string; bm: Bm25<Note> } | undefined;
|
|
59
|
+
private digestIndex = new Map<string, { key: string; bm: Bm25<{ sessionId: string; entryId?: string; dir: string }> }>();
|
|
60
|
+
|
|
61
|
+
constructor(private pi: ExtensionAPI) {}
|
|
62
|
+
|
|
63
|
+
get root(): string {
|
|
64
|
+
return this.cfg.memoryDir;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
active(): boolean {
|
|
68
|
+
return !disabledByEnv() && this.pi.getFlag("no-memory") !== true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
project(cwd: string): Promise<ProjectInfo> {
|
|
72
|
+
return resolveProject(cwd);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ── session lifecycle ────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
async onSessionStart(reason: string, ctx: ExtensionContext): Promise<void> {
|
|
78
|
+
this.cfg = loadConfig();
|
|
79
|
+
this.snapshot = undefined;
|
|
80
|
+
if (!this.active()) {
|
|
81
|
+
const keep = this.pi.getActiveTools().filter((n) => !n.startsWith("memory_"));
|
|
82
|
+
this.pi.setActiveTools(keep);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (reason === "resume" || reason === "fork" || reason === "reload") {
|
|
86
|
+
const rec = this.recorded(ctx);
|
|
87
|
+
if (rec) this.snapshot = { ...rec };
|
|
88
|
+
}
|
|
89
|
+
if (!this.snapshot && existsSync(join(this.root, ".git"))) this.syncing = this.sync(false);
|
|
90
|
+
void this.project(ctx.cwd);
|
|
91
|
+
if (ctx.hasUI) {
|
|
92
|
+
this.notifyFinishedDream(ctx);
|
|
93
|
+
if (reason === "startup" || reason === "new") setTimeout(() => void this.maybeDream(ctx).catch(() => undefined), 1500).unref?.();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private recorded(ctx: ExtensionContext): InjectRecord | undefined {
|
|
98
|
+
const branch = ctx.sessionManager.getBranch() as Array<{ type?: string; customType?: string; data?: unknown }>;
|
|
99
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
100
|
+
const e = branch[i];
|
|
101
|
+
if (e.type === "custom" && e.customType === EVENT && (e.data as InjectRecord | undefined)?.event === "inject") return e.data as InjectRecord;
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The `<memory>` section for this session (built on the first turn, then frozen). */
|
|
107
|
+
async memorySection(ctx: ExtensionContext): Promise<string | undefined> {
|
|
108
|
+
if (!this.active() || !this.cfg.inject) return undefined;
|
|
109
|
+
if (!this.snapshot) {
|
|
110
|
+
if (this.syncing) await Promise.race([this.syncing, new Promise((r) => setTimeout(r, 1500))]);
|
|
111
|
+
const snap = await this.buildSnapshot(ctx.cwd);
|
|
112
|
+
this.snapshot = snap;
|
|
113
|
+
if (snap.text) {
|
|
114
|
+
const rec: InjectRecord = { event: "inject", text: snap.text, ids: snap.ids, tokens: snap.tokens, memver: snap.memver, projectKey: snap.projectKey };
|
|
115
|
+
this.pi.appendEntry(EVENT, rec);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return this.snapshot.text;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private memver(): string | undefined {
|
|
122
|
+
try {
|
|
123
|
+
return execFileSync("git", ["-C", this.root, "rev-parse", "--short", "HEAD"], { timeout: 2000, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async buildSnapshot(cwd: string): Promise<Snapshot> {
|
|
130
|
+
const proj = await this.project(cwd);
|
|
131
|
+
const read = (scope: string) => {
|
|
132
|
+
const p = join(scopeDir(this.root, scope), INDEX_FILE);
|
|
133
|
+
return existsSync(p) ? indexBody(readFileSync(p, "utf-8")) : "";
|
|
134
|
+
};
|
|
135
|
+
const g = read(GLOBAL_SCOPE);
|
|
136
|
+
const p = read(projectScope(proj.key));
|
|
137
|
+
const memver = this.memver();
|
|
138
|
+
const text = renderInjection({
|
|
139
|
+
memver,
|
|
140
|
+
global: g,
|
|
141
|
+
project: p ? { key: proj.key, body: p } : undefined,
|
|
142
|
+
maxTokens: this.cfg.budgets.globalTokens + this.cfg.budgets.projectTokens + 200,
|
|
143
|
+
});
|
|
144
|
+
return { text, ids: text ? indexIds(text) : [], tokens: text ? estimateTokens(text) : 0, memver, projectKey: proj.key };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
resourcesFor(cwd: string): Promise<string[]> {
|
|
148
|
+
return this.project(cwd).then((proj) =>
|
|
149
|
+
[join(scopeDir(this.root, GLOBAL_SCOPE), "skills"), join(scopeDir(this.root, projectScope(proj.key)), "skills")].filter((d) => existsSync(d)),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── sync ─────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
async sync(force: boolean): Promise<string> {
|
|
156
|
+
const P = memoryPaths(this.root);
|
|
157
|
+
if (!existsSync(join(this.root, ".git"))) return "not initialized";
|
|
158
|
+
try {
|
|
159
|
+
if (!force && existsSync(P.sync) && Date.now() - statSync(P.sync).mtimeMs < SYNC_INTERVAL_MS) return "recently synced";
|
|
160
|
+
mkdirSync(P.local, { recursive: true });
|
|
161
|
+
if (existsSync(P.sync)) utimesSync(P.sync, new Date(), new Date());
|
|
162
|
+
else writeFileSync(P.sync, "");
|
|
163
|
+
} catch {
|
|
164
|
+
// best effort
|
|
165
|
+
}
|
|
166
|
+
const base = this.cfg.github.base;
|
|
167
|
+
if ((await git(this.root, ["remote", "get-url", "origin"])).code !== 0) return "no remote";
|
|
168
|
+
const fetch = await git(this.root, ["fetch", "origin", base, "--quiet"], 20_000);
|
|
169
|
+
if (fetch.code !== 0) return `fetch failed: ${fetch.stderr.trim().split("\n")[0]}`;
|
|
170
|
+
const branch = (await git(this.root, ["rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
171
|
+
if (branch !== base) return `memory repo is on ${branch}`;
|
|
172
|
+
if ((await git(this.root, ["status", "--porcelain"])).stdout.trim()) return "memory repo has local changes";
|
|
173
|
+
const merge = await git(this.root, ["merge", "--ff-only", `origin/${base}`]);
|
|
174
|
+
if (merge.code !== 0) return `fast-forward failed: ${merge.stderr.trim().split("\n")[0]}`;
|
|
175
|
+
return /Already up to date/i.test(merge.stdout) ? "up to date" : "updated";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── tools ────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
private allNotes(scopes: string[]): Note[] {
|
|
181
|
+
return scopes.flatMap((s) => loadNotes(this.root, s));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
private dirsKey(dirs: string[]): string {
|
|
185
|
+
return dirs
|
|
186
|
+
.map((d) => {
|
|
187
|
+
try {
|
|
188
|
+
return `${d}:${statSync(d).mtimeMs}`;
|
|
189
|
+
} catch {
|
|
190
|
+
return `${d}:-`;
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
.join("|");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async search(ctx: ExtensionContext, query: string, scope: "all" | "notes" | "sessions", projects: "current" | "all", limit: number): Promise<string> {
|
|
197
|
+
const proj = await this.project(ctx.cwd);
|
|
198
|
+
const out: string[] = [];
|
|
199
|
+
if (scope !== "sessions") {
|
|
200
|
+
const scopes = projects === "all" ? listScopes(this.root) : [GLOBAL_SCOPE, projectScope(proj.key)];
|
|
201
|
+
const dirs = scopes.map((s) => join(scopeDir(this.root, s), "notes"));
|
|
202
|
+
const key = this.dirsKey(dirs);
|
|
203
|
+
if (!this.noteIndex || this.noteIndex.key !== key) {
|
|
204
|
+
const notes = this.allNotes(scopes).filter((n) => n.id);
|
|
205
|
+
this.noteIndex = { key, bm: new Bm25(notes.map((n) => ({ id: n.id, text: `${n.title}\n${n.when ?? ""}\n${n.body}`, meta: n }))) };
|
|
206
|
+
}
|
|
207
|
+
const hits = this.noteIndex.bm.search(query, limit);
|
|
208
|
+
out.push(`notes (${hits.length}):`);
|
|
209
|
+
for (const h of hits) {
|
|
210
|
+
const n = h.doc.meta;
|
|
211
|
+
const st = n.status === "active" ? "" : ` [${n.status}]`;
|
|
212
|
+
out.push(`- [${n.id}] (${n.scope === GLOBAL_SCOPE ? "global" : shortKey(n.scope)}, ${n.type})${st} ${n.title}${n.when ? ` — when: ${n.when}` : ""}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (scope !== "notes") {
|
|
216
|
+
const P = memoryPaths(this.root);
|
|
217
|
+
const dirNames = projects === "all" ? (existsSync(P.digests) ? readdirSync(P.digests).filter((d) => !d.endsWith(".json")) : []) : [keyToDir(proj.key)];
|
|
218
|
+
const docs: Doc<{ sessionId: string; entryId?: string; dir: string }>[] = [];
|
|
219
|
+
for (const dir of dirNames) {
|
|
220
|
+
const full = join(P.digests, dir);
|
|
221
|
+
if (!existsSync(full)) continue;
|
|
222
|
+
const cacheKey = this.dirsKey([full]);
|
|
223
|
+
let cached = this.digestIndex.get(full);
|
|
224
|
+
if (!cached || cached.key !== cacheKey) {
|
|
225
|
+
const ds: typeof docs = [];
|
|
226
|
+
for (const name of readdirSync(full)) {
|
|
227
|
+
if (!name.endsWith(".txt")) continue;
|
|
228
|
+
const sessionId = name.slice(0, -4);
|
|
229
|
+
let text = "";
|
|
230
|
+
try {
|
|
231
|
+
text = readFileSync(join(full, name), "utf-8");
|
|
232
|
+
} catch {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
for (const [k, ch] of chunkDigest(text).entries()) ds.push({ id: `${sessionId}#${k}`, text: ch.text, meta: { sessionId, entryId: ch.entryId, dir } });
|
|
236
|
+
}
|
|
237
|
+
cached = { key: cacheKey, bm: new Bm25(ds) };
|
|
238
|
+
this.digestIndex.set(full, cached);
|
|
239
|
+
}
|
|
240
|
+
docs.push(...cached.bm.docs);
|
|
241
|
+
}
|
|
242
|
+
const bm = dirNames.length === 1 ? this.digestIndex.get(join(P.digests, dirNames[0]))?.bm : new Bm25(docs);
|
|
243
|
+
const own = ctx.sessionManager.getSessionId();
|
|
244
|
+
const hits = (bm?.search(query, limit * 2) ?? []).filter((h) => h.doc.meta.sessionId !== own).slice(0, limit);
|
|
245
|
+
out.push(`past sessions (${hits.length}${existsSync(P.digests) ? "" : " — no digests yet: /dream index"}):`);
|
|
246
|
+
for (const h of hits) {
|
|
247
|
+
const ref = `s:${h.doc.meta.sessionId}#${h.doc.meta.entryId ?? ""}`;
|
|
248
|
+
out.push(`- ${ref} (${h.doc.meta.dir === keyToDir(proj.key) ? "this project" : h.doc.meta.dir})\n ${snippet(h.doc.text, query, 400).replace(/\n/g, "\n ")}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
out.push("Open a note or a session reference with memory_read.");
|
|
252
|
+
return clipHead(out.join("\n"), 9000);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async read(ref: string): Promise<string> {
|
|
256
|
+
const r = ref.trim();
|
|
257
|
+
if (/^m-[0-9a-f]{6,12}$/.test(r)) {
|
|
258
|
+
const note = this.allNotes(listScopes(this.root)).find((n) => n.id === r);
|
|
259
|
+
if (!note) return `No note ${r}.`;
|
|
260
|
+
const ev = note.evidence
|
|
261
|
+
.slice(-6)
|
|
262
|
+
.map((e) => `${e.s.slice(0, 13)}${e.e.length ? `#${e.e.join(",")}` : ""} ${e.date ?? ""} ${e.kind ?? ""}`.trim())
|
|
263
|
+
.join("; ");
|
|
264
|
+
return [
|
|
265
|
+
`[${note.id}] ${note.title}`,
|
|
266
|
+
`type: ${note.type} · status: ${note.status}${note.validUntil ? ` (until ${note.validUntil})` : ""} · scope: ${note.scope === GLOBAL_SCOPE ? "global" : note.scope} · updated: ${note.updated}`,
|
|
267
|
+
note.when ? `when: ${note.when}` : "",
|
|
268
|
+
note.supersededBy ? `superseded by: ${note.supersededBy}` : "",
|
|
269
|
+
"",
|
|
270
|
+
note.body,
|
|
271
|
+
"",
|
|
272
|
+
`evidence (${note.evidence.length}): ${ev}`,
|
|
273
|
+
"Evidence references can be opened as s:<session>#<entry>.",
|
|
274
|
+
]
|
|
275
|
+
.filter((l) => l !== "")
|
|
276
|
+
.join("\n");
|
|
277
|
+
}
|
|
278
|
+
const m = /^s:([0-9a-zA-Z-]+)(?:#([0-9a-f-]+))?$/.exec(r);
|
|
279
|
+
if (!m) return `Unknown reference "${r}". Use a note id (m-xxxxxx) or s:<sessionId>#<entryId>.`;
|
|
280
|
+
const file = this.findSessionFile(m[1]);
|
|
281
|
+
if (!file) return `No session ${m[1]}.`;
|
|
282
|
+
const s = await parseSession(file);
|
|
283
|
+
if (!s) return `Unreadable session ${m[1]}.`;
|
|
284
|
+
let i = m[2] ? s.branch.findIndex((e) => e.id === m[2]) : 0;
|
|
285
|
+
if (i < 0) i = 0;
|
|
286
|
+
const parts = [`session ${s.header.id} · ${s.header.timestamp.slice(0, 16)} · cwd ${s.header.cwd}${s.name ? ` · "${s.name}"` : ""}`];
|
|
287
|
+
for (let k = Math.max(0, i - 3); k <= Math.min(s.branch.length - 1, i + 4); k++) parts.push(renderEntryFull(s.branch[k], 1500));
|
|
288
|
+
return clipHead(parts.join("\n"), 9000);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private findSessionFile(id: string): string | undefined {
|
|
292
|
+
const P = memoryPaths(this.root);
|
|
293
|
+
try {
|
|
294
|
+
const idx = JSON.parse(readFileSync(P.digestIndex, "utf-8")) as Record<string, { sessionId: string }>;
|
|
295
|
+
for (const [file, rec] of Object.entries(idx)) if (rec.sessionId === id || (id.length >= 8 && rec.sessionId.startsWith(id))) return file;
|
|
296
|
+
} catch {
|
|
297
|
+
// no index yet
|
|
298
|
+
}
|
|
299
|
+
const root = sessionsRoot();
|
|
300
|
+
if (!existsSync(root)) return undefined;
|
|
301
|
+
for (const dir of readdirSync(root)) {
|
|
302
|
+
const full = join(root, dir);
|
|
303
|
+
try {
|
|
304
|
+
for (const name of readdirSync(full)) if (name.endsWith(`_${id}.jsonl`)) return join(full, name);
|
|
305
|
+
} catch {
|
|
306
|
+
// not a directory
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async inbox(ctx: ExtensionContext, text: string, source: "agent" | "user", kind?: InboxKind, scope?: "global" | "project"): Promise<string> {
|
|
313
|
+
const proj = await this.project(ctx.cwd);
|
|
314
|
+
const item = appendInbox(this.root, {
|
|
315
|
+
source,
|
|
316
|
+
text: text.trim(),
|
|
317
|
+
kind,
|
|
318
|
+
scope,
|
|
319
|
+
cwd: ctx.cwd,
|
|
320
|
+
projectKey: proj.key,
|
|
321
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
322
|
+
sessionFile: ctx.sessionManager.getSessionFile() ?? undefined,
|
|
323
|
+
entryId: ctx.sessionManager.getLeafId() ?? undefined,
|
|
324
|
+
});
|
|
325
|
+
return item.id;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── dreams ───────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
dreaming(): boolean {
|
|
331
|
+
return !!lockIsLive(memoryPaths(this.root).lock, this.cfg.dream.maxRuntimeMinutes * 90_000);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
dueCheck() {
|
|
335
|
+
const state = loadState(memoryPaths(this.root).state);
|
|
336
|
+
const sessions = pendingSessions(listSessionFiles(), state, this.cfg);
|
|
337
|
+
const inbox = pendingInbox(readInbox(this.root), state);
|
|
338
|
+
return checkDue(this.cfg, state, sessions.length, inbox.length, this.dreaming());
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
startDream(extra: string[] = []): { runId: string; log: string } {
|
|
342
|
+
const runId = newRunId();
|
|
343
|
+
const { log } = spawnDream(this.cfg, runId, extra);
|
|
344
|
+
return { runId, log };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async maybeDream(ctx: ExtensionContext): Promise<void> {
|
|
348
|
+
if (!this.active() || !this.cfg.schedule.enabled || process.env.PI_DREAM_CHILD === "1") return;
|
|
349
|
+
if (!existsSync(join(this.root, ".git"))) return;
|
|
350
|
+
const due = this.dueCheck();
|
|
351
|
+
if (!due.due) return;
|
|
352
|
+
const { log } = this.startDream();
|
|
353
|
+
ctx.ui.notify(
|
|
354
|
+
ru(this.cfg)
|
|
355
|
+
? `pi-dream: засыпаю в фоне — ${due.reason}. Лог: ${log}`
|
|
356
|
+
: `pi-dream: dreaming in the background — ${due.reason}. Log: ${log}`,
|
|
357
|
+
"info",
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private notifyFinishedDream(ctx: ExtensionContext): void {
|
|
362
|
+
const P = memoryPaths(this.root);
|
|
363
|
+
const state = loadState(P.state);
|
|
364
|
+
const last = state?.lastRun;
|
|
365
|
+
if (!last?.finishedAt) return;
|
|
366
|
+
let seen = "";
|
|
367
|
+
try {
|
|
368
|
+
seen = (JSON.parse(readFileSync(P.notify, "utf-8")) as { runId?: string }).runId ?? "";
|
|
369
|
+
} catch {
|
|
370
|
+
// first time
|
|
371
|
+
}
|
|
372
|
+
if (seen === last.id) return;
|
|
373
|
+
try {
|
|
374
|
+
mkdirSync(P.local, { recursive: true });
|
|
375
|
+
writeFileSync(P.notify, JSON.stringify({ runId: last.id }));
|
|
376
|
+
} catch {
|
|
377
|
+
// best effort
|
|
378
|
+
}
|
|
379
|
+
const r = ru(this.cfg);
|
|
380
|
+
if (last.status === "ok" && last.pr) ctx.ui.notify(r ? `pi-dream: сон закончился — PR на ревью: ${last.pr}` : `pi-dream: dream finished — PR to review: ${last.pr}`, "info");
|
|
381
|
+
else if (last.status === "error") ctx.ui.notify(r ? `pi-dream: сон упал — ${last.error ?? ""} (/dream log)` : `pi-dream: dream failed — ${last.error ?? ""} (/dream log)`, "warning");
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
status(ctx: ExtensionContext): string {
|
|
385
|
+
const P = memoryPaths(this.root);
|
|
386
|
+
const state = loadState(P.state);
|
|
387
|
+
const lock = lockIsLive(P.lock, this.cfg.dream.maxRuntimeMinutes * 90_000);
|
|
388
|
+
const snap = this.snapshot;
|
|
389
|
+
const scopes = existsSync(P.scopes) ? listScopes(this.root) : [];
|
|
390
|
+
const counts = scopes.map((s) => {
|
|
391
|
+
const notes = loadNotes(this.root, s);
|
|
392
|
+
return `${s === GLOBAL_SCOPE ? "global" : s.slice("projects/".length)}: ${notes.filter((n) => n.status === "active").length}`;
|
|
393
|
+
});
|
|
394
|
+
let due = "";
|
|
395
|
+
try {
|
|
396
|
+
const d = this.dueCheck();
|
|
397
|
+
due = `${d.due ? "due" : "not due"} (${d.reason})`;
|
|
398
|
+
} catch (err) {
|
|
399
|
+
due = String(err);
|
|
400
|
+
}
|
|
401
|
+
const last = state?.lastRun;
|
|
402
|
+
return [
|
|
403
|
+
`memory: ${this.root}${existsSync(join(this.root, ".git")) ? "" : " (not initialized: /dream init)"}`,
|
|
404
|
+
`active: ${this.active() ? "yes" : "no (--no-memory / PI_DREAM_DISABLE)"} · inject: ${this.cfg.inject}`,
|
|
405
|
+
`session: ${snap ? `${snap.ids.length} notes, ~${snap.tokens} tokens, memver ${snap.memver ?? "?"}` : "nothing injected yet (first turn builds it)"}`,
|
|
406
|
+
`project: ${snap?.projectKey ?? "(resolving)"}`,
|
|
407
|
+
`notes: ${counts.join(", ") || "none yet"}`,
|
|
408
|
+
`inbox: ${pendingInbox(readInbox(this.root), state).length} pending`,
|
|
409
|
+
`dream: ${lock ? `running (${lock.runId})` : due}`,
|
|
410
|
+
`last: ${last ? `${last.status} ${last.finishedAt ?? last.startedAt}${last.pr ? ` ${last.pr}` : ""}${last.error ? ` — ${last.error}` : ""}` : "never"}`,
|
|
411
|
+
`review: ${state?.openPr ? state.openPr.url : "no open PR"}`,
|
|
412
|
+
`cwd: ${ctx.cwd}`,
|
|
413
|
+
].join("\n");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
latestLog(lines = 40): string {
|
|
417
|
+
const P = memoryPaths(this.root);
|
|
418
|
+
if (!existsSync(P.logs)) return "no dream logs yet";
|
|
419
|
+
const files = readdirSync(P.logs)
|
|
420
|
+
.filter((f) => f.endsWith(".log"))
|
|
421
|
+
.map((f) => ({ f, t: statSync(join(P.logs, f)).mtimeMs }))
|
|
422
|
+
.sort((a, b) => b.t - a.t);
|
|
423
|
+
if (!files[0]) return "no dream logs yet";
|
|
424
|
+
const path = join(P.logs, files[0].f);
|
|
425
|
+
const text = readFileSync(path, "utf-8").trimEnd().split("\n");
|
|
426
|
+
return [`${path}:`, ...text.slice(-lines)].join("\n");
|
|
427
|
+
}
|
|
428
|
+
}
|
package/scheduler.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to dream, and how to start one.
|
|
3
|
+
*
|
|
4
|
+
* pi itself is the scheduler: when a session starts and a dream is due
|
|
5
|
+
* (enough new sessions or inbox items, enough time since the last one, no
|
|
6
|
+
* dream running), a detached headless pi process runs the dream. It keeps
|
|
7
|
+
* running after the interactive pi exits.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { closeSync, existsSync, mkdirSync, openSync } from "node:fs";
|
|
12
|
+
import { basename, dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { DreamConfig } from "./config.ts";
|
|
16
|
+
import type { InboxItem } from "./inbox.ts";
|
|
17
|
+
import { memoryPaths } from "./layout.ts";
|
|
18
|
+
import type { SessionFile } from "./sessions.ts";
|
|
19
|
+
import type { DreamState } from "./state.ts";
|
|
20
|
+
|
|
21
|
+
const MINUTE = 60_000;
|
|
22
|
+
const HOUR = 60 * MINUTE;
|
|
23
|
+
const DAY = 24 * HOUR;
|
|
24
|
+
|
|
25
|
+
/** Sessions the next dream should summarize, oldest first. */
|
|
26
|
+
export function pendingSessions(files: readonly SessionFile[], state: DreamState | undefined, cfg: DreamConfig, now = Date.now()): SessionFile[] {
|
|
27
|
+
const cutoff = state?.cutoffMs ?? now - cfg.dream.firstRunLookbackDays * DAY;
|
|
28
|
+
return files
|
|
29
|
+
.filter((f) => {
|
|
30
|
+
const mark = state?.sessions[f.path];
|
|
31
|
+
if (mark && mark.size === f.size && (mark.ok || mark.failures >= 3)) return false;
|
|
32
|
+
if (!mark && f.mtimeMs < cutoff) return false;
|
|
33
|
+
if (now - f.mtimeMs < cfg.dream.minIdleMinutes * MINUTE) return false;
|
|
34
|
+
return true;
|
|
35
|
+
})
|
|
36
|
+
.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function pendingInbox(items: readonly InboxItem[], state: DreamState | undefined): InboxItem[] {
|
|
40
|
+
const done = new Set(state?.inboxDone ?? []);
|
|
41
|
+
return items.filter((i) => !done.has(i.id));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DueCheck {
|
|
45
|
+
due: boolean;
|
|
46
|
+
reason: string;
|
|
47
|
+
sessions: number;
|
|
48
|
+
inbox: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function checkDue(
|
|
52
|
+
cfg: DreamConfig,
|
|
53
|
+
state: DreamState | undefined,
|
|
54
|
+
sessions: number,
|
|
55
|
+
inbox: number,
|
|
56
|
+
dreaming: boolean,
|
|
57
|
+
now = Date.now(),
|
|
58
|
+
): DueCheck {
|
|
59
|
+
const base = { sessions, inbox };
|
|
60
|
+
if (!cfg.schedule.enabled) return { ...base, due: false, reason: "schedule disabled" };
|
|
61
|
+
if (dreaming) return { ...base, due: false, reason: "a dream is running" };
|
|
62
|
+
const last = state?.lastRun?.finishedAt ?? state?.lastRun?.startedAt;
|
|
63
|
+
const minHours = state?.lastRun?.status === "error" ? Math.min(cfg.schedule.minHoursBetween, 2) : cfg.schedule.minHoursBetween;
|
|
64
|
+
if (last) {
|
|
65
|
+
const hours = (now - Date.parse(last)) / HOUR;
|
|
66
|
+
if (hours < minHours) return { ...base, due: false, reason: `last dream ${hours.toFixed(1)} h ago (< ${minHours} h)` };
|
|
67
|
+
}
|
|
68
|
+
if (sessions + inbox < cfg.schedule.minNewSessions)
|
|
69
|
+
return { ...base, due: false, reason: `${sessions} new sessions + ${inbox} inbox (< ${cfg.schedule.minNewSessions})` };
|
|
70
|
+
return { ...base, due: true, reason: `${sessions} new sessions, ${inbox} inbox items` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `@scope/name@1.2.3` → `@scope/name`, `name@1` → `name`. */
|
|
74
|
+
export function npmPackageName(spec: string): string {
|
|
75
|
+
const at = spec.indexOf("@", spec.startsWith("@") ? 1 : 0);
|
|
76
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Resolve `childExtensions` entries to paths pi can load with `-e`. */
|
|
80
|
+
export function resolveChildExtensions(list: readonly string[]): string[] {
|
|
81
|
+
const out: string[] = [];
|
|
82
|
+
for (const entry of list) {
|
|
83
|
+
if (entry.startsWith("npm:")) {
|
|
84
|
+
const path = join(getAgentDir(), "npm", "node_modules", npmPackageName(entry.slice(4)));
|
|
85
|
+
if (existsSync(path)) out.push(path);
|
|
86
|
+
} else if (existsSync(entry)) {
|
|
87
|
+
out.push(entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function runnerFile(): string {
|
|
94
|
+
return join(dirname(fileURLToPath(import.meta.url)), "runner.ts");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How to invoke pi again: the running CLI when we know it, else `pi` from PATH. */
|
|
98
|
+
function piCommand(): { cmd: string; pre: string[] } {
|
|
99
|
+
const script = process.argv[1];
|
|
100
|
+
if (script && (basename(script) === "pi" || script.endsWith("cli.js"))) return { cmd: process.execPath, pre: [script] };
|
|
101
|
+
return { cmd: "pi", pre: [] };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function dreamArgs(cfg: DreamConfig, runId: string, extra: readonly string[]): { cmd: string; args: string[] } {
|
|
105
|
+
const { cmd, pre } = piCommand();
|
|
106
|
+
const exts = resolveChildExtensions(cfg.dream.childExtensions).flatMap((p) => ["-e", p]);
|
|
107
|
+
const command = ["/pi-dream-run", "--run-id", runId, ...extra].join(" ");
|
|
108
|
+
return {
|
|
109
|
+
cmd,
|
|
110
|
+
args: [...pre, "-p", "--no-session", "--no-extensions", ...exts, "-e", runnerFile(), "--no-skills", "--no-context-files", "--no-prompt-templates", command],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Start a detached dream; returns the log file path. */
|
|
115
|
+
export function spawnDream(cfg: DreamConfig, runId: string, extra: readonly string[] = []): { pid?: number; log: string } {
|
|
116
|
+
const P = memoryPaths(cfg.memoryDir);
|
|
117
|
+
mkdirSync(P.logs, { recursive: true });
|
|
118
|
+
const log = join(P.logs, `${runId}.log`);
|
|
119
|
+
const fd = openSync(log, "a");
|
|
120
|
+
const { cmd, args } = dreamArgs(cfg, runId, extra);
|
|
121
|
+
try {
|
|
122
|
+
const child = spawn(cmd, args, {
|
|
123
|
+
cwd: cfg.memoryDir,
|
|
124
|
+
detached: true,
|
|
125
|
+
stdio: ["ignore", fd, fd],
|
|
126
|
+
env: { ...process.env, PI_DREAM_CHILD: "1" },
|
|
127
|
+
});
|
|
128
|
+
child.unref();
|
|
129
|
+
return { pid: child.pid, log };
|
|
130
|
+
} finally {
|
|
131
|
+
closeSync(fd);
|
|
132
|
+
}
|
|
133
|
+
}
|
package/search.test.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { Bm25, chunkDigest, snippet, tokenize } from "./search.ts";
|
|
3
|
+
|
|
4
|
+
describe("search", () => {
|
|
5
|
+
it("stems Russian and English word forms together", () => {
|
|
6
|
+
expect(tokenize("коммита")).toEqual(tokenize("коммит"));
|
|
7
|
+
expect(tokenize("деплоить")[0]).toBe(tokenize("деплой")[0]);
|
|
8
|
+
expect(tokenize("задеплой")).toEqual(expect.arrayContaining(tokenize("деплой")));
|
|
9
|
+
expect(tokenize("контекст")).not.toEqual(tokenize("контроль"));
|
|
10
|
+
expect(tokenize("deploys deploying")).toEqual(["deploy", "deploy"]);
|
|
11
|
+
expect(tokenize("и в на the a")).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("ranks the relevant document first", () => {
|
|
15
|
+
const bm = new Bm25([
|
|
16
|
+
{ id: "1", text: "добавь изменения в chezmoi без коммита", meta: 1 },
|
|
17
|
+
{ id: "2", text: "задеплой на тестовый стенд", meta: 2 },
|
|
18
|
+
{ id: "3", text: "tell me a joke", meta: 3 },
|
|
19
|
+
]);
|
|
20
|
+
expect(bm.search("chezmoi коммит", 3)[0].doc.id).toBe("1");
|
|
21
|
+
expect(bm.search("деплой на тест", 3)[0].doc.id).toBe("2");
|
|
22
|
+
expect(bm.search("nothing matches here", 3)).toEqual([]);
|
|
23
|
+
expect(bm.search("chezmoi", 3, (d) => d.id !== "1")).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("chunks digests at entry lines and finds snippets", () => {
|
|
27
|
+
const lines = ["header line"];
|
|
28
|
+
for (let i = 0; i < 40; i++) lines.push(`[${String(i).padStart(8, "0")}] USER: message ${i} ${"x".repeat(100)}`);
|
|
29
|
+
const chunks = chunkDigest(lines.join("\n"), 1000);
|
|
30
|
+
expect(chunks.length).toBeGreaterThan(3);
|
|
31
|
+
expect(chunks[1].entryId).toMatch(/^\d{8}$/);
|
|
32
|
+
expect(snippet("a\nb deploy here\nc", "deploy", 50)).toContain("deploy here");
|
|
33
|
+
});
|
|
34
|
+
});
|