@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/CHANGELOG.md +12 -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 +323 -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 +69 -0
- package/notes.ts +235 -0
- package/package.json +54 -4
- package/pipeline.ts +1149 -0
- package/project.ts +115 -0
- package/projects.test.ts +79 -0
- package/projects.ts +74 -0
- package/prompts.ts +159 -0
- package/report.ts +287 -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/curate.ts
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The curator's hands: operations proposed by the model are validated here
|
|
3
|
+
* and applied by plain code — notes are edited item by item, never rewritten
|
|
4
|
+
* wholesale, so memory cannot "collapse" into a short generic summary.
|
|
5
|
+
*
|
|
6
|
+
* Invariants: every note keeps its evidence; superseded and deprecated notes
|
|
7
|
+
* stay on disk with `valid_until`; near-duplicates are merged into the
|
|
8
|
+
* existing note; text with credentials or prompt-injection patterns is
|
|
9
|
+
* refused; indexes are regenerated from front matter within budget.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { buildIndex, type IndexResult } from "./memindex.ts";
|
|
15
|
+
import {
|
|
16
|
+
type Evidence,
|
|
17
|
+
INDEX_MODES,
|
|
18
|
+
type IndexMode,
|
|
19
|
+
isNoteId,
|
|
20
|
+
listScopes,
|
|
21
|
+
loadNotes,
|
|
22
|
+
type Note,
|
|
23
|
+
NOTE_TYPES,
|
|
24
|
+
type NoteType,
|
|
25
|
+
newNoteId,
|
|
26
|
+
serializeNote,
|
|
27
|
+
slugify,
|
|
28
|
+
} from "./notes.ts";
|
|
29
|
+
import { CHARTER_FILE, GLOBAL_SCOPE, INDEX_FILE, projectScope, SCOPE_META_FILE, scopeDir } from "./layout.ts";
|
|
30
|
+
import { findInjection, findRisky, findSecrets } from "./secrets.ts";
|
|
31
|
+
import { tokenize } from "./search.ts";
|
|
32
|
+
|
|
33
|
+
export type OpKind = "add" | "update" | "supersede" | "deprecate" | "confirm" | "agents_md" | "skill";
|
|
34
|
+
|
|
35
|
+
export interface EvidenceRef {
|
|
36
|
+
s: string;
|
|
37
|
+
e: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface NoteFields {
|
|
41
|
+
type?: NoteType;
|
|
42
|
+
title?: string;
|
|
43
|
+
body?: string;
|
|
44
|
+
when?: string;
|
|
45
|
+
index?: IndexMode;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface BaseOp {
|
|
49
|
+
ref: string;
|
|
50
|
+
rationale: string;
|
|
51
|
+
evidence: EvidenceRef[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AddOp extends BaseOp, NoteFields {
|
|
55
|
+
op: "add";
|
|
56
|
+
/** `global`, `project` or an existing scope id. */
|
|
57
|
+
scope: string;
|
|
58
|
+
title: string;
|
|
59
|
+
}
|
|
60
|
+
export interface UpdateOp extends BaseOp, NoteFields {
|
|
61
|
+
op: "update";
|
|
62
|
+
id: string;
|
|
63
|
+
/** Move the note: `global`, `project`, `project:<key>` or a scope id. */
|
|
64
|
+
scope?: string;
|
|
65
|
+
}
|
|
66
|
+
export interface SupersedeOp extends BaseOp {
|
|
67
|
+
op: "supersede";
|
|
68
|
+
id: string;
|
|
69
|
+
replacement: NoteFields & { title: string };
|
|
70
|
+
}
|
|
71
|
+
export interface DeprecateOp extends BaseOp {
|
|
72
|
+
op: "deprecate";
|
|
73
|
+
id: string;
|
|
74
|
+
}
|
|
75
|
+
export interface ConfirmOp extends BaseOp {
|
|
76
|
+
op: "confirm";
|
|
77
|
+
id: string;
|
|
78
|
+
}
|
|
79
|
+
export interface AgentsMdOp extends BaseOp {
|
|
80
|
+
op: "agents_md";
|
|
81
|
+
/** Pull request title. */
|
|
82
|
+
summary?: string;
|
|
83
|
+
edits: Array<{ old: string; new: string }>;
|
|
84
|
+
}
|
|
85
|
+
export interface SkillOp extends BaseOp {
|
|
86
|
+
op: "skill";
|
|
87
|
+
scope: string;
|
|
88
|
+
name: string;
|
|
89
|
+
description: string;
|
|
90
|
+
body: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type Op = AddOp | UpdateOp | SupersedeOp | DeprecateOp | ConfirmOp | AgentsMdOp | SkillOp;
|
|
94
|
+
|
|
95
|
+
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v);
|
|
96
|
+
const oneOf = <T extends string>(list: readonly T[], v: unknown): v is T =>
|
|
97
|
+
typeof v === "string" && (list as readonly string[]).includes(v);
|
|
98
|
+
const optStr = (v: unknown): string | undefined => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
99
|
+
|
|
100
|
+
function parseEvidenceRefs(v: unknown): EvidenceRef[] {
|
|
101
|
+
if (!Array.isArray(v)) return [];
|
|
102
|
+
const out: EvidenceRef[] = [];
|
|
103
|
+
for (const item of v) {
|
|
104
|
+
if (!isObj(item) || typeof item.s !== "string" || !item.s.trim()) continue;
|
|
105
|
+
const e = Array.isArray(item.e) ? item.e : typeof item.e === "string" ? [item.e] : [];
|
|
106
|
+
out.push({
|
|
107
|
+
s: item.s.trim(),
|
|
108
|
+
e: e.filter((x): x is string => typeof x === "string" && !!x.trim()).map((x) => x.trim().replace(/^\[|\]$/g, "")),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function noteFields(o: Record<string, unknown>): NoteFields {
|
|
115
|
+
return {
|
|
116
|
+
type: oneOf(NOTE_TYPES, o.type) ? o.type : undefined,
|
|
117
|
+
title: optStr(o.title),
|
|
118
|
+
body: typeof o.body === "string" ? o.body.trim() : undefined,
|
|
119
|
+
when: optStr(o.when),
|
|
120
|
+
index: oneOf(INDEX_MODES, o.index) ? o.index : undefined,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface ParsedOps {
|
|
125
|
+
ops: Op[];
|
|
126
|
+
problems: string[];
|
|
127
|
+
unhandled: Array<{ comment: string; reason: string }>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Validate a curator reply. Throws only when the shape is unusable (so the model is asked again). */
|
|
131
|
+
export function parseOps(raw: unknown): ParsedOps {
|
|
132
|
+
if (!isObj(raw) || !Array.isArray(raw.ops)) throw new Error('expected an object {"ops": [...]}');
|
|
133
|
+
const ops: Op[] = [];
|
|
134
|
+
const problems: string[] = [];
|
|
135
|
+
const refs = new Set<string>();
|
|
136
|
+
raw.ops.forEach((item, i) => {
|
|
137
|
+
if (!isObj(item)) return problems.push(`op ${i + 1}: not an object`);
|
|
138
|
+
let ref = optStr(item.ref) ?? `op${i + 1}`;
|
|
139
|
+
if (refs.has(ref)) ref = `${ref}-${i + 1}`;
|
|
140
|
+
refs.add(ref);
|
|
141
|
+
const base: BaseOp = { ref, rationale: optStr(item.rationale) ?? "", evidence: parseEvidenceRefs(item.evidence) };
|
|
142
|
+
const id = optStr(item.id);
|
|
143
|
+
const bad = (why: string) => problems.push(`${ref} (${String(item.op)}): ${why}`);
|
|
144
|
+
switch (item.op) {
|
|
145
|
+
case "add": {
|
|
146
|
+
const f = noteFields(item);
|
|
147
|
+
const scope = optStr(item.scope);
|
|
148
|
+
if (!f.title) return bad("missing title");
|
|
149
|
+
if (!scope) return bad("missing scope");
|
|
150
|
+
ops.push({ ...base, op: "add", ...f, title: f.title, scope });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
case "update":
|
|
154
|
+
if (!isNoteId(id)) return bad("invalid id");
|
|
155
|
+
ops.push({ ...base, op: "update", id, ...noteFields(item), scope: optStr(item.scope) });
|
|
156
|
+
return;
|
|
157
|
+
case "supersede": {
|
|
158
|
+
if (!isNoteId(id)) return bad("invalid id");
|
|
159
|
+
const r = isObj(item.replacement) ? noteFields(item.replacement) : {};
|
|
160
|
+
if (!r.title) return bad("replacement needs a title");
|
|
161
|
+
ops.push({ ...base, op: "supersede", id, replacement: { ...r, title: r.title } });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
case "deprecate":
|
|
165
|
+
case "confirm":
|
|
166
|
+
if (!isNoteId(id)) return bad("invalid id");
|
|
167
|
+
ops.push({ ...base, op: item.op, id });
|
|
168
|
+
return;
|
|
169
|
+
case "agents_md": {
|
|
170
|
+
const edits = (Array.isArray(item.edits) ? item.edits : [])
|
|
171
|
+
.filter(isObj)
|
|
172
|
+
.filter((e) => typeof e.old === "string" && typeof e.new === "string")
|
|
173
|
+
.map((e) => ({ old: e.old as string, new: e.new as string }));
|
|
174
|
+
if (!edits.length) return bad("no edits");
|
|
175
|
+
ops.push({ ...base, op: "agents_md", summary: optStr(item.summary)?.slice(0, 90), edits });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
case "skill": {
|
|
179
|
+
const name = optStr(item.name);
|
|
180
|
+
const description = optStr(item.description);
|
|
181
|
+
const body = optStr(item.body);
|
|
182
|
+
const scope = optStr(item.scope) ?? "project";
|
|
183
|
+
if (!name || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name) || name.length > 64) return bad("invalid skill name");
|
|
184
|
+
if (!description || !body) return bad("skill needs description and body");
|
|
185
|
+
ops.push({ ...base, op: "skill", scope, name, description: description.slice(0, 1024), body });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
default:
|
|
189
|
+
bad("unknown op");
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
const unhandled = (Array.isArray(raw.unhandled) ? raw.unhandled : [])
|
|
193
|
+
.filter(isObj)
|
|
194
|
+
.map((u) => ({ comment: String(u.comment ?? ""), reason: String(u.reason ?? "") }));
|
|
195
|
+
return { ops, problems, unhandled };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Token-set Jaccard similarity of two titles. */
|
|
199
|
+
export function similarity(a: string, b: string): number {
|
|
200
|
+
const A = new Set(tokenize(a));
|
|
201
|
+
const B = new Set(tokenize(b));
|
|
202
|
+
if (!A.size || !B.size) return 0;
|
|
203
|
+
let inter = 0;
|
|
204
|
+
for (const t of A) if (B.has(t)) inter++;
|
|
205
|
+
return inter / (A.size + B.size - inter);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const DUPLICATE_THRESHOLD = 0.72;
|
|
209
|
+
export const MAX_TITLE = 240;
|
|
210
|
+
export const MAX_BODY = 4000;
|
|
211
|
+
|
|
212
|
+
/** Problems that make text unacceptable as memory. */
|
|
213
|
+
export function lintText(text: string): string[] {
|
|
214
|
+
const out: string[] = [];
|
|
215
|
+
const secrets = findSecrets(text);
|
|
216
|
+
if (secrets.length) out.push(`contains credentials (${secrets.join(", ")})`);
|
|
217
|
+
const inj = findInjection(text);
|
|
218
|
+
if (inj.length) out.push(`contains a prompt-injection phrase (${inj.join(", ")})`);
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Things the reviewer should look at before merging. */
|
|
223
|
+
export function warnText(text: string): string | undefined {
|
|
224
|
+
const risky = findRisky(text);
|
|
225
|
+
return risky.length ? `⚠️ ${risky.join(", ")}: check the source before merging` : undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function evKey(e: Evidence): string {
|
|
229
|
+
return `${e.s}|${[...e.e].sort().join(",")}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function mergeEvidence(into: Evidence[], add: Evidence[]): number {
|
|
233
|
+
const seen = new Set(into.map(evKey));
|
|
234
|
+
let n = 0;
|
|
235
|
+
for (const e of add) {
|
|
236
|
+
const k = evKey(e);
|
|
237
|
+
if (seen.has(k)) continue;
|
|
238
|
+
seen.add(k);
|
|
239
|
+
into.push(e);
|
|
240
|
+
n++;
|
|
241
|
+
}
|
|
242
|
+
return n;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export type ChangeKind = "added" | "merged" | "updated" | "moved" | "superseded" | "deprecated" | "removed" | "confirmed" | "skill";
|
|
246
|
+
|
|
247
|
+
export interface Change {
|
|
248
|
+
kind: ChangeKind;
|
|
249
|
+
ref: string;
|
|
250
|
+
scope: string;
|
|
251
|
+
noteId?: string;
|
|
252
|
+
title: string;
|
|
253
|
+
/** For superseded: the replacement's id. */
|
|
254
|
+
newId?: string;
|
|
255
|
+
file?: string;
|
|
256
|
+
note?: string;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export class Workspace {
|
|
260
|
+
readonly notes = new Map<string, Note[]>();
|
|
261
|
+
readonly ids = new Set<string>();
|
|
262
|
+
private dirty = new Set<Note>();
|
|
263
|
+
private files = new Map<string, string>();
|
|
264
|
+
private metas = new Map<string, Record<string, unknown>>();
|
|
265
|
+
private removed = new Set<string>();
|
|
266
|
+
/** Notes that exist only in the open pull request (not yet approved). */
|
|
267
|
+
pending = new Set<string>();
|
|
268
|
+
|
|
269
|
+
constructor(
|
|
270
|
+
readonly root: string,
|
|
271
|
+
readonly date: string,
|
|
272
|
+
) {
|
|
273
|
+
for (const s of listScopes(root)) this.scope(s);
|
|
274
|
+
this.scope(GLOBAL_SCOPE);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
scope(scope: string): Note[] {
|
|
278
|
+
let list = this.notes.get(scope);
|
|
279
|
+
if (!list) {
|
|
280
|
+
list = loadNotes(this.root, scope);
|
|
281
|
+
this.notes.set(scope, list);
|
|
282
|
+
for (const n of list) if (n.id) this.ids.add(n.id);
|
|
283
|
+
}
|
|
284
|
+
return list;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Register a project scope; its `scope.json` is written once it holds a note. */
|
|
288
|
+
ensureScope(scope: string, meta?: Record<string, unknown>): void {
|
|
289
|
+
this.scope(scope);
|
|
290
|
+
if (meta) this.metas.set(scope, meta);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
all(): Note[] {
|
|
294
|
+
return [...this.notes.values()].flat();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
find(id: string): Note | undefined {
|
|
298
|
+
return this.all().find((n) => n.id === id);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Fill ids and dates of hand-written notes. Returns the notes it touched. */
|
|
302
|
+
normalize(): Note[] {
|
|
303
|
+
const touched: Note[] = [];
|
|
304
|
+
for (const n of this.all()) {
|
|
305
|
+
let changed = false;
|
|
306
|
+
if (!n.id) {
|
|
307
|
+
n.id = newNoteId(this.ids);
|
|
308
|
+
changed = true;
|
|
309
|
+
}
|
|
310
|
+
if (!n.created) {
|
|
311
|
+
n.created = this.date;
|
|
312
|
+
changed = true;
|
|
313
|
+
}
|
|
314
|
+
if (!n.updated) {
|
|
315
|
+
n.updated = n.created;
|
|
316
|
+
changed = true;
|
|
317
|
+
}
|
|
318
|
+
if (changed) {
|
|
319
|
+
this.dirty.add(n);
|
|
320
|
+
touched.push(n);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return touched;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private uniqueFile(scope: string, title: string): string {
|
|
327
|
+
const base = slugify(title);
|
|
328
|
+
const dir = join(scopeDir(this.root, scope), "notes");
|
|
329
|
+
const taken = new Set(this.scope(scope).map((n) => n.file.split("/").pop()));
|
|
330
|
+
let name = `${base}.md`;
|
|
331
|
+
for (let i = 2; taken.has(name) || existsSync(join(dir, name)); i++) name = `${base}-${i}.md`;
|
|
332
|
+
return join("scopes", scope, "notes", name);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
createNote(scope: string, f: NoteFields & { title: string }, evidence: Evidence[]): Note {
|
|
336
|
+
const note: Note = {
|
|
337
|
+
id: newNoteId(this.ids),
|
|
338
|
+
title: f.title,
|
|
339
|
+
type: f.type ?? "fact",
|
|
340
|
+
status: "active",
|
|
341
|
+
index: f.index ?? "none",
|
|
342
|
+
when: f.when,
|
|
343
|
+
created: this.date,
|
|
344
|
+
updated: this.date,
|
|
345
|
+
stats: { helpful: 0, harmful: 0 },
|
|
346
|
+
evidence: [...evidence],
|
|
347
|
+
body: f.body ?? "",
|
|
348
|
+
file: this.uniqueFile(scope, f.title),
|
|
349
|
+
scope,
|
|
350
|
+
extra: {},
|
|
351
|
+
problems: [],
|
|
352
|
+
};
|
|
353
|
+
this.scope(scope).push(note);
|
|
354
|
+
this.dirty.add(note);
|
|
355
|
+
return note;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
touch(n: Note): void {
|
|
359
|
+
this.dirty.add(n);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Move a note to another scope; id, evidence and history stay. */
|
|
363
|
+
moveNote(n: Note, scope: string): void {
|
|
364
|
+
const from = this.scope(n.scope);
|
|
365
|
+
const i = from.indexOf(n);
|
|
366
|
+
if (i >= 0) from.splice(i, 1);
|
|
367
|
+
this.removed.add(n.file);
|
|
368
|
+
n.scope = scope;
|
|
369
|
+
n.file = this.uniqueFile(scope, n.title);
|
|
370
|
+
this.removed.delete(n.file);
|
|
371
|
+
this.scope(scope).push(n);
|
|
372
|
+
this.dirty.add(n);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Drop a note that was never approved (only exists in the open pull request). */
|
|
376
|
+
removeNote(n: Note): void {
|
|
377
|
+
const list = this.scope(n.scope);
|
|
378
|
+
const i = list.indexOf(n);
|
|
379
|
+
if (i >= 0) list.splice(i, 1);
|
|
380
|
+
this.dirty.delete(n);
|
|
381
|
+
this.removed.add(n.file);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
writeFile(absPath: string, content: string): void {
|
|
385
|
+
this.files.set(absPath, content);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
removeFile(absPath: string): void {
|
|
389
|
+
this.files.delete(absPath);
|
|
390
|
+
this.removed.add(absPath.slice(this.root.length + 1));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Write changed notes and files; returns repo-relative paths written. */
|
|
394
|
+
flush(): string[] {
|
|
395
|
+
const out: string[] = [];
|
|
396
|
+
for (const rel of this.removed) {
|
|
397
|
+
const abs = join(this.root, rel);
|
|
398
|
+
if (existsSync(abs)) {
|
|
399
|
+
rmSync(abs);
|
|
400
|
+
out.push(rel);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
this.removed.clear();
|
|
404
|
+
for (const [scope, meta] of this.metas) {
|
|
405
|
+
const metaPath = join(scopeDir(this.root, scope), SCOPE_META_FILE);
|
|
406
|
+
if (this.scope(scope).length && !existsSync(metaPath)) this.files.set(metaPath, `${JSON.stringify(meta, null, 2)}\n`);
|
|
407
|
+
}
|
|
408
|
+
for (const n of this.dirty) {
|
|
409
|
+
const abs = join(this.root, n.file);
|
|
410
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
411
|
+
const text = serializeNote(n);
|
|
412
|
+
if (!existsSync(abs) || readFileSync(abs, "utf-8") !== text) {
|
|
413
|
+
writeFileSync(abs, text, "utf-8");
|
|
414
|
+
out.push(n.file);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
this.dirty.clear();
|
|
418
|
+
for (const [abs, content] of this.files) {
|
|
419
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
420
|
+
if (!existsSync(abs) || readFileSync(abs, "utf-8") !== content) {
|
|
421
|
+
writeFileSync(abs, content, "utf-8");
|
|
422
|
+
out.push(abs.slice(this.root.length + 1));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
this.files.clear();
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export interface ApplyContext {
|
|
431
|
+
/** Scope id that "project" refers to in this pass. */
|
|
432
|
+
projectScope?: string;
|
|
433
|
+
evidence: Evidence[];
|
|
434
|
+
/** Corrections from the fact checker. */
|
|
435
|
+
fix?: { title?: string; body?: string; scope?: string };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export type ApplyResult = { ok: true; change: Change } | { ok: false; reason: string };
|
|
439
|
+
|
|
440
|
+
function cleanFields<T extends NoteFields>(f: T): T {
|
|
441
|
+
const out = { ...f };
|
|
442
|
+
if (out.title && out.title.length > MAX_TITLE) out.title = `${out.title.slice(0, MAX_TITLE - 1)}…`;
|
|
443
|
+
if (out.body && out.body.length > MAX_BODY) out.body = `${out.body.slice(0, MAX_BODY - 1)}…`;
|
|
444
|
+
return out;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** `global` | `project` (this pass) | `project:<key>` | an existing scope id → scope id. */
|
|
448
|
+
export function resolveScope(ws: Workspace, scope: string, thisProject?: string): string | undefined {
|
|
449
|
+
if (scope === "global") return GLOBAL_SCOPE;
|
|
450
|
+
if (scope === "project") return thisProject;
|
|
451
|
+
if (scope.startsWith("project:") && scope.length > 8) {
|
|
452
|
+
const id = projectScope(scope.slice(8));
|
|
453
|
+
ws.scope(id);
|
|
454
|
+
return id;
|
|
455
|
+
}
|
|
456
|
+
if (ws.notes.has(scope)) return scope;
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export function applyOp(ws: Workspace, op: Op, actx: ApplyContext): ApplyResult {
|
|
461
|
+
const fix = actx.fix ?? {};
|
|
462
|
+
const ev = actx.evidence;
|
|
463
|
+
switch (op.op) {
|
|
464
|
+
case "add": {
|
|
465
|
+
const scope = resolveScope(ws, fix.scope ?? op.scope, actx.projectScope);
|
|
466
|
+
if (!scope) return { ok: false, reason: `unknown scope ${fix.scope ?? op.scope}` };
|
|
467
|
+
const f = cleanFields({ ...op, title: fix.title ?? op.title, body: fix.body ?? op.body });
|
|
468
|
+
const problems = lintText(`${f.title}\n${f.body ?? ""}\n${f.when ?? ""}`);
|
|
469
|
+
if (problems.length) return { ok: false, reason: problems.join("; ") };
|
|
470
|
+
const dup = ws
|
|
471
|
+
.scope(scope)
|
|
472
|
+
.filter((n) => n.status === "active")
|
|
473
|
+
.map((n) => ({ n, s: similarity(n.title, f.title) }))
|
|
474
|
+
.sort((a, b) => b.s - a.s)[0];
|
|
475
|
+
if (dup && dup.s >= DUPLICATE_THRESHOLD) {
|
|
476
|
+
mergeEvidence(dup.n.evidence, ev);
|
|
477
|
+
dup.n.confirmed = ws.date;
|
|
478
|
+
ws.touch(dup.n);
|
|
479
|
+
return { ok: true, change: { kind: "merged", ref: op.ref, scope, noteId: dup.n.id, title: dup.n.title, note: `duplicate of ${dup.n.id}` } };
|
|
480
|
+
}
|
|
481
|
+
const note = ws.createNote(scope, f, ev);
|
|
482
|
+
const warning = warnText(`${f.title}\n${f.body ?? ""}`);
|
|
483
|
+
return { ok: true, change: { kind: "added", ref: op.ref, scope, noteId: note.id, title: note.title, file: note.file, note: warning } };
|
|
484
|
+
}
|
|
485
|
+
case "update": {
|
|
486
|
+
const n = ws.find(op.id);
|
|
487
|
+
if (!n) return { ok: false, reason: `no note ${op.id}` };
|
|
488
|
+
const f = cleanFields({ ...op, title: fix.title ?? op.title, body: fix.body ?? op.body });
|
|
489
|
+
const problems = lintText(`${f.title ?? ""}\n${f.body ?? ""}\n${f.when ?? ""}`);
|
|
490
|
+
if (problems.length) return { ok: false, reason: problems.join("; ") };
|
|
491
|
+
const target = fix.scope ?? op.scope;
|
|
492
|
+
const to = target ? resolveScope(ws, target, actx.projectScope) : undefined;
|
|
493
|
+
if (target && !to) return { ok: false, reason: `unknown scope ${target}` };
|
|
494
|
+
if (f.title) n.title = f.title;
|
|
495
|
+
if (f.body !== undefined) n.body = f.body;
|
|
496
|
+
if (f.when) n.when = f.when;
|
|
497
|
+
if (f.index) n.index = f.index;
|
|
498
|
+
if (f.type) n.type = f.type;
|
|
499
|
+
mergeEvidence(n.evidence, ev);
|
|
500
|
+
n.updated = ws.date;
|
|
501
|
+
ws.touch(n);
|
|
502
|
+
const from = n.scope;
|
|
503
|
+
if (to && to !== n.scope) ws.moveNote(n, to);
|
|
504
|
+
const moved = n.scope !== from;
|
|
505
|
+
const warning = warnText(`${n.title}\n${n.body}`);
|
|
506
|
+
const note = [moved ? `${from} → ${n.scope}` : "", warning ?? ""].filter(Boolean).join(" · ") || undefined;
|
|
507
|
+
return { ok: true, change: { kind: moved ? "moved" : "updated", ref: op.ref, scope: n.scope, noteId: n.id, title: n.title, file: n.file, note } };
|
|
508
|
+
}
|
|
509
|
+
case "supersede": {
|
|
510
|
+
const old = ws.find(op.id);
|
|
511
|
+
if (!old) return { ok: false, reason: `no note ${op.id}` };
|
|
512
|
+
if (old.status !== "active") return { ok: false, reason: `${op.id} is already ${old.status}` };
|
|
513
|
+
const f = cleanFields({ ...op.replacement, title: fix.title ?? op.replacement.title, body: fix.body ?? op.replacement.body });
|
|
514
|
+
const problems = lintText(`${f.title}\n${f.body ?? ""}`);
|
|
515
|
+
if (problems.length) return { ok: false, reason: problems.join("; ") };
|
|
516
|
+
const repl = ws.createNote(old.scope, { type: old.type, index: old.index, when: old.when, ...f }, ev);
|
|
517
|
+
repl.supersedes = [old.id];
|
|
518
|
+
old.status = "superseded";
|
|
519
|
+
old.validUntil = ws.date;
|
|
520
|
+
old.supersededBy = repl.id;
|
|
521
|
+
old.updated = ws.date;
|
|
522
|
+
ws.touch(old);
|
|
523
|
+
return {
|
|
524
|
+
ok: true,
|
|
525
|
+
change: { kind: "superseded", ref: op.ref, scope: old.scope, noteId: old.id, newId: repl.id, title: repl.title, file: repl.file },
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
case "deprecate": {
|
|
529
|
+
const n = ws.find(op.id);
|
|
530
|
+
if (!n) return { ok: false, reason: `no note ${op.id}` };
|
|
531
|
+
if (ws.pending.has(n.id)) {
|
|
532
|
+
ws.removeNote(n);
|
|
533
|
+
return { ok: true, change: { kind: "removed", ref: op.ref, scope: n.scope, noteId: n.id, title: n.title, file: n.file } };
|
|
534
|
+
}
|
|
535
|
+
if (n.pinned) return { ok: false, reason: `${op.id} is pinned` };
|
|
536
|
+
if (n.status !== "active") return { ok: false, reason: `${op.id} is already ${n.status}` };
|
|
537
|
+
n.status = "deprecated";
|
|
538
|
+
n.validUntil = ws.date;
|
|
539
|
+
n.updated = ws.date;
|
|
540
|
+
mergeEvidence(n.evidence, ev);
|
|
541
|
+
ws.touch(n);
|
|
542
|
+
return { ok: true, change: { kind: "deprecated", ref: op.ref, scope: n.scope, noteId: n.id, title: n.title, file: n.file } };
|
|
543
|
+
}
|
|
544
|
+
case "confirm": {
|
|
545
|
+
const n = ws.find(op.id);
|
|
546
|
+
if (!n) return { ok: false, reason: `no note ${op.id}` };
|
|
547
|
+
const added = mergeEvidence(n.evidence, ev);
|
|
548
|
+
if (!added) return { ok: false, reason: "no new evidence" };
|
|
549
|
+
n.confirmed = ws.date;
|
|
550
|
+
ws.touch(n);
|
|
551
|
+
return { ok: true, change: { kind: "confirmed", ref: op.ref, scope: n.scope, noteId: n.id, title: n.title, file: n.file } };
|
|
552
|
+
}
|
|
553
|
+
case "skill": {
|
|
554
|
+
const scope = resolveScope(ws, op.scope, actx.projectScope);
|
|
555
|
+
if (!scope) return { ok: false, reason: `unknown scope ${op.scope}` };
|
|
556
|
+
const problems = lintText(`${op.description}\n${op.body}`);
|
|
557
|
+
if (problems.length) return { ok: false, reason: problems.join("; ") };
|
|
558
|
+
const rel = join("scopes", scope, "skills", op.name, "SKILL.md");
|
|
559
|
+
const content = `---\nname: ${op.name}\ndescription: ${JSON.stringify(op.description)}\n---\n\n${op.body.trim()}\n`;
|
|
560
|
+
ws.writeFile(join(ws.root, rel), content);
|
|
561
|
+
return { ok: true, change: { kind: "skill", ref: op.ref, scope, title: `${op.name}: ${op.description}`, file: rel } };
|
|
562
|
+
}
|
|
563
|
+
case "agents_md":
|
|
564
|
+
return { ok: false, reason: "agents_md is published separately" };
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Record helpful / harmful votes from session cards on existing notes. */
|
|
569
|
+
export function applyVotes(ws: Workspace, votes: Array<{ id: string; effect: string }>): number {
|
|
570
|
+
let n = 0;
|
|
571
|
+
for (const v of votes) {
|
|
572
|
+
const note = ws.find(v.id);
|
|
573
|
+
if (!note) continue;
|
|
574
|
+
if (v.effect === "helpful") note.stats.helpful++;
|
|
575
|
+
else if (v.effect === "harmful" || v.effect === "stale") note.stats.harmful++;
|
|
576
|
+
else continue;
|
|
577
|
+
ws.touch(note);
|
|
578
|
+
n++;
|
|
579
|
+
}
|
|
580
|
+
return n;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export interface IndexStat extends IndexResult {
|
|
584
|
+
scope: string;
|
|
585
|
+
budget: number;
|
|
586
|
+
changed: boolean;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Regenerate `MEMORY.md` of every scope; returns per-scope stats. */
|
|
590
|
+
export function regenerateIndexes(ws: Workspace, budgets: { globalTokens: number; projectTokens: number }, titleOf: (scope: string) => string): IndexStat[] {
|
|
591
|
+
const out: IndexStat[] = [];
|
|
592
|
+
for (const [scope, notes] of ws.notes) {
|
|
593
|
+
const dir = scopeDir(ws.root, scope);
|
|
594
|
+
if (scope !== GLOBAL_SCOPE && !notes.length) {
|
|
595
|
+
// A project scope left without notes (all moved or removed in the open PR) disappears,
|
|
596
|
+
// unless the user keeps a charter or skills there.
|
|
597
|
+
if (existsSync(dir) && !existsSync(join(dir, "skills")) && !existsSync(join(dir, CHARTER_FILE))) {
|
|
598
|
+
for (const f of [INDEX_FILE, SCOPE_META_FILE]) if (existsSync(join(dir, f))) ws.removeFile(join(dir, f));
|
|
599
|
+
}
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
const budget = scope === GLOBAL_SCOPE ? budgets.globalTokens : budgets.projectTokens;
|
|
603
|
+
const res = buildIndex(notes, titleOf(scope), budget);
|
|
604
|
+
const path = join(dir, INDEX_FILE);
|
|
605
|
+
const changed = !existsSync(path) || readFileSync(path, "utf-8") !== res.text;
|
|
606
|
+
if (changed) ws.writeFile(path, res.text);
|
|
607
|
+
out.push({ ...res, scope, budget, changed });
|
|
608
|
+
}
|
|
609
|
+
return out;
|
|
610
|
+
}
|
package/digest.test.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildDigest, clipMiddle, renderDigest, renderEntryFull, stripInjected } from "./digest.ts";
|
|
3
|
+
import type { ParsedSession, RawEntry } from "./sessions.ts";
|
|
4
|
+
|
|
5
|
+
function session(branch: RawEntry[]): ParsedSession {
|
|
6
|
+
return { file: "/tmp/s.jsonl", header: { id: "sess-1", cwd: "/tmp/p", timestamp: "2026-11-07T10:00:00Z" }, branch, lastEntryId: branch.at(-1)?.id };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const entries: RawEntry[] = [
|
|
10
|
+
{ type: "message", id: "u1", message: { role: "user", content: [{ type: "text", text: "добавь в chezmoi без коммита" }, { type: "image" }] } },
|
|
11
|
+
{ type: "custom", id: "c1", customType: "pi-dream", data: { event: "inject", ids: ["m-000001"] } },
|
|
12
|
+
{
|
|
13
|
+
type: "message",
|
|
14
|
+
id: "a1",
|
|
15
|
+
message: {
|
|
16
|
+
role: "assistant",
|
|
17
|
+
provider: "anthropic",
|
|
18
|
+
model: "claude-opus-5-5",
|
|
19
|
+
content: [
|
|
20
|
+
{ type: "thinking" },
|
|
21
|
+
{ type: "text", text: "Сделаю." },
|
|
22
|
+
{ type: "toolCall", name: "bash", arguments: { command: "chezmoi add ~/.config/x token=abcdefghijklmnop" } },
|
|
23
|
+
{ type: "toolCall", name: "memory_read", arguments: { ref: "m-000001" } },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
{ type: "message", id: "r1", message: { role: "toolResult", toolName: "bash", isError: true, content: [{ type: "text", text: "error: not found" }] } },
|
|
28
|
+
{ type: "message", id: "u2", message: { role: "user", content: '<skill name="x">long body</skill> ok' } },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
describe("digest", () => {
|
|
32
|
+
it("renders kinds, ids, stats and scrubs secrets", () => {
|
|
33
|
+
const d = buildDigest(session(entries));
|
|
34
|
+
expect(d.meta).toMatchObject({ userMessages: 2, toolCalls: 2, toolErrors: 1, injected: ["m-000001"], read: ["m-000001"], models: ["anthropic/claude-opus-5-5"] });
|
|
35
|
+
const text = renderDigest(d);
|
|
36
|
+
expect(text).toContain("[u1] USER: добавь в chezmoi без коммита\n [image]");
|
|
37
|
+
expect(text).toContain("[c1] MEMORY: injected notes: m-000001");
|
|
38
|
+
expect(text).toContain("[a1] CALL: bash $ chezmoi add ~/.config/x token=[REDACTED]");
|
|
39
|
+
expect(text).toContain("[r1] ERROR: bash: error: not found");
|
|
40
|
+
expect(text).toContain("[u2] USER: [skill: x] ok");
|
|
41
|
+
expect(text).not.toContain("thinking");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("shrinks to fit, keeping user lines longest", () => {
|
|
45
|
+
const big: RawEntry[] = [];
|
|
46
|
+
for (let i = 0; i < 50; i++) {
|
|
47
|
+
big.push({ type: "message", id: `u${i}`, message: { role: "user", content: `question ${i}` } });
|
|
48
|
+
big.push({ type: "message", id: `a${i}`, message: { role: "assistant", content: [{ type: "text", text: "y".repeat(3000) }] } });
|
|
49
|
+
big.push({ type: "message", id: `r${i}`, message: { role: "toolResult", toolName: "read", content: [{ type: "text", text: "z".repeat(500) }] } });
|
|
50
|
+
}
|
|
51
|
+
const d = buildDigest(session(big));
|
|
52
|
+
const text = renderDigest(d, 30_000);
|
|
53
|
+
expect(text.length).toBeLessThanOrEqual(30_100);
|
|
54
|
+
expect(text).toContain("question 49");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("clips and strips helpers", () => {
|
|
58
|
+
expect(clipMiddle("abcdefghij", 6)).toContain("omitted");
|
|
59
|
+
expect(stripInjected('<file name="a.ts">x</file> hi')).toBe("[file: a.ts] hi");
|
|
60
|
+
expect(renderEntryFull(entries[0])).toContain("[u1] USER:");
|
|
61
|
+
});
|
|
62
|
+
});
|