@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/digest.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session digest: a compact, line-per-entry rendering of the active branch.
|
|
3
|
+
*
|
|
4
|
+
* User messages are kept almost whole, assistant text is clipped, tool calls
|
|
5
|
+
* are one line, tool results keep only a head (errors: head and tail).
|
|
6
|
+
* Images and thinking never appear. Every line carries its entry id, so the
|
|
7
|
+
* models can cite evidence the dream can later look up in the raw session.
|
|
8
|
+
* Credentials are scrubbed from every line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { normalize, resolve } from "node:path";
|
|
13
|
+
import { type ParsedSession, type RawEntry, textOf } from "./sessions.ts";
|
|
14
|
+
import { scrubSecrets } from "./secrets.ts";
|
|
15
|
+
|
|
16
|
+
export type LineKind = "USER" | "AI" | "CALL" | "RESULT" | "ERROR" | "SHELL" | "MEMORY" | "SUMMARY" | "CUSTOM";
|
|
17
|
+
|
|
18
|
+
export interface DigestLine {
|
|
19
|
+
id: string;
|
|
20
|
+
kind: LineKind;
|
|
21
|
+
text: string;
|
|
22
|
+
ts?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DigestMeta {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
file: string;
|
|
28
|
+
cwd: string;
|
|
29
|
+
started: string;
|
|
30
|
+
ended?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
models: string[];
|
|
33
|
+
userMessages: number;
|
|
34
|
+
toolCalls: number;
|
|
35
|
+
toolErrors: number;
|
|
36
|
+
/** Memory notes injected into this session by pi-dream. */
|
|
37
|
+
injected: string[];
|
|
38
|
+
/** Memory notes the agent opened with memory_read. */
|
|
39
|
+
read: string[];
|
|
40
|
+
lastEntryId?: string;
|
|
41
|
+
/** Absolute paths the session worked with (tool paths, `cd` targets), most used first. */
|
|
42
|
+
paths: Array<[string, number]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const MAX_PATHS = 40;
|
|
46
|
+
|
|
47
|
+
/** `~/x`, `./x`, `x` (relative to cwd) → absolute path; undefined for globs, variables and flags. */
|
|
48
|
+
export function expandPath(raw: string, cwd: string): string | undefined {
|
|
49
|
+
let t = raw.trim().replace(/^['"]|['"]$/g, "");
|
|
50
|
+
if (!t || t.startsWith("-") || /[$*?{}`]/.test(t) || t.includes("://")) return undefined;
|
|
51
|
+
if (t === "~") t = homedir();
|
|
52
|
+
else if (t.startsWith("~/")) t = homedir() + t.slice(1);
|
|
53
|
+
else if (!t.startsWith("/")) {
|
|
54
|
+
if (!cwd) return undefined;
|
|
55
|
+
t = resolve(cwd, t);
|
|
56
|
+
}
|
|
57
|
+
return normalize(t).replace(/\/+$/, "") || "/";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Paths a tool call works with: `path` arguments and, for bash, `cd` targets and home paths. */
|
|
61
|
+
/** Absolute paths under a home folder in a shell command: `~/…`, `/Users/<u>/…` (macOS), `/home/<u>/…` (Linux) or this machine's home. */
|
|
62
|
+
const HOME_PATH_RE = new RegExp(
|
|
63
|
+
`(?:^|[\\s'"=(:])((?:~|/Users/[^/\\s'"]+|/home/[^/\\s'"]+|${homedir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})/[^\\s'"\`;|&)<>]+)`,
|
|
64
|
+
"g",
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
export function callPaths(name: string, args: unknown, cwd: string): string[] {
|
|
68
|
+
const a = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
const add = (p: string | undefined) => {
|
|
71
|
+
if (p) out.push(p);
|
|
72
|
+
};
|
|
73
|
+
if (typeof a.path === "string") add(expandPath(a.path, cwd));
|
|
74
|
+
if (name === "bash" && typeof a.command === "string") {
|
|
75
|
+
const cmd = a.command;
|
|
76
|
+
for (const m of cmd.matchAll(/(?:^|[;&|(]|\n)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&|)]+)/g)) add(expandPath(m[1], cwd));
|
|
77
|
+
for (const m of cmd.matchAll(HOME_PATH_RE)) add(expandPath(m[1], cwd));
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface Digest {
|
|
83
|
+
meta: DigestMeta;
|
|
84
|
+
lines: DigestLine[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const LIMITS = { USER: 6000, AI: 2000, CALL: 400, RESULT: 200, ERROR: 700, SHELL: 600, SUMMARY: 1500, CUSTOM: 400 };
|
|
88
|
+
|
|
89
|
+
export function clipHead(text: string, max: number): string {
|
|
90
|
+
const t = text.trim();
|
|
91
|
+
return t.length <= max ? t : `${t.slice(0, max).trimEnd()} …[+${t.length - max} chars]`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function clipMiddle(text: string, max: number, headShare = 0.6): string {
|
|
95
|
+
const t = text.trim();
|
|
96
|
+
if (t.length <= max) return t;
|
|
97
|
+
const head = Math.floor(max * headShare);
|
|
98
|
+
const tail = max - head;
|
|
99
|
+
return `${t.slice(0, head).trimEnd()} …[${t.length - max} chars omitted]… ${t.slice(t.length - tail).trimStart()}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Collapse skill bodies and attached files pi inlines into user messages. */
|
|
103
|
+
export function stripInjected(text: string): string {
|
|
104
|
+
return text
|
|
105
|
+
.replace(/<skill\b[^>]*\bname="([^"]+)"[^>]*>[\s\S]*?<\/skill>/g, "[skill: $1]")
|
|
106
|
+
.replace(/<file\b[^>]*\bname="([^"]+)"[^>]*>[\s\S]*?<\/file>/g, "[file: $1]")
|
|
107
|
+
.trim();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function summarizeCall(name: string, args: unknown): string {
|
|
111
|
+
const a = (args && typeof args === "object" ? args : {}) as Record<string, unknown>;
|
|
112
|
+
const s = (v: unknown) => (typeof v === "string" ? v : v === undefined ? "" : JSON.stringify(v));
|
|
113
|
+
switch (name) {
|
|
114
|
+
case "bash":
|
|
115
|
+
return `bash $ ${s(a.command)}`;
|
|
116
|
+
case "read":
|
|
117
|
+
return `read ${s(a.path)}${a.offset ? ` @${s(a.offset)}` : ""}${a.limit ? `+${s(a.limit)}` : ""}`;
|
|
118
|
+
case "edit":
|
|
119
|
+
return `edit ${s(a.path)} (${Array.isArray(a.edits) ? a.edits.length : 1} edit(s))`;
|
|
120
|
+
case "write":
|
|
121
|
+
return `write ${s(a.path)} (${typeof a.content === "string" ? a.content.length : 0} chars)`;
|
|
122
|
+
case "grep":
|
|
123
|
+
case "find":
|
|
124
|
+
case "ls":
|
|
125
|
+
return `${name} ${s(a.pattern)} ${s(a.path)}`.trim();
|
|
126
|
+
default:
|
|
127
|
+
return `${name} ${s(args)}`;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function toolResultText(m: Record<string, unknown>): string {
|
|
132
|
+
return textOf(m.content);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function msgModel(m: Record<string, unknown>): string | undefined {
|
|
136
|
+
return typeof m.provider === "string" && typeof m.model === "string" ? `${m.provider}/${m.model}` : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Note ids an entry shows as injected (our own `pi-dream` custom entries). */
|
|
140
|
+
function injectedIds(e: RawEntry): string[] {
|
|
141
|
+
if (e.type !== "custom" || e.customType !== "pi-dream") return [];
|
|
142
|
+
const d = e.data as { event?: string; ids?: unknown } | undefined;
|
|
143
|
+
return d?.event === "inject" && Array.isArray(d.ids) ? d.ids.filter((x): x is string => typeof x === "string") : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function buildDigest(s: ParsedSession): Digest {
|
|
147
|
+
const lines: DigestLine[] = [];
|
|
148
|
+
const models = new Set<string>();
|
|
149
|
+
const injected = new Set<string>();
|
|
150
|
+
const read = new Set<string>();
|
|
151
|
+
let userMessages = 0;
|
|
152
|
+
let toolCalls = 0;
|
|
153
|
+
let toolErrors = 0;
|
|
154
|
+
let ended: string | undefined;
|
|
155
|
+
const paths = new Map<string, number>();
|
|
156
|
+
const touch = (list: string[]) => {
|
|
157
|
+
for (const p of list) paths.set(p, (paths.get(p) ?? 0) + 1);
|
|
158
|
+
};
|
|
159
|
+
const push = (e: RawEntry, kind: LineKind, text: string) => {
|
|
160
|
+
const t = scrubSecrets(text).trim();
|
|
161
|
+
if (t) lines.push({ id: e.id, kind, text: t, ts: e.timestamp });
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
for (const e of s.branch) {
|
|
165
|
+
if (e.timestamp) ended = e.timestamp;
|
|
166
|
+
const ids = injectedIds(e);
|
|
167
|
+
if (ids.length) {
|
|
168
|
+
for (const id of ids) injected.add(id);
|
|
169
|
+
push(e, "MEMORY", `injected notes: ${ids.join(", ")}`);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (e.type === "branch_summary" && typeof e.summary === "string") {
|
|
173
|
+
push(e, "SUMMARY", clipHead(e.summary, LIMITS.SUMMARY));
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (e.type === "custom_message") {
|
|
177
|
+
const ct = typeof e.customType === "string" ? e.customType : "custom";
|
|
178
|
+
push(e, "CUSTOM", `${ct}: ${clipHead(textOf(e.content), LIMITS.CUSTOM)}`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (e.type !== "message" || !e.message) continue;
|
|
182
|
+
const m = e.message;
|
|
183
|
+
switch (m.role) {
|
|
184
|
+
case "user": {
|
|
185
|
+
userMessages++;
|
|
186
|
+
push(e, "USER", clipMiddle(stripInjected(textOf(m.content)), LIMITS.USER));
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
case "assistant": {
|
|
190
|
+
const model = msgModel(m);
|
|
191
|
+
if (model) models.add(model);
|
|
192
|
+
const blocks = Array.isArray(m.content) ? m.content : [];
|
|
193
|
+
for (const b of blocks) {
|
|
194
|
+
if (!b || typeof b !== "object") continue;
|
|
195
|
+
const blk = b as { type?: string; text?: string; name?: string; arguments?: unknown };
|
|
196
|
+
if (blk.type === "text" && blk.text?.trim()) push(e, "AI", clipMiddle(blk.text, LIMITS.AI));
|
|
197
|
+
else if (blk.type === "toolCall" && blk.name) {
|
|
198
|
+
toolCalls++;
|
|
199
|
+
if (blk.name === "memory_read") {
|
|
200
|
+
const ref = (blk.arguments as { ref?: unknown } | undefined)?.ref;
|
|
201
|
+
if (typeof ref === "string" && ref.startsWith("m-")) read.add(ref);
|
|
202
|
+
}
|
|
203
|
+
touch(callPaths(blk.name, blk.arguments, s.header.cwd));
|
|
204
|
+
push(e, "CALL", clipHead(summarizeCall(blk.name, blk.arguments), LIMITS.CALL));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case "toolResult": {
|
|
210
|
+
const name = typeof m.toolName === "string" ? m.toolName : "tool";
|
|
211
|
+
const text = toolResultText(m);
|
|
212
|
+
if (m.isError) {
|
|
213
|
+
toolErrors++;
|
|
214
|
+
push(e, "ERROR", `${name}: ${clipMiddle(text, LIMITS.ERROR, 0.65)}`);
|
|
215
|
+
} else {
|
|
216
|
+
push(e, "RESULT", `${name}: ${clipHead(text, LIMITS.RESULT) || "(empty)"}`);
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case "bashExecution": {
|
|
221
|
+
const cmd = typeof m.command === "string" ? m.command : "";
|
|
222
|
+
const out = typeof m.output === "string" ? m.output : "";
|
|
223
|
+
touch(callPaths("bash", { command: cmd }, s.header.cwd));
|
|
224
|
+
push(e, "SHELL", `$ ${cmd} (exit ${String(m.exitCode ?? "?")})\n${clipMiddle(out, LIMITS.SHELL, 0.5)}`);
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "custom": {
|
|
228
|
+
const ct = typeof m.customType === "string" ? m.customType : "custom";
|
|
229
|
+
push(e, "CUSTOM", `${ct}: ${clipHead(textOf(m.content), LIMITS.CUSTOM)}`);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
default:
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
meta: {
|
|
239
|
+
sessionId: s.header.id,
|
|
240
|
+
file: s.file,
|
|
241
|
+
cwd: s.header.cwd,
|
|
242
|
+
started: s.header.timestamp,
|
|
243
|
+
ended,
|
|
244
|
+
name: s.name,
|
|
245
|
+
models: [...models],
|
|
246
|
+
userMessages,
|
|
247
|
+
toolCalls,
|
|
248
|
+
toolErrors,
|
|
249
|
+
injected: [...injected],
|
|
250
|
+
read: [...read],
|
|
251
|
+
lastEntryId: s.lastEntryId,
|
|
252
|
+
paths: [...paths.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_PATHS),
|
|
253
|
+
},
|
|
254
|
+
lines,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function renderLine(l: DigestLine): string {
|
|
259
|
+
const body = l.text.replace(/\n/g, "\n ");
|
|
260
|
+
return `[${l.id}] ${l.kind}: ${body}`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Shrinking passes applied in order until the digest fits. User lines survive longest. */
|
|
264
|
+
const PASSES: Array<(l: DigestLine) => DigestLine | null> = [
|
|
265
|
+
(l) => (l.kind === "RESULT" ? { ...l, text: clipHead(l.text, 80) } : l),
|
|
266
|
+
(l) => (l.kind === "AI" ? { ...l, text: clipMiddle(l.text, 700) } : l),
|
|
267
|
+
(l) => (l.kind === "CALL" ? { ...l, text: clipHead(l.text, 160) } : l),
|
|
268
|
+
(l) => (l.kind === "ERROR" || l.kind === "SHELL" || l.kind === "CUSTOM" ? { ...l, text: clipHead(l.text, 240) } : l),
|
|
269
|
+
(l) => (l.kind === "RESULT" ? null : l),
|
|
270
|
+
(l) => (l.kind === "AI" ? { ...l, text: clipMiddle(l.text, 300) } : l),
|
|
271
|
+
(l) => (l.kind === "USER" ? { ...l, text: clipMiddle(l.text, 2000) } : l),
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
export function renderDigest(d: Digest, maxChars = Number.POSITIVE_INFINITY): string {
|
|
275
|
+
let lines = d.lines;
|
|
276
|
+
let text = lines.map(renderLine).join("\n");
|
|
277
|
+
for (const pass of PASSES) {
|
|
278
|
+
if (text.length <= maxChars) return text;
|
|
279
|
+
lines = lines.map(pass).filter((l): l is DigestLine => l !== null);
|
|
280
|
+
text = lines.map(renderLine).join("\n");
|
|
281
|
+
}
|
|
282
|
+
if (text.length <= maxChars) return text;
|
|
283
|
+
const head = Math.floor(maxChars * 0.4);
|
|
284
|
+
const tail = maxChars - head;
|
|
285
|
+
return `${text.slice(0, head)}\n[… ${text.length - maxChars} chars omitted …]\n${text.slice(text.length - tail)}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function digestHeader(meta: DigestMeta, projectKey: string): string {
|
|
289
|
+
const parts = [
|
|
290
|
+
`session ${meta.sessionId}`,
|
|
291
|
+
`project ${projectKey}`,
|
|
292
|
+
`cwd ${meta.cwd}`,
|
|
293
|
+
`started ${meta.started}`,
|
|
294
|
+
meta.name ? `name "${meta.name}"` : "",
|
|
295
|
+
meta.models.length ? `models ${meta.models.join(", ")}` : "",
|
|
296
|
+
`user messages ${meta.userMessages}, tool calls ${meta.toolCalls}, tool errors ${meta.toolErrors}`,
|
|
297
|
+
];
|
|
298
|
+
return parts.filter(Boolean).join(" | ");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** A full rendering of one raw entry, for evidence excerpts shown to the fact checker. */
|
|
302
|
+
export function renderEntryFull(e: RawEntry, max = 4000): string {
|
|
303
|
+
const line = (kind: string, text: string) => `[${e.id}] ${kind}: ${scrubSecrets(text).trim()}`;
|
|
304
|
+
if (e.type === "branch_summary" && typeof e.summary === "string") return line("SUMMARY", clipHead(e.summary, max));
|
|
305
|
+
if (e.type === "custom_message") return line("CUSTOM", clipHead(textOf(e.content), max));
|
|
306
|
+
if (e.type !== "message" || !e.message) return line(e.type.toUpperCase(), "");
|
|
307
|
+
const m = e.message;
|
|
308
|
+
switch (m.role) {
|
|
309
|
+
case "user":
|
|
310
|
+
return line("USER", clipMiddle(stripInjected(textOf(m.content)), max));
|
|
311
|
+
case "assistant": {
|
|
312
|
+
const parts: string[] = [];
|
|
313
|
+
for (const b of Array.isArray(m.content) ? m.content : []) {
|
|
314
|
+
const blk = b as { type?: string; text?: string; name?: string; arguments?: unknown };
|
|
315
|
+
if (blk?.type === "text" && blk.text) parts.push(blk.text);
|
|
316
|
+
if (blk?.type === "toolCall" && blk.name) parts.push(`CALL ${clipHead(summarizeCall(blk.name, blk.arguments), 1500)}`);
|
|
317
|
+
}
|
|
318
|
+
return line("AI", clipMiddle(parts.join("\n"), max));
|
|
319
|
+
}
|
|
320
|
+
case "toolResult": {
|
|
321
|
+
const name = typeof m.toolName === "string" ? m.toolName : "tool";
|
|
322
|
+
return line(m.isError ? "ERROR" : "RESULT", `${name}: ${clipMiddle(textOf(m.content), Math.min(max, 2000), 0.7)}`);
|
|
323
|
+
}
|
|
324
|
+
case "bashExecution":
|
|
325
|
+
return line("SHELL", `$ ${String(m.command ?? "")}\n${clipMiddle(String(m.output ?? ""), Math.min(max, 2000), 0.5)}`);
|
|
326
|
+
default:
|
|
327
|
+
return line(String(m.role ?? "ENTRY").toUpperCase(), clipHead(textOf(m.content), max));
|
|
328
|
+
}
|
|
329
|
+
}
|
package/gitops.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin, timeout-bounded wrappers around `git` and `gh`.
|
|
3
|
+
*
|
|
4
|
+
* Background dreams must never hang on a prompt: git runs with
|
|
5
|
+
* `GIT_TERMINAL_PROMPT=0` and ssh in batch mode, gh with `GH_PROMPT_DISABLED`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
|
|
10
|
+
export interface ExecResult {
|
|
11
|
+
code: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ENV = {
|
|
17
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
18
|
+
GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? "ssh -o BatchMode=yes -o ConnectTimeout=20",
|
|
19
|
+
GH_PROMPT_DISABLED: "1",
|
|
20
|
+
GH_NO_UPDATE_NOTIFIER: "1",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function run(cmd: string, args: string[], opts: { cwd?: string; timeoutMs?: number } = {}): Promise<ExecResult> {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
execFile(
|
|
26
|
+
cmd,
|
|
27
|
+
args,
|
|
28
|
+
{ cwd: opts.cwd, timeout: opts.timeoutMs ?? 60_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, ...ENV } },
|
|
29
|
+
(err, stdout, stderr) => {
|
|
30
|
+
const e = err as (NodeJS.ErrnoException & { code?: number | string; killed?: boolean }) | null;
|
|
31
|
+
const code = !e ? 0 : typeof e.code === "number" ? e.code : 1;
|
|
32
|
+
const extra = e && !String(stderr ?? "").trim() ? (e.killed ? "timed out" : e.message) : "";
|
|
33
|
+
resolve({ code, stdout: String(stdout ?? ""), stderr: `${String(stderr ?? "")}${extra}` });
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function git(cwd: string, args: string[], timeoutMs?: number): Promise<ExecResult> {
|
|
40
|
+
return run("git", ["-C", cwd, ...args], { timeoutMs });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Run git and return trimmed stdout; throws with stderr on failure. */
|
|
44
|
+
export async function gitOk(cwd: string, args: string[], timeoutMs?: number): Promise<string> {
|
|
45
|
+
const r = await git(cwd, args, timeoutMs);
|
|
46
|
+
if (r.code !== 0) throw new Error(`git ${args.join(" ")}: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
47
|
+
return r.stdout.trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function gh(args: string[], opts: { cwd?: string; timeoutMs?: number } = {}): Promise<ExecResult> {
|
|
51
|
+
return run("gh", args, opts);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function ghOk(args: string[], opts: { cwd?: string; timeoutMs?: number } = {}): Promise<string> {
|
|
55
|
+
const r = await gh(args, opts);
|
|
56
|
+
if (r.code !== 0) throw new Error(`gh ${args.slice(0, 3).join(" ")}: ${r.stderr.trim() || `exit ${r.code}`}`);
|
|
57
|
+
return r.stdout.trim();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function ghJson<T>(args: string[], opts: { cwd?: string; timeoutMs?: number } = {}): Promise<T> {
|
|
61
|
+
return JSON.parse(await ghOk(args, opts)) as T;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `owner/repo` for a GitHub remote URL, or undefined. */
|
|
65
|
+
export function githubRepoOf(remoteUrl: string | undefined): string | undefined {
|
|
66
|
+
if (!remoteUrl) return undefined;
|
|
67
|
+
const m = /github\.com[:/]+([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(remoteUrl.trim());
|
|
68
|
+
return m ? `${m[1]}/${m[2]}` : undefined;
|
|
69
|
+
}
|
package/inbox.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inbox: things an agent (memory_inbox) or the user (/remember) wants the
|
|
3
|
+
* next dream to consider. Append-only JSONL per day under `.local/inbox/`;
|
|
4
|
+
* items are never active memory by themselves — the dream reviews them like
|
|
5
|
+
* any other evidence and proposes notes in the pull request.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { memoryPaths, today } from "./layout.ts";
|
|
12
|
+
|
|
13
|
+
export const INBOX_KINDS = ["preference", "procedure", "pitfall", "fact", "decision", "reference", "other"] as const;
|
|
14
|
+
export type InboxKind = (typeof INBOX_KINDS)[number];
|
|
15
|
+
|
|
16
|
+
export interface InboxItem {
|
|
17
|
+
id: string;
|
|
18
|
+
ts: string;
|
|
19
|
+
source: "agent" | "user";
|
|
20
|
+
text: string;
|
|
21
|
+
kind?: InboxKind;
|
|
22
|
+
scope?: "global" | "project";
|
|
23
|
+
cwd: string;
|
|
24
|
+
projectKey: string;
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
sessionFile?: string;
|
|
27
|
+
/** Leaf entry of the session when the item was written. */
|
|
28
|
+
entryId?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function appendInbox(root: string, item: Omit<InboxItem, "id" | "ts">): InboxItem {
|
|
32
|
+
const dir = memoryPaths(root).inbox;
|
|
33
|
+
mkdirSync(dir, { recursive: true });
|
|
34
|
+
const full: InboxItem = { id: `i-${randomBytes(4).toString("hex")}`, ts: new Date().toISOString(), ...item };
|
|
35
|
+
appendFileSync(join(dir, `${today()}.jsonl`), `${JSON.stringify(full)}\n`, "utf-8");
|
|
36
|
+
return full;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readInbox(root: string): InboxItem[] {
|
|
40
|
+
const dir = memoryPaths(root).inbox;
|
|
41
|
+
if (!existsSync(dir)) return [];
|
|
42
|
+
const out: InboxItem[] = [];
|
|
43
|
+
for (const name of readdirSync(dir).sort()) {
|
|
44
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
45
|
+
for (const line of readFileSync(join(dir, name), "utf-8").split("\n")) {
|
|
46
|
+
if (!line.trim()) continue;
|
|
47
|
+
try {
|
|
48
|
+
const item = JSON.parse(line) as InboxItem;
|
|
49
|
+
if (item?.id && typeof item.text === "string") out.push(item);
|
|
50
|
+
} catch {
|
|
51
|
+
// torn line: skip
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @wierdbytes/pi-dream — offline memory consolidation ("dreaming") for pi.
|
|
3
|
+
*
|
|
4
|
+
* Wiring only: the in-session behaviour lives in `runtime.ts`, the dream in
|
|
5
|
+
* `pipeline.ts` (run by `runner.ts` in a headless pi process).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { registerCommands } from "./commands.ts";
|
|
10
|
+
import { Runtime } from "./runtime.ts";
|
|
11
|
+
import { registerTools } from "./tools.ts";
|
|
12
|
+
|
|
13
|
+
export default function piDream(pi: ExtensionAPI) {
|
|
14
|
+
if (process.env.PI_DREAM_CHILD === "1") return;
|
|
15
|
+
|
|
16
|
+
pi.registerFlag("no-memory", {
|
|
17
|
+
description: "pi-dream: no memory injection and no memory tools in this run",
|
|
18
|
+
type: "boolean",
|
|
19
|
+
default: false,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const rt = new Runtime(pi);
|
|
23
|
+
|
|
24
|
+
pi.on("session_start", async (event, ctx) => rt.onSessionStart(event.reason, ctx));
|
|
25
|
+
pi.on("resources_discover", async (event) => ({ skillPaths: rt.active() ? await rt.resourcesFor(event.cwd) : [] }));
|
|
26
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
27
|
+
const section = await rt.memorySection(ctx);
|
|
28
|
+
if (section) (event.systemPromptOptions.sections ??= {}).memory = section;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
registerTools(pi, rt);
|
|
32
|
+
registerCommands(pi, rt);
|
|
33
|
+
}
|
package/layout.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout of the memory repository.
|
|
3
|
+
*
|
|
4
|
+
* ```
|
|
5
|
+
* <memoryDir>/ git repo, `main` = approved memory
|
|
6
|
+
* scopes/global/ loaded in every session on this machine
|
|
7
|
+
* charter.md what this memory is for (steers the dream)
|
|
8
|
+
* MEMORY.md generated index — the part injected into sessions
|
|
9
|
+
* notes/<slug>.md one note per file, YAML front matter + body
|
|
10
|
+
* skills/<name>/SKILL.md approved skills (exposed to pi)
|
|
11
|
+
* scopes/projects/<key>/ same, for one project (git remote or path)
|
|
12
|
+
* reports/<run>.md one report per dream
|
|
13
|
+
* prompts/<stage>.md optional overrides of the dream prompts
|
|
14
|
+
* .local/ gitignored: inbox, digests, cards, state, logs
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
export const GLOBAL_SCOPE = "global";
|
|
22
|
+
export const INDEX_FILE = "MEMORY.md";
|
|
23
|
+
export const CHARTER_FILE = "charter.md";
|
|
24
|
+
export const SCOPE_META_FILE = "scope.json";
|
|
25
|
+
|
|
26
|
+
/** Directory-safe name for a project key (`github.com/o/r` → `github.com__o__r`). */
|
|
27
|
+
export function keyToDir(key: string): string {
|
|
28
|
+
const raw = key.startsWith("local:") ? `local/${key.slice(6)}` : key;
|
|
29
|
+
let s = raw
|
|
30
|
+
.split("/")
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join("__")
|
|
33
|
+
.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
34
|
+
if (s.length > 120) s = `${s.slice(0, 100)}__${createHash("sha256").update(key).digest("hex").slice(0, 8)}`;
|
|
35
|
+
return s || "_";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Scope id for a project key: `projects/<dir>`. */
|
|
39
|
+
export function projectScope(key: string): string {
|
|
40
|
+
return `projects/${keyToDir(key)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function scopeDir(root: string, scope: string): string {
|
|
44
|
+
return join(root, "scopes", scope);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MemoryPaths {
|
|
48
|
+
root: string;
|
|
49
|
+
scopes: string;
|
|
50
|
+
reports: string;
|
|
51
|
+
prompts: string;
|
|
52
|
+
local: string;
|
|
53
|
+
state: string;
|
|
54
|
+
lock: string;
|
|
55
|
+
notify: string;
|
|
56
|
+
inbox: string;
|
|
57
|
+
digests: string;
|
|
58
|
+
digestIndex: string;
|
|
59
|
+
cards: string;
|
|
60
|
+
runs: string;
|
|
61
|
+
logs: string;
|
|
62
|
+
worktrees: string;
|
|
63
|
+
sync: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function memoryPaths(root: string): MemoryPaths {
|
|
67
|
+
const local = join(root, ".local");
|
|
68
|
+
return {
|
|
69
|
+
root,
|
|
70
|
+
scopes: join(root, "scopes"),
|
|
71
|
+
reports: join(root, "reports"),
|
|
72
|
+
prompts: join(root, "prompts"),
|
|
73
|
+
local,
|
|
74
|
+
state: join(local, "state.json"),
|
|
75
|
+
lock: join(local, "dream.lock"),
|
|
76
|
+
notify: join(local, "notified.json"),
|
|
77
|
+
inbox: join(local, "inbox"),
|
|
78
|
+
digests: join(local, "digests"),
|
|
79
|
+
digestIndex: join(local, "digests", "index.json"),
|
|
80
|
+
cards: join(local, "cards"),
|
|
81
|
+
runs: join(local, "runs"),
|
|
82
|
+
logs: join(local, "logs"),
|
|
83
|
+
worktrees: join(local, "worktrees"),
|
|
84
|
+
sync: join(local, "last-sync"),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Local calendar date `YYYY-MM-DD`. */
|
|
89
|
+
export function today(d: Date = new Date()): string {
|
|
90
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
91
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Sortable run id: `20261108-0415-a1b2`. */
|
|
95
|
+
export function newRunId(d: Date = new Date()): string {
|
|
96
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
97
|
+
const rand = Math.random().toString(16).slice(2, 6);
|
|
98
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}-${rand}`;
|
|
99
|
+
}
|