@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/search.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small in-memory BM25 for notes and session digests (Russian + English).
|
|
3
|
+
*
|
|
4
|
+
* The corpus is tiny (hundreds of notes, a few thousand digest chunks), so a
|
|
5
|
+
* plain inverted index rebuilt on demand beats running a search service.
|
|
6
|
+
*
|
|
7
|
+
* Russian inflects heavily, so a Cyrillic word yields truncated forms at two
|
|
8
|
+
* lengths (4 and 6 letters) plus the same without a common verb prefix:
|
|
9
|
+
* "коммита"/"коммит" and "задеплой"/"деплой" meet, while the longer form
|
|
10
|
+
* keeps "контекст" apart from "контроль". English gets light suffix
|
|
11
|
+
* stripping ("deploys" → "deploy").
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const RU_PREFIXES = ["пере", "под", "при", "про", "раз", "рас", "за", "вы", "по", "на", "от", "до", "об", "вз", "из"];
|
|
15
|
+
|
|
16
|
+
const STOP = new Set(
|
|
17
|
+
"и в во не что он на я с со как а то все она так его но да ты к у же вы за бы по только ее мне было вот от меня еще нет о из ему теперь когда даже ну вдруг ли если уже или ни быть был него до вас нибудь опять уж вам ведь там потом себя ничего ей может они тут где есть надо ней для мы тебя их чем была сам чтоб без будто чего раз тоже себе под будет ж тогда кто этот того потому этого какой совсем ним здесь этом один почти мой тем чтобы нее сейчас были куда зачем всех никогда можно при наконец два об другой хоть после над больше тот через эти нас про всего них какая много разве три эту моя впрочем хорошо свою этой перед иногда лучше чуть том нельзя такой им более всегда конечно всю между the a an and or of to in on for is it this that with be as at by from are was were has have had not but if then so do does did can will just".split(
|
|
18
|
+
" ",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
function ruForms(word: string): string[] {
|
|
23
|
+
if (word.length <= 4) return [word];
|
|
24
|
+
const out = [word.slice(0, 4)];
|
|
25
|
+
if (word.length >= 6) out.push(word.slice(0, 6));
|
|
26
|
+
for (const p of RU_PREFIXES) {
|
|
27
|
+
if (word.startsWith(p) && word.length - p.length >= 5) {
|
|
28
|
+
const rest = word.slice(p.length);
|
|
29
|
+
out.push(rest.slice(0, 4));
|
|
30
|
+
if (rest.length >= 6) out.push(rest.slice(0, 6));
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function enStem(word: string): string {
|
|
38
|
+
if (word.length > 4) {
|
|
39
|
+
for (const suf of ["ing", "ed", "es", "s"]) if (word.endsWith(suf) && word.length - suf.length >= 3) return word.slice(0, -suf.length);
|
|
40
|
+
}
|
|
41
|
+
return word;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function tokenize(text: string): string[] {
|
|
45
|
+
const out: string[] = [];
|
|
46
|
+
for (const raw of text.toLowerCase().replace(/ё/g, "е").split(/[^\p{L}\p{N}_]+/u)) {
|
|
47
|
+
if (raw.length < 2 || STOP.has(raw)) continue;
|
|
48
|
+
if (/^[а-я]+$/.test(raw)) out.push(...ruForms(raw));
|
|
49
|
+
else if (/^[a-z]+$/.test(raw)) out.push(enStem(raw));
|
|
50
|
+
else out.push(raw);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Doc<M = unknown> {
|
|
56
|
+
id: string;
|
|
57
|
+
text: string;
|
|
58
|
+
meta: M;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Hit<M = unknown> {
|
|
62
|
+
doc: Doc<M>;
|
|
63
|
+
score: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class Bm25<M = unknown> {
|
|
67
|
+
private postings = new Map<string, Array<[number, number]>>();
|
|
68
|
+
private lengths: number[] = [];
|
|
69
|
+
private avg = 0;
|
|
70
|
+
constructor(
|
|
71
|
+
readonly docs: Doc<M>[],
|
|
72
|
+
private k1 = 1.2,
|
|
73
|
+
private b = 0.75,
|
|
74
|
+
) {
|
|
75
|
+
let total = 0;
|
|
76
|
+
docs.forEach((doc, i) => {
|
|
77
|
+
const tf = new Map<string, number>();
|
|
78
|
+
const toks = tokenize(doc.text);
|
|
79
|
+
for (const t of toks) tf.set(t, (tf.get(t) ?? 0) + 1);
|
|
80
|
+
for (const [t, n] of tf) {
|
|
81
|
+
let list = this.postings.get(t);
|
|
82
|
+
if (!list) this.postings.set(t, (list = []));
|
|
83
|
+
list.push([i, n]);
|
|
84
|
+
}
|
|
85
|
+
this.lengths.push(toks.length);
|
|
86
|
+
total += toks.length;
|
|
87
|
+
});
|
|
88
|
+
this.avg = docs.length ? total / docs.length : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
search(query: string, limit = 10, filter?: (doc: Doc<M>) => boolean): Hit<M>[] {
|
|
92
|
+
const terms = [...new Set(tokenize(query))];
|
|
93
|
+
if (!terms.length || !this.docs.length) return [];
|
|
94
|
+
const scores = new Map<number, number>();
|
|
95
|
+
const N = this.docs.length;
|
|
96
|
+
for (const t of terms) {
|
|
97
|
+
const list = this.postings.get(t);
|
|
98
|
+
if (!list) continue;
|
|
99
|
+
const idf = Math.log(1 + (N - list.length + 0.5) / (list.length + 0.5));
|
|
100
|
+
for (const [i, tf] of list) {
|
|
101
|
+
const len = this.lengths[i] || 1;
|
|
102
|
+
const s = (idf * tf * (this.k1 + 1)) / (tf + this.k1 * (1 - this.b + (this.b * len) / (this.avg || 1)));
|
|
103
|
+
scores.set(i, (scores.get(i) ?? 0) + s);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return [...scores.entries()]
|
|
107
|
+
.filter(([i]) => !filter || filter(this.docs[i]))
|
|
108
|
+
.sort((a, b) => b[1] - a[1])
|
|
109
|
+
.slice(0, limit)
|
|
110
|
+
.map(([i, score]) => ({ doc: this.docs[i], score }));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The lines of `text` that best match `query`, clipped to `max` chars. */
|
|
115
|
+
export function snippet(text: string, query: string, max = 320): string {
|
|
116
|
+
const terms = new Set(tokenize(query));
|
|
117
|
+
const lines = text.split("\n");
|
|
118
|
+
let best = 0;
|
|
119
|
+
let bestScore = -1;
|
|
120
|
+
lines.forEach((line, i) => {
|
|
121
|
+
const score = tokenize(line).filter((t) => terms.has(t)).length;
|
|
122
|
+
if (score > bestScore) {
|
|
123
|
+
bestScore = score;
|
|
124
|
+
best = i;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
let out = lines[best] ?? "";
|
|
128
|
+
for (let i = best + 1; i < lines.length && out.length < max; i++) out += `\n${lines[i]}`;
|
|
129
|
+
return out.length > max ? `${out.slice(0, max)}…` : out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Split a digest into chunks of whole lines; each chunk starts at an entry line. */
|
|
133
|
+
export function chunkDigest(text: string, maxChars = 2400): Array<{ entryId?: string; text: string }> {
|
|
134
|
+
const chunks: Array<{ entryId?: string; text: string }> = [];
|
|
135
|
+
let cur: string[] = [];
|
|
136
|
+
let size = 0;
|
|
137
|
+
let entryId: string | undefined;
|
|
138
|
+
const flush = () => {
|
|
139
|
+
if (cur.length) chunks.push({ entryId, text: cur.join("\n") });
|
|
140
|
+
cur = [];
|
|
141
|
+
size = 0;
|
|
142
|
+
entryId = undefined;
|
|
143
|
+
};
|
|
144
|
+
for (const line of text.split("\n")) {
|
|
145
|
+
const id = /^\[([0-9a-f-]{6,})\]/.exec(line)?.[1];
|
|
146
|
+
if (id && size + line.length > maxChars) flush();
|
|
147
|
+
if (id && !entryId) entryId = id;
|
|
148
|
+
cur.push(line);
|
|
149
|
+
size += line.length + 1;
|
|
150
|
+
}
|
|
151
|
+
flush();
|
|
152
|
+
return chunks;
|
|
153
|
+
}
|
package/secrets.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redact credentials before transcript text reaches a model or a note, and
|
|
3
|
+
* detect them when linting what the dream is about to write.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
interface Rule {
|
|
7
|
+
kind: string;
|
|
8
|
+
re: RegExp;
|
|
9
|
+
/** Keep capture group 1 (e.g. the key name) and redact the rest. */
|
|
10
|
+
keepPrefix?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RULES: Rule[] = [
|
|
14
|
+
{ kind: "private-key", re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g },
|
|
15
|
+
{ kind: "anthropic-key", re: /\bsk-ant-[A-Za-z0-9_-]{16,}/g },
|
|
16
|
+
{ kind: "openai-key", re: /\bsk-(?!ant-|lf-)(?:proj-|svcacct-)?[A-Za-z0-9_-]{20,}/g },
|
|
17
|
+
{ kind: "langfuse-key", re: /\b(?:pk|sk)-lf-[A-Za-z0-9-]{8,}/g },
|
|
18
|
+
{ kind: "github-token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g },
|
|
19
|
+
{ kind: "aws-key", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
20
|
+
{ kind: "slack-token", re: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g },
|
|
21
|
+
{ kind: "google-key", re: /\bAIza[0-9A-Za-z_-]{30,}/g },
|
|
22
|
+
{ kind: "jwt", re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
|
|
23
|
+
{ kind: "bearer", re: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{20,}=*/g, keepPrefix: true },
|
|
24
|
+
{
|
|
25
|
+
kind: "assignment",
|
|
26
|
+
re: /\b((?:api[_-]?key|apikey|secret|client[_-]?secret|token|access[_-]?token|auth[_-]?token|refresh[_-]?token|password|passwd|pwd)["']?\s*[:=]\s*["']?)([^\s"'`,;]{8,})/gi,
|
|
27
|
+
keepPrefix: true,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
export function scrubSecrets(text: string): string {
|
|
32
|
+
if (!text) return text;
|
|
33
|
+
let out = text;
|
|
34
|
+
for (const rule of RULES) {
|
|
35
|
+
rule.re.lastIndex = 0;
|
|
36
|
+
out = out.replace(rule.re, (match: string, prefix?: string) =>
|
|
37
|
+
rule.keepPrefix && typeof prefix === "string" ? `${prefix}[REDACTED]` : `[REDACTED:${rule.kind}]`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Kinds of credentials found in `text` (empty when clean). */
|
|
44
|
+
export function findSecrets(text: string): string[] {
|
|
45
|
+
const found = new Set<string>();
|
|
46
|
+
for (const rule of RULES) {
|
|
47
|
+
rule.re.lastIndex = 0;
|
|
48
|
+
for (const m of text.matchAll(rule.re)) {
|
|
49
|
+
// `[REDACTED]` placeholders left by scrubSecrets are not secrets.
|
|
50
|
+
const value = rule.keepPrefix ? (m[2] ?? m[0]) : m[0];
|
|
51
|
+
if (!value.includes("[REDACTED")) found.add(rule.kind);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return [...found];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const INJECTION: Array<[string, RegExp]> = [
|
|
58
|
+
["ignore-instructions", /ignore (?:all |any )?(?:the )?(?:previous|prior|above) instructions/i],
|
|
59
|
+
["disregard-instructions", /disregard (?:all |any )?(?:the )?(?:previous|prior|above)/i],
|
|
60
|
+
["fake-prompt-tag", /<\/?(?:system|instructions|memory|project_context)>/i],
|
|
61
|
+
["role-override", /you are now\b/i],
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const RISKY: Array<[string, RegExp]> = [["pipe-to-shell", /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z)?sh\b/i]];
|
|
65
|
+
|
|
66
|
+
/** Prompt-injection phrases: never acceptable as memory text. */
|
|
67
|
+
export function findInjection(text: string): string[] {
|
|
68
|
+
return INJECTION.filter(([, re]) => re.test(text)).map(([name]) => name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Risky but possibly legitimate content (e.g. an installer piped to sh): flagged for the reviewer. */
|
|
72
|
+
export function findRisky(text: string): string[] {
|
|
73
|
+
return RISKY.filter(([, re]) => re.test(text)).map(([name]) => name);
|
|
74
|
+
}
|
package/sessions.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading pi session files (`~/.pi/agent/sessions/<cwd>/<ts>_<id>.jsonl`).
|
|
3
|
+
*
|
|
4
|
+
* Sessions are trees; the newest appended entry is the current leaf, so the
|
|
5
|
+
* active branch is the parent chain from the last entry back to the root.
|
|
6
|
+
* Images and thinking are dropped while streaming, which keeps 100 MB
|
|
7
|
+
* sessions full of screenshots cheap to hold in memory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { createReadStream, existsSync, readdirSync, statSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { createInterface } from "node:readline";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
export interface SessionFile {
|
|
16
|
+
path: string;
|
|
17
|
+
mtimeMs: number;
|
|
18
|
+
size: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RawEntry {
|
|
22
|
+
type: string;
|
|
23
|
+
id: string;
|
|
24
|
+
parentId?: string | null;
|
|
25
|
+
timestamp?: string;
|
|
26
|
+
message?: Record<string, unknown> & { role?: string; content?: unknown };
|
|
27
|
+
customType?: string;
|
|
28
|
+
data?: unknown;
|
|
29
|
+
content?: unknown;
|
|
30
|
+
summary?: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SessionHeader {
|
|
36
|
+
id: string;
|
|
37
|
+
cwd: string;
|
|
38
|
+
timestamp: string;
|
|
39
|
+
parentSession?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ParsedSession {
|
|
43
|
+
file: string;
|
|
44
|
+
header: SessionHeader;
|
|
45
|
+
/** Active branch, root first. */
|
|
46
|
+
branch: RawEntry[];
|
|
47
|
+
name?: string;
|
|
48
|
+
lastEntryId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sessionsRoot(): string {
|
|
52
|
+
return process.env.PI_CODING_AGENT_SESSION_DIR || join(getAgentDir(), "sessions");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listSessionFiles(root: string = sessionsRoot()): SessionFile[] {
|
|
56
|
+
const out: SessionFile[] = [];
|
|
57
|
+
if (!existsSync(root)) return out;
|
|
58
|
+
for (const dir of readdirSync(root, { withFileTypes: true })) {
|
|
59
|
+
if (!dir.isDirectory()) continue;
|
|
60
|
+
const full = join(root, dir.name);
|
|
61
|
+
let names: string[];
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(full);
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const name of names) {
|
|
68
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
69
|
+
const path = join(full, name);
|
|
70
|
+
try {
|
|
71
|
+
const st = statSync(path);
|
|
72
|
+
if (st.isFile()) out.push({ path, mtimeMs: st.mtimeMs, size: st.size });
|
|
73
|
+
} catch {
|
|
74
|
+
// vanished between readdir and stat
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Session id from a file name `<timestamp>_<id>.jsonl`. */
|
|
82
|
+
export function sessionIdFromPath(path: string): string {
|
|
83
|
+
const base = path.split("/").pop() ?? path;
|
|
84
|
+
const m = /_([^_]+)\.jsonl$/.exec(base);
|
|
85
|
+
return m ? m[1] : base.replace(/\.jsonl$/, "");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function slim(content: unknown): unknown {
|
|
89
|
+
if (!Array.isArray(content)) return content;
|
|
90
|
+
return content.map((b) => {
|
|
91
|
+
if (!b || typeof b !== "object") return b;
|
|
92
|
+
const t = (b as { type?: string }).type;
|
|
93
|
+
if (t === "image") return { type: "image" };
|
|
94
|
+
if (t === "thinking" || t === "redacted_thinking") return { type: "thinking" };
|
|
95
|
+
return b;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function slimEntry(e: RawEntry): RawEntry {
|
|
100
|
+
if (e.message && typeof e.message === "object") {
|
|
101
|
+
const m = e.message;
|
|
102
|
+
if (m.role === "system") e.message = { role: "system" };
|
|
103
|
+
else if ("content" in m) m.content = slim(m.content);
|
|
104
|
+
}
|
|
105
|
+
if (e.type === "custom_message" && "content" in e) e.content = slim(e.content);
|
|
106
|
+
if (e.type === "compaction") delete e.systemMessage;
|
|
107
|
+
return e;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function parseSession(file: string): Promise<ParsedSession | undefined> {
|
|
111
|
+
const entries = new Map<string, RawEntry>();
|
|
112
|
+
let header: SessionHeader | undefined;
|
|
113
|
+
let lastId: string | undefined;
|
|
114
|
+
let name: string | undefined;
|
|
115
|
+
const rl = createInterface({ input: createReadStream(file, { encoding: "utf8" }), crlfDelay: Infinity });
|
|
116
|
+
for await (const line of rl) {
|
|
117
|
+
if (!line) continue;
|
|
118
|
+
let e: RawEntry;
|
|
119
|
+
try {
|
|
120
|
+
e = JSON.parse(line) as RawEntry;
|
|
121
|
+
} catch {
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (e.type === "session") {
|
|
125
|
+
header = e as unknown as SessionHeader;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (typeof e.id !== "string") continue;
|
|
129
|
+
entries.set(e.id, slimEntry(e));
|
|
130
|
+
lastId = e.id;
|
|
131
|
+
if (e.type === "session_info" && typeof e.name === "string") name = e.name;
|
|
132
|
+
}
|
|
133
|
+
if (!header) return undefined;
|
|
134
|
+
const branch: RawEntry[] = [];
|
|
135
|
+
const seen = new Set<string>();
|
|
136
|
+
let cur = lastId ? entries.get(lastId) : undefined;
|
|
137
|
+
while (cur && !seen.has(cur.id)) {
|
|
138
|
+
seen.add(cur.id);
|
|
139
|
+
branch.push(cur);
|
|
140
|
+
cur = cur.parentId ? entries.get(cur.parentId) : undefined;
|
|
141
|
+
}
|
|
142
|
+
branch.reverse();
|
|
143
|
+
return { file, header, branch, name, lastEntryId: lastId };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Plain text of a message content (string or blocks); images become `[image]`. */
|
|
147
|
+
export function textOf(content: unknown): string {
|
|
148
|
+
if (typeof content === "string") return content;
|
|
149
|
+
if (!Array.isArray(content)) return "";
|
|
150
|
+
const parts: string[] = [];
|
|
151
|
+
for (const b of content) {
|
|
152
|
+
if (!b || typeof b !== "object") continue;
|
|
153
|
+
const blk = b as { type?: string; text?: unknown };
|
|
154
|
+
if (blk.type === "text" && typeof blk.text === "string") parts.push(blk.text);
|
|
155
|
+
else if (blk.type === "image") parts.push("[image]");
|
|
156
|
+
}
|
|
157
|
+
return parts.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Small LRU of parsed sessions, for evidence lookups during one dream. */
|
|
161
|
+
export class SessionCache {
|
|
162
|
+
private map = new Map<string, Promise<ParsedSession | undefined>>();
|
|
163
|
+
constructor(private max = 8) {}
|
|
164
|
+
get(file: string): Promise<ParsedSession | undefined> {
|
|
165
|
+
let p = this.map.get(file);
|
|
166
|
+
if (p) {
|
|
167
|
+
this.map.delete(file);
|
|
168
|
+
this.map.set(file, p);
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
p = parseSession(file);
|
|
172
|
+
this.map.set(file, p);
|
|
173
|
+
while (this.map.size > this.max) this.map.delete(this.map.keys().next().value as string);
|
|
174
|
+
return p;
|
|
175
|
+
}
|
|
176
|
+
}
|
package/setup.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/dream init`: create the memory repository, its GitHub remote and the
|
|
3
|
+
* settings file. Idempotent — every step checks before it acts, so running it
|
|
4
|
+
* again only fills in what is missing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { configPath, type DreamConfig, saveConfig } from "./config.ts";
|
|
10
|
+
import { gh, ghOk, git, githubRepoOf, gitOk } from "./gitops.ts";
|
|
11
|
+
import { CHARTER_FILE, GLOBAL_SCOPE, INDEX_FILE, memoryPaths, scopeDir } from "./layout.ts";
|
|
12
|
+
import { buildIndex } from "./memindex.ts";
|
|
13
|
+
import { DEFAULT_GLOBAL_CHARTER, DEFAULT_PROJECT_CHARTER } from "./prompts.ts";
|
|
14
|
+
|
|
15
|
+
const GITIGNORE = ".local/\n.DS_Store\n";
|
|
16
|
+
|
|
17
|
+
const README = `# pi memory
|
|
18
|
+
|
|
19
|
+
Память агентов [pi](https://pi.dev) на этой машине. Её ведёт [@wierdbytes/pi-dream](https://github.com/wierdbytes/pi-wierd-stuff/tree/master/packages/dream): фоновый «сон» перечитывает новые сессии pi, предлагает изменения памяти и присылает их сюда pull request'ом. В сессии попадает только то, что смёрджено в \`main\`.
|
|
20
|
+
|
|
21
|
+
## Устройство
|
|
22
|
+
|
|
23
|
+
\`\`\`
|
|
24
|
+
scopes/global/ всё, что верно для тебя и машины во всех проектах
|
|
25
|
+
charter.md зачем эта память — направляет сон (в сессии не попадает)
|
|
26
|
+
MEMORY.md индекс, генерируется из notes/ — именно он подгружается в сессии
|
|
27
|
+
notes/<slug>.md одна заметка = один файл (front matter + текст)
|
|
28
|
+
skills/<name>/SKILL.md одобренные скиллы, pi подхватывает их автоматически
|
|
29
|
+
scopes/projects/<проект>/ то же для одного проекта (ключ — git remote или путь)
|
|
30
|
+
reports/<run>.md отчёт каждого сна
|
|
31
|
+
prompts/<stage>.md (необязательно) свои промпты сна: cards, curator, review, verify
|
|
32
|
+
.local/ не в git: inbox, дайджесты и карточки сессий, логи, состояние
|
|
33
|
+
\`\`\`
|
|
34
|
+
|
|
35
|
+
## Заметка
|
|
36
|
+
|
|
37
|
+
\`\`\`markdown
|
|
38
|
+
---
|
|
39
|
+
id: m-7f3a2c
|
|
40
|
+
title: "Утверждение одной строкой — это и есть строка в индексе"
|
|
41
|
+
type: procedure # preference | procedure | pitfall | fact | decision | reference
|
|
42
|
+
status: active # active | superseded | deprecated
|
|
43
|
+
index: always # always — строка в каждой сессии scope; pointer — «когда → id»; none — только поиск
|
|
44
|
+
when: "когда это нужно"
|
|
45
|
+
created: "2026-11-08"
|
|
46
|
+
updated: "2026-11-08"
|
|
47
|
+
evidence:
|
|
48
|
+
- {"s": "<session id>", "e": ["<entry id>"], "date": "2026-11-06", "at": "2026-11-06T14:05Z", "kind": "user"}
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
Подробности: точные команды, пути, условия, причины.
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
## Ревью
|
|
55
|
+
|
|
56
|
+
- **Merge** — изменения становятся памятью.
|
|
57
|
+
- **Правки прямо в PR** — сон продолжит с твоей версии; можно и руками добавлять заметки (id и даты сон допишет сам).
|
|
58
|
+
- **Комментарий в PR** — указание сну, выполнится при следующем \`/dream run\`.
|
|
59
|
+
- **Закрыть без merge** — отклонить предложенные заметки.
|
|
60
|
+
- \`pinned: true\` в заметке — сон никогда не закроет её сам.
|
|
61
|
+
|
|
62
|
+
Сессию-доказательство можно открыть: \`pi --session <id>\`.
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
const PROMPTS_README = `# Свои промпты сна
|
|
66
|
+
|
|
67
|
+
Положи сюда \`cards.md\`, \`curator.md\`, \`review.md\` или \`verify.md\`, чтобы заменить соответствующий системный промпт pi-dream.
|
|
68
|
+
Плейсхолдеры: \`{{language}}\`, \`{{charter}}\`, \`{{global_budget}}\`, \`{{project_budget}}\`.
|
|
69
|
+
Изменения промптов проходят ревью как всё остальное.
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
function writeIfMissing(path: string, content: string, created: string[]): void {
|
|
73
|
+
if (existsSync(path)) return;
|
|
74
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
75
|
+
writeFileSync(path, content, "utf-8");
|
|
76
|
+
created.push(path);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function initMemory(cfg: DreamConfig, log: (msg: string) => void): Promise<{ repo?: string }> {
|
|
80
|
+
const root = cfg.memoryDir;
|
|
81
|
+
const P = memoryPaths(root);
|
|
82
|
+
mkdirSync(P.local, { recursive: true });
|
|
83
|
+
|
|
84
|
+
if (!existsSync(join(root, ".git"))) {
|
|
85
|
+
await gitOk(root, ["init", "-b", cfg.github.base]);
|
|
86
|
+
log(`git init ${root}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const created: string[] = [];
|
|
90
|
+
writeIfMissing(join(root, ".gitignore"), GITIGNORE, created);
|
|
91
|
+
writeIfMissing(join(root, "README.md"), README, created);
|
|
92
|
+
writeIfMissing(join(scopeDir(root, GLOBAL_SCOPE), CHARTER_FILE), `${DEFAULT_GLOBAL_CHARTER}\n`, created);
|
|
93
|
+
writeIfMissing(join(scopeDir(root, GLOBAL_SCOPE), INDEX_FILE), buildIndex([], "global", cfg.budgets.globalTokens).text, created);
|
|
94
|
+
writeIfMissing(join(scopeDir(root, GLOBAL_SCOPE), "notes", ".gitkeep"), "", created);
|
|
95
|
+
writeIfMissing(join(P.scopes, "projects", "_charter.md"), `${DEFAULT_PROJECT_CHARTER}\n`, created);
|
|
96
|
+
writeIfMissing(join(P.reports, ".gitkeep"), "", created);
|
|
97
|
+
writeIfMissing(join(P.prompts, "README.md"), PROMPTS_README, created);
|
|
98
|
+
if (created.length) log(`created ${created.length} files`);
|
|
99
|
+
|
|
100
|
+
const status = await git(root, ["status", "--porcelain"]);
|
|
101
|
+
if (status.stdout.trim()) {
|
|
102
|
+
await gitOk(root, ["add", "-A"]);
|
|
103
|
+
await gitOk(root, ["commit", "-m", "pi-dream: initialize memory repository"]);
|
|
104
|
+
log("committed the skeleton");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let remote = await git(root, ["remote", "get-url", "origin"]);
|
|
108
|
+
if (remote.code !== 0) {
|
|
109
|
+
const owner = await ghOk(["api", "user", "--jq", ".login"]);
|
|
110
|
+
const full = `${owner}/${cfg.github.repo}`;
|
|
111
|
+
const exists = await gh(["repo", "view", full, "--json", "name"]);
|
|
112
|
+
if (exists.code === 0) {
|
|
113
|
+
const ssh = `git@github.com:${full}.git`;
|
|
114
|
+
await gitOk(root, ["remote", "add", "origin", ssh]);
|
|
115
|
+
log(`origin → existing ${full}`);
|
|
116
|
+
} else {
|
|
117
|
+
await ghOk(
|
|
118
|
+
["repo", "create", full, "--private", "--description", "pi agent memory, maintained by pi-dream", "--source", root, "--remote", "origin"],
|
|
119
|
+
{ timeoutMs: 120_000 },
|
|
120
|
+
);
|
|
121
|
+
log(`created private GitHub repo ${full}`);
|
|
122
|
+
}
|
|
123
|
+
remote = await git(root, ["remote", "get-url", "origin"]);
|
|
124
|
+
}
|
|
125
|
+
const repo = githubRepoOf(remote.stdout.trim());
|
|
126
|
+
|
|
127
|
+
const push = await git(root, ["push", "-u", "origin", cfg.github.base], 120_000);
|
|
128
|
+
if (push.code !== 0) log(`push failed: ${push.stderr.trim()}`);
|
|
129
|
+
else log(`pushed ${cfg.github.base}`);
|
|
130
|
+
|
|
131
|
+
if (repo) {
|
|
132
|
+
const label = await gh(["label", "create", cfg.github.label, "--repo", repo, "--color", "7AA2F7", "--description", "pi-dream memory review", "--force"]);
|
|
133
|
+
if (label.code === 0) log(`label "${cfg.github.label}" ready`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!existsSync(configPath())) {
|
|
137
|
+
saveConfig(cfg);
|
|
138
|
+
log(`settings → ${configPath()}`);
|
|
139
|
+
}
|
|
140
|
+
return { repo };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Whether the memory repo exists and has a remote. */
|
|
144
|
+
export async function memoryReady(root: string): Promise<{ git: boolean; remote: boolean }> {
|
|
145
|
+
if (!existsSync(join(root, ".git"))) return { git: false, remote: false };
|
|
146
|
+
const r = await git(root, ["remote", "get-url", "origin"]);
|
|
147
|
+
return { git: true, remote: r.code === 0 };
|
|
148
|
+
}
|
package/state.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local dream state (`.local/state.json`, never committed): which sessions and
|
|
3
|
+
* inbox items were processed, the open review pull request, what was rejected.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface SessionMark {
|
|
10
|
+
size: number;
|
|
11
|
+
mtimeMs: number;
|
|
12
|
+
at: string;
|
|
13
|
+
/** The session (at this size) was summarized, or had nothing to summarize. */
|
|
14
|
+
ok: boolean;
|
|
15
|
+
/** Failed card attempts at this size; the session is skipped after 3. */
|
|
16
|
+
failures: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunRecord {
|
|
20
|
+
id: string;
|
|
21
|
+
pr?: number;
|
|
22
|
+
/** Notes added by this run (id + title), to learn from rejections. */
|
|
23
|
+
added: Array<{ id: string; title: string }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DreamState {
|
|
27
|
+
version: 1;
|
|
28
|
+
createdAt: string;
|
|
29
|
+
/** Sessions last written before this and never processed are ignored. */
|
|
30
|
+
cutoffMs: number;
|
|
31
|
+
sessions: Record<string, SessionMark>;
|
|
32
|
+
inboxDone: string[];
|
|
33
|
+
lastRun?: {
|
|
34
|
+
id: string;
|
|
35
|
+
startedAt: string;
|
|
36
|
+
finishedAt?: string;
|
|
37
|
+
status: "running" | "ok" | "noop" | "error";
|
|
38
|
+
error?: string;
|
|
39
|
+
pr?: string;
|
|
40
|
+
sessions?: number;
|
|
41
|
+
cost?: number;
|
|
42
|
+
};
|
|
43
|
+
openPr?: { number: number; url: string; branch: string };
|
|
44
|
+
/** PR number → ISO time of the newest review comment already applied. */
|
|
45
|
+
reviewSeen: Record<string, string>;
|
|
46
|
+
rejected: Array<{ title: string; at: string; pr?: number }>;
|
|
47
|
+
runs: RunRecord[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function freshState(lookbackDays: number, now = Date.now()): DreamState {
|
|
51
|
+
return {
|
|
52
|
+
version: 1,
|
|
53
|
+
createdAt: new Date(now).toISOString(),
|
|
54
|
+
cutoffMs: now - lookbackDays * 86_400_000,
|
|
55
|
+
sessions: {},
|
|
56
|
+
inboxDone: [],
|
|
57
|
+
reviewSeen: {},
|
|
58
|
+
rejected: [],
|
|
59
|
+
runs: [],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadState(path: string): DreamState | undefined {
|
|
64
|
+
if (!existsSync(path)) return undefined;
|
|
65
|
+
try {
|
|
66
|
+
const s = JSON.parse(readFileSync(path, "utf-8")) as DreamState;
|
|
67
|
+
if (s?.version !== 1) return undefined;
|
|
68
|
+
s.sessions ??= {};
|
|
69
|
+
s.inboxDone ??= [];
|
|
70
|
+
s.reviewSeen ??= {};
|
|
71
|
+
s.rejected ??= [];
|
|
72
|
+
s.runs ??= [];
|
|
73
|
+
return s;
|
|
74
|
+
} catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveState(path: string, state: DreamState): void {
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
81
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
82
|
+
writeFileSync(tmp, `${JSON.stringify(state, null, 1)}\n`, "utf-8");
|
|
83
|
+
renameSync(tmp, path);
|
|
84
|
+
}
|