@askjo/pi-reflect 1.0.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.
@@ -0,0 +1,996 @@
1
+ /**
2
+ * Reflect core logic — all pure/testable functions.
3
+ * The extension entry point (index.ts) wires these into the pi extension API.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+ import { createReadStream } from "node:fs";
9
+ import { createInterface } from "node:readline";
10
+
11
+ // --- Paths ---
12
+
13
+ const HOME = process.env.HOME ?? "~";
14
+ const CONFIG_DIR = path.join(HOME, ".pi", "agent");
15
+ const CONFIG_FILE = path.join(CONFIG_DIR, "reflect.json");
16
+ const SESSIONS_DIR = path.join(HOME, ".pi", "agent", "sessions");
17
+ const DEFAULT_BACKUP_DIR = path.join(CONFIG_DIR, "reflect-backups");
18
+ const HISTORY_FILE = path.join(CONFIG_DIR, "reflect-history.json");
19
+
20
+ // --- Types ---
21
+
22
+ export interface TranscriptSource {
23
+ type: "pi-sessions" | "command";
24
+ /** Shell command that outputs transcript text to stdout. `{lookbackDays}` is interpolated. */
25
+ command?: string;
26
+ }
27
+
28
+ export interface ContextSource {
29
+ type: "files" | "command" | "url";
30
+ /** Label shown in the context block (e.g. "daily logs", "recent conversations") */
31
+ label?: string;
32
+ /** For "files": glob patterns or file paths. */
33
+ paths?: string[];
34
+ /** For "command": shell command. `{lookbackDays}` is interpolated. */
35
+ command?: string;
36
+ /** For "url": HTTP GET endpoint. `{lookbackDays}` is interpolated. */
37
+ url?: string;
38
+ /** Max bytes to read from this source (default: 100KB) */
39
+ maxBytes?: number;
40
+ }
41
+
42
+ export interface ReflectTarget {
43
+ path: string;
44
+ schedule: "daily" | "manual";
45
+ model: string;
46
+ lookbackDays: number;
47
+ maxSessionBytes: number;
48
+ backupDir: string;
49
+ /** Transcript sources. Can be a single TranscriptSource (legacy) or array of ContextSource[].
50
+ * Multiple sources are concatenated with --- separators. */
51
+ transcriptSource?: TranscriptSource;
52
+ transcripts?: ContextSource[];
53
+ /** Custom prompt template. Use {fileName}, {targetContent}, {transcripts}, {context} as placeholders.
54
+ * If omitted, uses the default correction-pattern prompt. */
55
+ prompt?: string;
56
+ /** Additional context sources to read and inject. Available via {context} placeholder in prompts.
57
+ * Each source has a type: "files" (glob/paths), "command" (shell, stdout), or "url" (HTTP GET). */
58
+ context?: ContextSource[];
59
+ }
60
+
61
+ export interface ReflectConfig {
62
+ targets: ReflectTarget[];
63
+ }
64
+
65
+ export interface EditRecord {
66
+ type: "strengthen" | "add";
67
+ section: string;
68
+ reason: string;
69
+ }
70
+
71
+ export interface ReflectRun {
72
+ timestamp: string;
73
+ targetPath: string;
74
+ sessionsAnalyzed: number;
75
+ correctionsFound: number;
76
+ editsApplied: number;
77
+ summary: string;
78
+ diffLines: number;
79
+ correctionRate: number;
80
+ edits?: EditRecord[];
81
+ sourceDate?: string;
82
+ date?: string; // legacy field from bash-script/batch runs
83
+ }
84
+
85
+ export interface SessionExchange {
86
+ role: "user" | "assistant";
87
+ text: string | null;
88
+ thinking: string | null;
89
+ }
90
+
91
+ export interface SessionData {
92
+ userCount: number;
93
+ exchangeCount: number;
94
+ transcript: string;
95
+ size: number;
96
+ project: string;
97
+ time: string;
98
+ }
99
+
100
+ export interface TranscriptResult {
101
+ transcripts: string;
102
+ sessionCount: number;
103
+ includedCount: number;
104
+ }
105
+
106
+ export interface EditResult {
107
+ result: string;
108
+ applied: number;
109
+ skipped: string[];
110
+ }
111
+
112
+ export interface AnalysisEdit {
113
+ type: "strengthen" | "add";
114
+ section?: string;
115
+ old_text?: string | null;
116
+ new_text: string;
117
+ after_text?: string | null;
118
+ reason?: string;
119
+ }
120
+
121
+ export interface ReflectionOptions {
122
+ sourceDateOverride?: string;
123
+ transcriptsOverride?: TranscriptResult;
124
+ dryRun?: boolean;
125
+ }
126
+
127
+ export type NotifyFn = (msg: string, level: "info" | "warning" | "error") => void;
128
+
129
+ // --- Defaults ---
130
+
131
+ export const DEFAULT_TARGET: ReflectTarget = {
132
+ path: "",
133
+ schedule: "daily",
134
+ model: "anthropic/claude-sonnet-4-5",
135
+ lookbackDays: 1,
136
+ maxSessionBytes: 600 * 1024,
137
+ backupDir: DEFAULT_BACKUP_DIR,
138
+ transcriptSource: { type: "pi-sessions" },
139
+ };
140
+
141
+ export const MAX_ASSISTANT_MSG_CHARS = 2000;
142
+ export const MAX_THINKING_MSG_CHARS = 1500;
143
+
144
+ // --- Config ---
145
+
146
+ export function loadConfig(): ReflectConfig {
147
+ try {
148
+ const raw = fs.readFileSync(CONFIG_FILE, "utf-8");
149
+ const parsed = JSON.parse(raw);
150
+ return {
151
+ targets: (parsed.targets ?? []).map((t: any) => ({
152
+ ...DEFAULT_TARGET,
153
+ ...t,
154
+ })),
155
+ };
156
+ } catch {
157
+ return { targets: [] };
158
+ }
159
+ }
160
+
161
+ export function saveConfig(config: ReflectConfig): void {
162
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
163
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
164
+ }
165
+
166
+ export function loadHistory(): ReflectRun[] {
167
+ try {
168
+ return JSON.parse(fs.readFileSync(HISTORY_FILE, "utf-8"));
169
+ } catch {
170
+ return [];
171
+ }
172
+ }
173
+
174
+ export function saveHistory(runs: ReflectRun[]): void {
175
+ const trimmed = runs.slice(-100);
176
+ fs.writeFileSync(HISTORY_FILE, JSON.stringify(trimmed, null, 2), "utf-8");
177
+ }
178
+
179
+ // --- Helpers ---
180
+
181
+ export function resolvePath(p: string): string {
182
+ if (p.startsWith("~")) {
183
+ return path.join(HOME, p.slice(1));
184
+ }
185
+ return path.resolve(p);
186
+ }
187
+
188
+ export function formatTimestamp(): string {
189
+ return new Date().toISOString().replace("T", "_").replace(/[:.]/g, "").slice(0, 15);
190
+ }
191
+
192
+ export function escapeRegex(str: string): string {
193
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
194
+ }
195
+
196
+ export function truncateText(text: string | null, limit: number): string | null {
197
+ if (!text) return text;
198
+ if (text.length > limit) {
199
+ return text.slice(0, limit) + `\n[...truncated, ${text.length - limit} chars omitted]`;
200
+ }
201
+ return text;
202
+ }
203
+
204
+ // --- Session extraction ---
205
+
206
+ export function projectNameFromDir(dirname: string): string {
207
+ let name = dirname;
208
+ const user = process.env.USER ?? "user";
209
+ const homePrefix = `--Users-${user}-`;
210
+ if (name.startsWith(homePrefix)) {
211
+ name = name.slice(homePrefix.length);
212
+ }
213
+ const linuxPrefix = `--home-${user}-`;
214
+ if (name.startsWith(linuxPrefix)) {
215
+ name = name.slice(linuxPrefix.length);
216
+ }
217
+ name = name.replace(/--/g, "/").replace(/^[-/]+|[-/]+$/g, "");
218
+ return name || "workspace";
219
+ }
220
+
221
+ export async function extractTranscript(filepath: string): Promise<SessionExchange[]> {
222
+ const exchanges: SessionExchange[] = [];
223
+ try {
224
+ const rl = createInterface({ input: createReadStream(filepath), crlfDelay: Infinity });
225
+ for await (const line of rl) {
226
+ let entry: any;
227
+ try {
228
+ entry = JSON.parse(line);
229
+ } catch {
230
+ continue;
231
+ }
232
+
233
+ if (entry.type !== "message") continue;
234
+ const msg = entry.message;
235
+ if (!msg) continue;
236
+ const role = msg.role;
237
+ if (role !== "user" && role !== "assistant") continue;
238
+
239
+ const content = msg.content;
240
+ if (!Array.isArray(content)) continue;
241
+
242
+ const textParts: string[] = [];
243
+ const thinkingParts: string[] = [];
244
+
245
+ for (const part of content) {
246
+ if (!part || typeof part !== "object") continue;
247
+ if (part.type === "text" && part.text?.trim()) {
248
+ textParts.push(part.text.trim());
249
+ } else if (part.type === "thinking" && part.thinking?.trim()) {
250
+ thinkingParts.push(part.thinking.trim());
251
+ }
252
+ }
253
+
254
+ if (textParts.length === 0 && thinkingParts.length === 0) continue;
255
+
256
+ exchanges.push({
257
+ role,
258
+ text: textParts.length > 0 ? textParts.join("\n") : null,
259
+ thinking: thinkingParts.length > 0 ? thinkingParts.join("\n") : null,
260
+ });
261
+ }
262
+ } catch {
263
+ // Skip unreadable files
264
+ }
265
+ return exchanges;
266
+ }
267
+
268
+ export function formatSessionTranscript(exchanges: SessionExchange[], sessionId: string, project: string): string {
269
+ const lines: string[] = [];
270
+ lines.push(`### Session: ${project} [${sessionId}]`);
271
+ lines.push("");
272
+
273
+ for (const ex of exchanges) {
274
+ if (ex.role === "user") {
275
+ lines.push(`**USER:** ${ex.text}`);
276
+ lines.push("");
277
+ } else if (ex.role === "assistant") {
278
+ if (ex.thinking) {
279
+ lines.push(`**THINKING:** ${truncateText(ex.thinking, MAX_THINKING_MSG_CHARS)}`);
280
+ lines.push("");
281
+ }
282
+ if (ex.text) {
283
+ lines.push(`**AGENT:** ${truncateText(ex.text, MAX_ASSISTANT_MSG_CHARS)}`);
284
+ lines.push("");
285
+ }
286
+ }
287
+ }
288
+
289
+ return lines.join("\n");
290
+ }
291
+
292
+ // --- Shared session scanning logic ---
293
+
294
+ function scanSessionFiles(
295
+ effectiveSessionsDir: string,
296
+ targetDates: string[],
297
+ maxBytes: number,
298
+ ): { allSessions: SessionData[]; totalScanned: number } {
299
+ // Include "next day" for UTC/local timezone overlap
300
+ const nextDates = targetDates.map((d) => {
301
+ const next = new Date(d + "T00:00:00Z");
302
+ next.setDate(next.getDate() + 1);
303
+ return next.toISOString().slice(0, 10);
304
+ });
305
+ const allDates = new Set([...targetDates, ...nextDates]);
306
+
307
+ const sessionDirs: string[] = [];
308
+ try {
309
+ for (const dir of fs.readdirSync(effectiveSessionsDir)) {
310
+ if (dir.includes("var-folders")) continue;
311
+ const fullDir = path.join(effectiveSessionsDir, dir);
312
+ if (fs.statSync(fullDir).isDirectory()) {
313
+ sessionDirs.push(fullDir);
314
+ }
315
+ }
316
+ } catch {
317
+ return { allSessions: [], totalScanned: 0 };
318
+ }
319
+
320
+ return { allSessions: [], totalScanned: 0, _sessionDirs: sessionDirs, _allDates: allDates, _targetDates: new Set(targetDates) } as any;
321
+ }
322
+
323
+ async function collectSessionsForDates(
324
+ targetDates: string[],
325
+ maxBytes: number,
326
+ sessionsDir?: string,
327
+ ): Promise<TranscriptResult> {
328
+ const effectiveSessionsDir = sessionsDir ?? SESSIONS_DIR;
329
+
330
+ const nextDates = targetDates.map((d) => {
331
+ const next = new Date(d + "T00:00:00Z");
332
+ next.setDate(next.getDate() + 1);
333
+ return next.toISOString().slice(0, 10);
334
+ });
335
+ const allDates = new Set([...targetDates, ...nextDates]);
336
+ const targetDateSet = new Set(targetDates);
337
+
338
+ const sessionDirs: string[] = [];
339
+ try {
340
+ for (const dir of fs.readdirSync(effectiveSessionsDir)) {
341
+ if (dir.includes("var-folders")) continue;
342
+ const fullDir = path.join(effectiveSessionsDir, dir);
343
+ if (fs.statSync(fullDir).isDirectory()) {
344
+ sessionDirs.push(fullDir);
345
+ }
346
+ }
347
+ } catch {
348
+ return { transcripts: "", sessionCount: 0, includedCount: 0 };
349
+ }
350
+
351
+ const allSessions: SessionData[] = [];
352
+ let totalScanned = 0;
353
+
354
+ for (const dir of sessionDirs) {
355
+ const project = projectNameFromDir(path.basename(dir));
356
+ let files: string[];
357
+ try {
358
+ files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
359
+ } catch {
360
+ continue;
361
+ }
362
+
363
+ for (const file of files) {
364
+ const fileDate = file.slice(0, 10);
365
+ if (!allDates.has(fileDate)) continue;
366
+
367
+ if (!targetDateSet.has(fileDate)) {
368
+ try {
369
+ const hour = parseInt(file.slice(11, 13));
370
+ if (hour >= 8) continue;
371
+ } catch {
372
+ continue;
373
+ }
374
+ }
375
+
376
+ totalScanned++;
377
+ const filepath = path.join(dir, file);
378
+ const exchanges = await extractTranscript(filepath);
379
+ const userCount = exchanges.filter((e) => e.role === "user").length;
380
+
381
+ if (userCount < 1 || exchanges.length < 3) continue;
382
+
383
+ const sessionTime = file.slice(0, 19).replace("T", " ");
384
+ const transcript = formatSessionTranscript(exchanges, sessionTime, project);
385
+
386
+ allSessions.push({
387
+ userCount,
388
+ exchangeCount: exchanges.length,
389
+ transcript,
390
+ size: transcript.length,
391
+ project,
392
+ time: sessionTime,
393
+ });
394
+ }
395
+ }
396
+
397
+ if (allSessions.length === 0) {
398
+ return { transcripts: "", sessionCount: totalScanned, includedCount: 0 };
399
+ }
400
+
401
+ allSessions.sort((a, b) => {
402
+ const densityA = a.userCount / Math.max(a.exchangeCount, 1);
403
+ const densityB = b.userCount / Math.max(b.exchangeCount, 1);
404
+ if (densityB !== densityA) return densityB - densityA;
405
+ return b.userCount - a.userCount;
406
+ });
407
+
408
+ const parts: string[] = [];
409
+ let currentSize = 0;
410
+ let included = 0;
411
+
412
+ for (const sd of allSessions) {
413
+ const entry = sd.transcript + "\n---\n\n";
414
+ if (currentSize + entry.length > maxBytes) continue;
415
+ parts.push(entry);
416
+ currentSize += entry.length;
417
+ included++;
418
+ }
419
+
420
+ const header =
421
+ `# Session Transcripts\n` +
422
+ `# Sessions scanned: ${totalScanned}, ${allSessions.length} with substantive conversation, ${included} included\n` +
423
+ `# Total user messages: ${allSessions.reduce((s, sd) => s + sd.userCount, 0)}\n\n`;
424
+
425
+ return {
426
+ transcripts: header + parts.join(""),
427
+ sessionCount: totalScanned,
428
+ includedCount: included,
429
+ };
430
+ }
431
+
432
+ export async function collectTranscripts(lookbackDays: number, maxBytes: number, sessionsDir?: string): Promise<TranscriptResult> {
433
+ const targetDates: string[] = [];
434
+ for (let i = 1; i <= lookbackDays; i++) {
435
+ const d = new Date();
436
+ d.setDate(d.getDate() - i);
437
+ targetDates.push(d.toISOString().slice(0, 10));
438
+ }
439
+ return collectSessionsForDates(targetDates, maxBytes, sessionsDir);
440
+ }
441
+
442
+ export async function collectTranscriptsForDate(targetDate: string, maxBytes: number, sessionsDir?: string): Promise<TranscriptResult> {
443
+ return collectSessionsForDates([targetDate], maxBytes, sessionsDir);
444
+ }
445
+
446
+ export async function collectTranscriptsFromCommand(command: string, lookbackDays: number, maxBytes: number): Promise<TranscriptResult> {
447
+ const { execSync } = await import("node:child_process");
448
+ const interpolated = command.replace(/\{lookbackDays\}/g, String(lookbackDays));
449
+
450
+ try {
451
+ let output = execSync(interpolated, {
452
+ encoding: "utf-8",
453
+ timeout: 60_000,
454
+ maxBuffer: maxBytes * 2,
455
+ });
456
+
457
+ if (output.length > maxBytes) {
458
+ output = output.slice(0, maxBytes) + "\n\n[...truncated to fit context budget]";
459
+ }
460
+
461
+ const sessionMatches = output.match(/^### Session:/gm);
462
+ const count = sessionMatches?.length ?? 1;
463
+
464
+ return { transcripts: output, sessionCount: count, includedCount: count };
465
+ } catch {
466
+ return { transcripts: "", sessionCount: 0, includedCount: 0 };
467
+ }
468
+ }
469
+
470
+ // --- Context collection ---
471
+
472
+ /** Compute the cutoff date string (YYYY-MM-DD) for lookbackDays ago */
473
+ function lookbackCutoff(lookbackDays: number): string {
474
+ const d = new Date();
475
+ d.setDate(d.getDate() - lookbackDays);
476
+ return d.toISOString().slice(0, 10);
477
+ }
478
+
479
+ /** Check if a filename contains a date and whether it's within the lookback window */
480
+ function isWithinLookback(filename: string, cutoff: string): boolean {
481
+ const match = filename.match(/(\d{4}-\d{2}-\d{2})/);
482
+ if (!match) return true; // no date in filename → include it
483
+ return match[1] >= cutoff;
484
+ }
485
+
486
+ export async function collectContext(sources: ContextSource[], lookbackDays: number): Promise<string> {
487
+ const parts: string[] = [];
488
+ const cutoff = lookbackCutoff(lookbackDays);
489
+
490
+ for (const source of sources) {
491
+ const maxBytes = source.maxBytes ?? 100 * 1024;
492
+ const label = source.label ?? source.type;
493
+ let content = "";
494
+ let totalBytes = 0;
495
+
496
+ try {
497
+ if (source.type === "files" && source.paths) {
498
+ const fileParts: string[] = [];
499
+ for (const pattern of source.paths) {
500
+ const expanded = pattern.replace(/\{lookbackDays\}/g, String(lookbackDays));
501
+ let candidates: { name: string; full: string }[] = [];
502
+
503
+ if (expanded.includes("*")) {
504
+ const dir = path.dirname(expanded);
505
+ const filePattern = path.basename(expanded);
506
+ const regex = new RegExp("^" + filePattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
507
+ try {
508
+ candidates = fs.readdirSync(dir)
509
+ .filter(f => regex.test(f))
510
+ .map(f => ({ name: f, full: path.join(dir, f) }));
511
+ } catch {}
512
+ } else if (fs.existsSync(expanded)) {
513
+ candidates = [{ name: path.basename(expanded), full: expanded }];
514
+ }
515
+
516
+ // Prune by date, sort newest first
517
+ candidates = candidates
518
+ .filter(c => isWithinLookback(c.name, cutoff))
519
+ .sort((a, b) => b.name.localeCompare(a.name));
520
+
521
+ for (const c of candidates) {
522
+ try {
523
+ if (!fs.statSync(c.full).isFile()) continue;
524
+ const fileContent = fs.readFileSync(c.full, "utf-8");
525
+ if (totalBytes + fileContent.length > maxBytes) break;
526
+ fileParts.push(`### ${c.name}\n${fileContent}`);
527
+ totalBytes += fileContent.length;
528
+ } catch {}
529
+ }
530
+ }
531
+ content = fileParts.join("\n\n");
532
+ } else if (source.type === "command" && source.command) {
533
+ const { execSync } = await import("node:child_process");
534
+ const interpolated = source.command.replace(/\{lookbackDays\}/g, String(lookbackDays));
535
+ content = execSync(interpolated, {
536
+ encoding: "utf-8",
537
+ timeout: 30_000,
538
+ maxBuffer: maxBytes * 2,
539
+ });
540
+ } else if (source.type === "url" && source.url) {
541
+ const interpolated = source.url.replace(/\{lookbackDays\}/g, String(lookbackDays));
542
+ const response = await fetch(interpolated, { signal: AbortSignal.timeout(15_000) });
543
+ if (response.ok) {
544
+ content = await response.text();
545
+ }
546
+ }
547
+ } catch {}
548
+
549
+ if (content) {
550
+ if (content.length > maxBytes) {
551
+ content = content.slice(0, maxBytes) + "\n\n[...truncated to fit context budget]";
552
+ }
553
+ parts.push(`## ${label}\n${content}`);
554
+ }
555
+ }
556
+
557
+ return parts.join("\n\n---\n\n");
558
+ }
559
+
560
+ // --- Scan available session dates ---
561
+
562
+ export function getAvailableSessionDates(): string[] {
563
+ const dates = new Set<string>();
564
+ try {
565
+ for (const dir of fs.readdirSync(SESSIONS_DIR)) {
566
+ if (dir.includes("var-folders")) continue;
567
+ const fullDir = path.join(SESSIONS_DIR, dir);
568
+ if (!fs.statSync(fullDir).isDirectory()) continue;
569
+ for (const file of fs.readdirSync(fullDir)) {
570
+ if (!file.endsWith(".jsonl")) continue;
571
+ const fileDate = file.slice(0, 10);
572
+ if (/^\d{4}-\d{2}-\d{2}$/.test(fileDate)) {
573
+ dates.add(fileDate);
574
+ }
575
+ }
576
+ }
577
+ } catch {}
578
+ return [...dates].sort();
579
+ }
580
+
581
+ // --- LLM prompt ---
582
+
583
+ export function buildReflectionPrompt(targetPath: string, targetContent: string, transcripts: string): string {
584
+ const fileName = path.basename(targetPath);
585
+ return `You are reviewing recent agent session transcripts to improve ${fileName}.
586
+
587
+ ## Input
588
+
589
+ ### Target file: ${fileName}
590
+ <target_file>
591
+ ${targetContent}
592
+ </target_file>
593
+
594
+ ### Session transcripts
595
+ <transcripts>
596
+ ${transcripts}
597
+ </transcripts>
598
+
599
+ ## Step 1: Identify Correction Patterns
600
+
601
+ Read through all the transcripts carefully. Look for:
602
+ - User redirecting the agent ("no", "not that", "I said...", "wrong", "actually...")
603
+ - User expressing frustration ("bro", "wtf", "seriously", "come on", "sigh")
604
+ - User having to repeat themselves or re-explain
605
+ - User asking the agent to undo or revert something
606
+ - User telling the agent to simplify or stop over-engineering
607
+ - User correcting the agent's approach or understanding
608
+ - Agent thinking that reveals a misunderstanding that the user then corrects
609
+
610
+ For each real correction, note: what the agent did wrong, what the user wanted, and which rule in ${fileName} (if any) already covers this.
611
+
612
+ Ignore normal conversation flow — "no" in "no worries" or "actually, that looks good" are NOT corrections. Focus on genuine friction where the agent's behavior wasted the user's time.
613
+
614
+ ## Step 2: Propose Edits
615
+
616
+ Based on the patterns you found:
617
+ - Only propose edits that address ACTUAL patterns in the transcripts. Don't invent hypothetical rules.
618
+ - If an existing rule already covers the pattern but the agent still violated it, STRENGTHEN the wording (make it more prominent, add emphasis, add a concrete example).
619
+ - If a correction pattern has no matching rule, propose a new bullet in the most appropriate existing section.
620
+ - Do NOT reorganize, rewrite, or restructure the file. Propose minimal, targeted edits.
621
+ - Do NOT remove any existing rules.
622
+ - Do NOT add rules for one-off situations. Only add rules for patterns (2+ occurrences across different sessions).
623
+ - Keep the same tone and style as the existing file.
624
+
625
+ ## Step 3: Output
626
+
627
+ IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no preamble. Start with { and end with }.
628
+
629
+ For "strengthen" edits: old_text must be a COMPLETE bullet point or rule from the file, copied character-for-character. new_text is the full replacement. Do NOT use partial strings — always include the complete line/bullet from "- **" to the end of the bullet point.
630
+ For "add" edits: after_text must be a COMPLETE bullet point or line from the file, copied exactly. new_text is inserted on a new line after it. The new_text should be a complete new bullet point.
631
+ CRITICAL: Never duplicate content. new_text should EXTEND or REPLACE old_text, not repeat it.
632
+
633
+ {
634
+ "corrections_found": <number>,
635
+ "sessions_with_corrections": <number>,
636
+ "edits": [
637
+ {
638
+ "type": "strengthen" | "add",
639
+ "section": "which section of the file",
640
+ "old_text": "exact text to find (for strengthen) or null (for add)",
641
+ "new_text": "replacement text (for strengthen) or new text to insert (for add)",
642
+ "after_text": "text after which to insert (for add) or null (for strengthen)",
643
+ "reason": "why this edit is needed, with session evidence"
644
+ }
645
+ ],
646
+ "patterns_not_added": [
647
+ {
648
+ "pattern": "description",
649
+ "reason": "why it wasn't added (one-off, already covered, etc.)"
650
+ }
651
+ ],
652
+ "summary": "2-3 sentence summary of what was found and changed"
653
+ }`;
654
+ }
655
+
656
+ /** Build the prompt for a target. If target has a custom prompt, interpolate it. Otherwise use default. */
657
+ export function buildPromptForTarget(target: ReflectTarget, targetPath: string, targetContent: string, transcripts: string, context?: string): string {
658
+ if (!target.prompt) {
659
+ return buildReflectionPrompt(targetPath, targetContent, transcripts);
660
+ }
661
+ const fileName = path.basename(targetPath);
662
+ return target.prompt
663
+ .replace(/\{fileName\}/g, fileName)
664
+ .replace(/\{targetContent\}/g, targetContent)
665
+ .replace(/\{transcripts\}/g, transcripts)
666
+ .replace(/\{context\}/g, context ?? "");
667
+ }
668
+
669
+ // --- Edit application ---
670
+
671
+ export function applyEdits(content: string, edits: AnalysisEdit[]): EditResult {
672
+ let result = content;
673
+ let applied = 0;
674
+ const skipped: string[] = [];
675
+
676
+ for (const edit of edits) {
677
+ if (edit.type === "strengthen" && edit.old_text && edit.new_text) {
678
+ if (!result.includes(edit.old_text)) {
679
+ skipped.push(`Could not find text to strengthen: "${edit.old_text.slice(0, 80)}..."`);
680
+ continue;
681
+ }
682
+
683
+ const firstIdx = result.indexOf(edit.old_text);
684
+ const secondIdx = result.indexOf(edit.old_text, firstIdx + 1);
685
+ if (secondIdx !== -1) {
686
+ skipped.push(`Ambiguous match (appears multiple times): "${edit.old_text.slice(0, 80)}..."`);
687
+ continue;
688
+ }
689
+
690
+ if (edit.old_text.length > 50) {
691
+ const checkSnippet = edit.old_text.slice(0, 50);
692
+ const occurrences = (edit.new_text.match(new RegExp(escapeRegex(checkSnippet), "g")) || []).length;
693
+ if (occurrences > 1) {
694
+ skipped.push(`Duplication detected in replacement text: "${edit.old_text.slice(0, 80)}..."`);
695
+ continue;
696
+ }
697
+ }
698
+
699
+ result = result.replace(edit.old_text, edit.new_text);
700
+ applied++;
701
+ } else if (edit.type === "add" && edit.new_text && edit.after_text) {
702
+ if (!result.includes(edit.after_text)) {
703
+ skipped.push(`Could not find insertion point: "${edit.after_text.slice(0, 80)}..."`);
704
+ continue;
705
+ }
706
+
707
+ const firstIdx = result.indexOf(edit.after_text);
708
+ const secondIdx = result.indexOf(edit.after_text, firstIdx + 1);
709
+ if (secondIdx !== -1) {
710
+ skipped.push(`Ambiguous insertion point (appears multiple times): "${edit.after_text.slice(0, 80)}..."`);
711
+ continue;
712
+ }
713
+
714
+ if (result.includes(edit.new_text.trim())) {
715
+ skipped.push(`Text already exists in file: "${edit.new_text.trim().slice(0, 80)}..."`);
716
+ continue;
717
+ }
718
+
719
+ result = result.replace(edit.after_text, edit.after_text + "\n" + edit.new_text);
720
+ applied++;
721
+ } else {
722
+ skipped.push(`Invalid edit: ${JSON.stringify(edit).slice(0, 100)}`);
723
+ }
724
+ }
725
+
726
+ return { result, applied, skipped };
727
+ }
728
+
729
+ // --- Main reflection logic ---
730
+
731
+ export interface RunReflectionDeps {
732
+ completeSimple: (model: any, request: any, options: any) => Promise<any>;
733
+ getModel: (provider: string, modelId: string) => any;
734
+ collectTranscriptsFn?: (lookbackDays: number, maxBytes: number) => Promise<TranscriptResult>;
735
+ collectTranscriptsFromCommandFn?: (command: string, lookbackDays: number, maxBytes: number) => Promise<TranscriptResult>;
736
+ }
737
+
738
+ export async function runReflection(
739
+ target: ReflectTarget,
740
+ modelRegistry: any,
741
+ notify: NotifyFn,
742
+ deps?: RunReflectionDeps,
743
+ options?: ReflectionOptions,
744
+ ): Promise<ReflectRun | null> {
745
+ const targetPath = resolvePath(target.path);
746
+
747
+ if (!fs.existsSync(targetPath)) {
748
+ notify(`Target file not found: ${targetPath}`, "error");
749
+ return null;
750
+ }
751
+
752
+ const targetContent = fs.readFileSync(targetPath, "utf-8");
753
+ if (targetContent.length < 100) {
754
+ notify(`Target file too small (${targetContent.length} bytes): ${targetPath}`, "error");
755
+ return null;
756
+ }
757
+
758
+ // Collect transcripts — supports new `transcripts` array (ContextSource[]) or legacy `transcriptSource`
759
+ let transcripts: string;
760
+ let sessionCount = 0;
761
+ let includedCount = 0;
762
+
763
+ if (options?.transcriptsOverride) {
764
+ ({ transcripts, sessionCount, includedCount } = options.transcriptsOverride);
765
+ } else if (target.transcripts && target.transcripts.length > 0) {
766
+ // New array-based transcript sources
767
+ notify(`Extracting transcripts from ${target.transcripts.length} source(s) (last ${target.lookbackDays} day(s))...`, "info");
768
+ transcripts = await collectContext(target.transcripts, target.lookbackDays);
769
+ // Estimate session count from section headers
770
+ const headerMatches = transcripts.match(/^###\s/gm);
771
+ sessionCount = headerMatches?.length ?? 1;
772
+ includedCount = sessionCount;
773
+ } else if (target.transcriptSource?.type === "command" && target.transcriptSource.command) {
774
+ notify(`Extracting transcripts (last ${target.lookbackDays} day(s))...`, "info");
775
+ const fn = deps?.collectTranscriptsFromCommandFn ?? collectTranscriptsFromCommand;
776
+ const result = await fn(
777
+ target.transcriptSource.command,
778
+ target.lookbackDays,
779
+ target.maxSessionBytes,
780
+ );
781
+ ({ transcripts, sessionCount, includedCount } = result);
782
+ } else {
783
+ notify(`Extracting transcripts (last ${target.lookbackDays} day(s))...`, "info");
784
+ const fn = deps?.collectTranscriptsFn ?? collectTranscripts;
785
+ const result = await fn(
786
+ target.lookbackDays,
787
+ target.maxSessionBytes,
788
+ );
789
+ ({ transcripts, sessionCount, includedCount } = result);
790
+ }
791
+
792
+ if (!transcripts || includedCount === 0) {
793
+ notify(`No substantive sessions found (${sessionCount} scanned). Nothing to reflect on.`, "info");
794
+ return null;
795
+ }
796
+
797
+ notify(`Extracted ${includedCount} sessions (${sessionCount} scanned, ${(transcripts.length / 1024).toFixed(0)}KB)`, "info");
798
+
799
+ // Resolve model
800
+ const getModelFn = deps?.getModel ?? (await import("@mariozechner/pi-ai")).getModel;
801
+ const [provider, modelId] = target.model.split("/", 2);
802
+ let model = getModelFn(provider as any, modelId as any);
803
+
804
+ if (!model) {
805
+ model = modelRegistry?.find(provider, modelId);
806
+ }
807
+ if (!model) {
808
+ notify(`Model not found: ${target.model}`, "error");
809
+ return null;
810
+ }
811
+
812
+ const apiKey = await modelRegistry?.getApiKey(model);
813
+ if (!apiKey) {
814
+ notify(`No API key for model: ${target.model}`, "error");
815
+ return null;
816
+ }
817
+
818
+ // Collect additional context
819
+ let context = "";
820
+ if (target.context && target.context.length > 0) {
821
+ notify(`Collecting context from ${target.context.length} source(s)...`, "info");
822
+ context = await collectContext(target.context, target.lookbackDays);
823
+ if (context) {
824
+ notify(`Collected ${(context.length / 1024).toFixed(0)}KB of additional context`, "info");
825
+ }
826
+ }
827
+
828
+ // Build prompt and call LLM
829
+ notify(`Analyzing with ${target.model}...`, "info");
830
+ const prompt = buildPromptForTarget(target, targetPath, targetContent, transcripts, context);
831
+
832
+ const completeFn = deps?.completeSimple ?? (await import("@mariozechner/pi-ai")).completeSimple;
833
+ const response = await completeFn(model, {
834
+ systemPrompt: "You are a behavioral analysis tool. You analyze agent session transcripts and output ONLY valid JSON. Never output markdown, explanations, or any text outside the JSON object.",
835
+ messages: [
836
+ {
837
+ role: "user" as const,
838
+ content: [{ type: "text" as const, text: prompt }],
839
+ timestamp: Date.now(),
840
+ },
841
+ ],
842
+ }, { apiKey, maxTokens: 16384 });
843
+
844
+ const responseText = response.content
845
+ .filter((c: any) => c.type === "text")
846
+ .map((c: any) => c.text)
847
+ .join("")
848
+ .trim();
849
+
850
+ // Parse JSON response
851
+ let analysis: any;
852
+ try {
853
+ const jsonStr = responseText.replace(/^```json?\s*\n?/m, "").replace(/\n?```\s*$/m, "");
854
+ analysis = JSON.parse(jsonStr);
855
+ } catch {
856
+ notify(`Failed to parse LLM response as JSON. Raw response:\n${responseText.slice(0, 500)}`, "error");
857
+ return null;
858
+ }
859
+
860
+ const edits: AnalysisEdit[] = analysis.edits ?? [];
861
+ const correctionsFound = analysis.corrections_found ?? 0;
862
+ const correctionRate = includedCount > 0 ? correctionsFound / includedCount : 0;
863
+
864
+ // sourceDate = the date of sessions being analyzed, not when reflect ran
865
+ let sourceDateStr: string;
866
+ if (options?.sourceDateOverride) {
867
+ sourceDateStr = options.sourceDateOverride;
868
+ } else {
869
+ const sourceDate = new Date();
870
+ sourceDate.setDate(sourceDate.getDate() - target.lookbackDays);
871
+ sourceDateStr = sourceDate.toISOString().slice(0, 10);
872
+ }
873
+
874
+ if (edits.length === 0) {
875
+ notify(`No edits needed. ${analysis.summary ?? ""}`, "info");
876
+ return {
877
+ timestamp: new Date().toISOString(),
878
+ targetPath,
879
+ sessionsAnalyzed: includedCount,
880
+ correctionsFound,
881
+ editsApplied: 0,
882
+ summary: analysis.summary ?? "No edits needed.",
883
+ diffLines: 0,
884
+ correctionRate,
885
+ edits: [],
886
+ sourceDate: sourceDateStr,
887
+ };
888
+ }
889
+
890
+ // In dryRun mode, skip applying edits — just record the analysis
891
+ if (options?.dryRun) {
892
+ const editRecords: EditRecord[] = edits
893
+ .filter((e: any) => e.section && e.reason)
894
+ .map((e: any) => ({
895
+ type: e.type ?? "add",
896
+ section: e.section,
897
+ reason: e.reason,
898
+ }));
899
+
900
+ const summary = analysis.summary ?? `${edits.length} edits proposed (dry run).`;
901
+ notify(`[dry run] ${summary}`, "info");
902
+
903
+ return {
904
+ timestamp: new Date().toISOString(),
905
+ targetPath,
906
+ sessionsAnalyzed: includedCount,
907
+ correctionsFound,
908
+ editsApplied: 0,
909
+ summary,
910
+ diffLines: 0,
911
+ correctionRate,
912
+ edits: editRecords,
913
+ sourceDate: sourceDateStr,
914
+ };
915
+ }
916
+
917
+ // Backup before editing
918
+ const backupDir = resolvePath(target.backupDir);
919
+ fs.mkdirSync(backupDir, { recursive: true });
920
+ const backupPath = path.join(backupDir, `${path.basename(targetPath, ".md")}_${formatTimestamp()}.md`);
921
+ fs.copyFileSync(targetPath, backupPath);
922
+
923
+ // Apply edits with safety checks
924
+ const { result, applied, skipped } = applyEdits(targetContent, edits);
925
+
926
+ if (applied === 0) {
927
+ notify(`All ${edits.length} edits failed to apply. Skipped: ${skipped.join("; ")}`, "warning");
928
+ try { fs.unlinkSync(backupPath); } catch {}
929
+ return null;
930
+ }
931
+
932
+ // Size sanity check — reject if result lost more than half the content
933
+ if (result.length < targetContent.length * 0.5) {
934
+ notify(`Result is suspiciously small (${result.length} vs ${targetContent.length} bytes). Aborting.`, "error");
935
+ return null;
936
+ }
937
+
938
+ fs.writeFileSync(targetPath, result, "utf-8");
939
+
940
+ // Count changed lines
941
+ const originalLines = targetContent.split("\n");
942
+ const resultLines = result.split("\n");
943
+ let diffLines = 0;
944
+ const maxLen = Math.max(originalLines.length, resultLines.length);
945
+ for (let i = 0; i < maxLen; i++) {
946
+ if (originalLines[i] !== resultLines[i]) diffLines++;
947
+ }
948
+
949
+ if (skipped.length > 0) {
950
+ notify(`Applied ${applied}/${edits.length} edits (${skipped.length} skipped). Backup: ${backupPath}`, "warning");
951
+ } else {
952
+ notify(`Applied ${applied} edit(s). Backup: ${backupPath}`, "info");
953
+ }
954
+
955
+ const summary = analysis.summary ?? `${applied} edits applied from ${includedCount} sessions.`;
956
+ notify(summary, "info");
957
+
958
+ // If target is in a git repo, commit the change
959
+ try {
960
+ const realPath = fs.realpathSync(targetPath);
961
+ const repoDir = path.dirname(realPath);
962
+ if (fs.existsSync(path.join(repoDir, ".git"))) {
963
+ const { execFileSync } = require("node:child_process");
964
+ execFileSync("git", ["add", "-A"], { cwd: repoDir, stdio: "ignore", timeout: 5000 });
965
+ execFileSync("git", ["commit", "-m", `reflect: ${path.basename(realPath)} — ${applied} edits from ${includedCount} sessions`, "--no-verify"], { cwd: repoDir, stdio: "ignore", timeout: 5000 });
966
+ notify(`Committed to ${path.basename(repoDir)}`, "info");
967
+ }
968
+ } catch {
969
+ // Not in a git repo or commit failed — that's fine
970
+ }
971
+
972
+ // Extract per-edit detail for recidivism tracking
973
+ const editRecords: EditRecord[] = edits
974
+ .filter((e: any) => e.section && e.reason)
975
+ .map((e: any) => ({
976
+ type: e.type ?? "add",
977
+ section: e.section,
978
+ reason: e.reason,
979
+ }));
980
+
981
+ return {
982
+ timestamp: new Date().toISOString(),
983
+ targetPath,
984
+ sessionsAnalyzed: includedCount,
985
+ correctionsFound,
986
+ editsApplied: applied,
987
+ summary,
988
+ diffLines,
989
+ correctionRate,
990
+ edits: editRecords,
991
+ sourceDate: sourceDateStr,
992
+ };
993
+ }
994
+
995
+ // Re-export path constants for the extension entry point
996
+ export { CONFIG_FILE, CONFIG_DIR, SESSIONS_DIR, DEFAULT_BACKUP_DIR, HISTORY_FILE, HOME };