@askjo/pi-reflect 1.0.0 → 1.2.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/.github/FUNDING.yml +1 -0
- package/.github/workflows/publish.yml +46 -0
- package/README.md +14 -0
- package/extensions/index.ts +103 -20
- package/extensions/reflect.ts +513 -139
- package/package.json +1 -1
- package/release.sh +39 -0
- package/tests/apply-edits.test.ts +51 -0
- package/tests/batching.test.ts +642 -0
- package/tests/context-files.test.ts +59 -0
- package/tests/helpers-and-utils.test.ts +29 -0
- package/tests/prompt-building.test.ts +9 -17
- package/tests/reflect.test.ts +539 -0
- package/tests/run-reflection.test.ts +33 -4
package/extensions/reflect.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface ReflectConfig {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface EditRecord {
|
|
66
|
-
type: "strengthen" | "add";
|
|
66
|
+
type: "strengthen" | "add" | "remove" | "merge";
|
|
67
67
|
section: string;
|
|
68
68
|
reason: string;
|
|
69
69
|
}
|
|
@@ -80,6 +80,8 @@ export interface ReflectRun {
|
|
|
80
80
|
edits?: EditRecord[];
|
|
81
81
|
sourceDate?: string;
|
|
82
82
|
date?: string; // legacy field from bash-script/batch runs
|
|
83
|
+
/** File size metrics after this run */
|
|
84
|
+
fileSize?: { chars: number; words: number; lines: number; estTokens: number };
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export interface SessionExchange {
|
|
@@ -101,6 +103,8 @@ export interface TranscriptResult {
|
|
|
101
103
|
transcripts: string;
|
|
102
104
|
sessionCount: number;
|
|
103
105
|
includedCount: number;
|
|
106
|
+
/** Individual sessions for chunked processing when transcripts exceed context budget */
|
|
107
|
+
sessions?: SessionData[];
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
export interface EditResult {
|
|
@@ -110,11 +114,13 @@ export interface EditResult {
|
|
|
110
114
|
}
|
|
111
115
|
|
|
112
116
|
export interface AnalysisEdit {
|
|
113
|
-
type: "strengthen" | "add";
|
|
117
|
+
type: "strengthen" | "add" | "remove" | "merge";
|
|
114
118
|
section?: string;
|
|
115
119
|
old_text?: string | null;
|
|
116
120
|
new_text: string;
|
|
117
121
|
after_text?: string | null;
|
|
122
|
+
/** For "merge": array of exact text strings to remove (they're consolidated into new_text) */
|
|
123
|
+
merge_sources?: string[];
|
|
118
124
|
reason?: string;
|
|
119
125
|
}
|
|
120
126
|
|
|
@@ -122,6 +128,10 @@ export interface ReflectionOptions {
|
|
|
122
128
|
sourceDateOverride?: string;
|
|
123
129
|
transcriptsOverride?: TranscriptResult;
|
|
124
130
|
dryRun?: boolean;
|
|
131
|
+
/** Override model — use the current session model instead of target.model */
|
|
132
|
+
currentModel?: any;
|
|
133
|
+
currentModelApiKey?: string;
|
|
134
|
+
currentModelHeaders?: Record<string, string>;
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
export type NotifyFn = (msg: string, level: "info" | "warning" | "error") => void;
|
|
@@ -141,6 +151,17 @@ export const DEFAULT_TARGET: ReflectTarget = {
|
|
|
141
151
|
export const MAX_ASSISTANT_MSG_CHARS = 2000;
|
|
142
152
|
export const MAX_THINKING_MSG_CHARS = 1500;
|
|
143
153
|
|
|
154
|
+
// --- File size metrics ---
|
|
155
|
+
|
|
156
|
+
export function computeFileMetrics(content: string): { chars: number; words: number; lines: number; estTokens: number } {
|
|
157
|
+
const chars = content.length;
|
|
158
|
+
const words = content.split(/\s+/).filter(Boolean).length;
|
|
159
|
+
const lines = content.split("\n").length;
|
|
160
|
+
// Rough estimate: ~4 chars per token for English/code mix
|
|
161
|
+
const estTokens = Math.round(chars / 4);
|
|
162
|
+
return { chars, words, lines, estTokens };
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
// --- Config ---
|
|
145
166
|
|
|
146
167
|
export function loadConfig(): ReflectConfig {
|
|
@@ -395,7 +416,7 @@ async function collectSessionsForDates(
|
|
|
395
416
|
}
|
|
396
417
|
|
|
397
418
|
if (allSessions.length === 0) {
|
|
398
|
-
return { transcripts: "", sessionCount: totalScanned, includedCount: 0 };
|
|
419
|
+
return { transcripts: "", sessionCount: totalScanned, includedCount: 0, sessions: [] };
|
|
399
420
|
}
|
|
400
421
|
|
|
401
422
|
allSessions.sort((a, b) => {
|
|
@@ -426,6 +447,7 @@ async function collectSessionsForDates(
|
|
|
426
447
|
transcripts: header + parts.join(""),
|
|
427
448
|
sessionCount: totalScanned,
|
|
428
449
|
includedCount: included,
|
|
450
|
+
sessions: allSessions,
|
|
429
451
|
};
|
|
430
452
|
}
|
|
431
453
|
|
|
@@ -483,6 +505,68 @@ function isWithinLookback(filename: string, cutoff: string): boolean {
|
|
|
483
505
|
return match[1] >= cutoff;
|
|
484
506
|
}
|
|
485
507
|
|
|
508
|
+
function findGlobBaseDir(globPattern: string): string {
|
|
509
|
+
const normalized = path.normalize(globPattern);
|
|
510
|
+
const segments = normalized.split(path.sep);
|
|
511
|
+
const globIndex = segments.findIndex((segment) => segment.includes("*"));
|
|
512
|
+
|
|
513
|
+
if (globIndex === -1) return path.dirname(normalized);
|
|
514
|
+
|
|
515
|
+
const baseSegments = segments.slice(0, globIndex).filter(Boolean);
|
|
516
|
+
if (normalized.startsWith(path.sep)) {
|
|
517
|
+
return baseSegments.length > 0 ? path.join(path.sep, ...baseSegments) : path.sep;
|
|
518
|
+
}
|
|
519
|
+
return baseSegments.length > 0 ? path.join(...baseSegments) : ".";
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function globToRegex(globPattern: string): RegExp {
|
|
523
|
+
const normalized = globPattern.split(path.sep).join("/");
|
|
524
|
+
const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
525
|
+
const globstarToken = "__PI_REFLECT_GLOBSTAR__";
|
|
526
|
+
const withGlobstarToken = escaped.replace(/\*\*\/?/g, globstarToken);
|
|
527
|
+
const withWildcards = withGlobstarToken.replace(/\*/g, "[^/]*");
|
|
528
|
+
const withGlobstar = withWildcards.replaceAll(globstarToken, "(?:.*/)?");
|
|
529
|
+
return new RegExp(`^${withGlobstar}$`);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function walkFilesRecursive(dir: string): string[] {
|
|
533
|
+
const files: string[] = [];
|
|
534
|
+
|
|
535
|
+
try {
|
|
536
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
537
|
+
for (const entry of entries) {
|
|
538
|
+
const fullPath = path.join(dir, entry.name);
|
|
539
|
+
if (entry.isDirectory()) {
|
|
540
|
+
files.push(...walkFilesRecursive(fullPath));
|
|
541
|
+
} else if (entry.isFile()) {
|
|
542
|
+
files.push(fullPath);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
} catch {}
|
|
546
|
+
|
|
547
|
+
return files;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function findMatchingFiles(globPattern: string): { name: string; full: string }[] {
|
|
551
|
+
const baseDir = findGlobBaseDir(globPattern);
|
|
552
|
+
if (!fs.existsSync(baseDir)) return [];
|
|
553
|
+
|
|
554
|
+
const relativePattern = path.relative(baseDir, globPattern);
|
|
555
|
+
const relativeRegex = globToRegex(relativePattern);
|
|
556
|
+
const basenameRegex = !relativePattern.includes(path.sep) ? globToRegex(path.basename(globPattern)) : null;
|
|
557
|
+
|
|
558
|
+
return walkFilesRecursive(baseDir)
|
|
559
|
+
.filter((fullPath) => {
|
|
560
|
+
const relativePath = path.relative(baseDir, fullPath).split(path.sep).join("/");
|
|
561
|
+
const baseName = path.basename(fullPath);
|
|
562
|
+
return relativeRegex.test(relativePath) || (basenameRegex?.test(baseName) ?? false);
|
|
563
|
+
})
|
|
564
|
+
.map((fullPath) => ({
|
|
565
|
+
name: path.relative(baseDir, fullPath).split(path.sep).join("/"),
|
|
566
|
+
full: fullPath,
|
|
567
|
+
}));
|
|
568
|
+
}
|
|
569
|
+
|
|
486
570
|
export async function collectContext(sources: ContextSource[], lookbackDays: number): Promise<string> {
|
|
487
571
|
const parts: string[] = [];
|
|
488
572
|
const cutoff = lookbackCutoff(lookbackDays);
|
|
@@ -501,14 +585,7 @@ export async function collectContext(sources: ContextSource[], lookbackDays: num
|
|
|
501
585
|
let candidates: { name: string; full: string }[] = [];
|
|
502
586
|
|
|
503
587
|
if (expanded.includes("*")) {
|
|
504
|
-
|
|
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 {}
|
|
588
|
+
candidates = findMatchingFiles(expanded);
|
|
512
589
|
} else if (fs.existsSync(expanded)) {
|
|
513
590
|
candidates = [{ name: path.basename(expanded), full: expanded }];
|
|
514
591
|
}
|
|
@@ -582,8 +659,20 @@ export function getAvailableSessionDates(): string[] {
|
|
|
582
659
|
|
|
583
660
|
export function buildReflectionPrompt(targetPath: string, targetContent: string, transcripts: string): string {
|
|
584
661
|
const fileName = path.basename(targetPath);
|
|
662
|
+
const charCount = targetContent.length;
|
|
663
|
+
const lineCount = targetContent.split("\n").length;
|
|
585
664
|
return `You are reviewing recent agent session transcripts to improve ${fileName}.
|
|
586
665
|
|
|
666
|
+
## CRITICAL: Conciseness
|
|
667
|
+
|
|
668
|
+
The target file is ${lineCount} lines / ${charCount} chars. Your #1 job is to keep it CONCISE.
|
|
669
|
+
- Every rule should be 1-2 sentences max. If a rule is longer, condense it.
|
|
670
|
+
- Remove session counts, escalating repetition tallies, and "this happened N times" histories — the rule itself is what matters, not how many times it was violated.
|
|
671
|
+
- Remove verbose examples when the rule is self-explanatory.
|
|
672
|
+
- Merge rules that say the same thing in different words.
|
|
673
|
+
- Remove rules that are subsumed by other, better-worded rules.
|
|
674
|
+
- A good rule file is SHORT and scannable. Walls of text get ignored by agents.
|
|
675
|
+
|
|
587
676
|
## Input
|
|
588
677
|
|
|
589
678
|
### Target file: ${fileName}
|
|
@@ -598,58 +687,52 @@ ${transcripts}
|
|
|
598
687
|
|
|
599
688
|
## Step 1: Identify Correction Patterns
|
|
600
689
|
|
|
601
|
-
Read
|
|
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
|
|
690
|
+
Read the transcripts for genuine corrections — user redirecting the agent, expressing frustration, repeating themselves, or correcting approach. Ignore normal flow ("no worries", "actually that looks good").
|
|
609
691
|
|
|
610
|
-
For each
|
|
692
|
+
For each correction: what the agent did wrong, what the user wanted, and which rule (if any) already covers it.
|
|
611
693
|
|
|
612
|
-
|
|
694
|
+
## Step 2: Propose Edits (prioritize conciseness)
|
|
613
695
|
|
|
614
|
-
|
|
696
|
+
Four edit types available:
|
|
697
|
+
1. **strengthen**: Tighten an existing rule's wording (make it clearer/shorter, not longer).
|
|
698
|
+
2. **add**: Add a new rule for a pattern with 2+ occurrences. Keep it to 1-2 sentences.
|
|
699
|
+
3. **remove**: Delete a rule that is redundant (covered by another rule), obsolete, or overly verbose noise.
|
|
700
|
+
4. **merge**: Consolidate 2+ rules that overlap into one concise rule.
|
|
615
701
|
|
|
616
|
-
|
|
617
|
-
-
|
|
618
|
-
-
|
|
619
|
-
-
|
|
620
|
-
-
|
|
621
|
-
-
|
|
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.
|
|
702
|
+
Guidelines:
|
|
703
|
+
- Prefer strengthen/merge/remove over add. The file should get SHORTER or stay the same size, not grow.
|
|
704
|
+
- When strengthening, make the rule SHORTER and CLEARER — don't add history or examples unless essential.
|
|
705
|
+
- Strip "This happened in N sessions", "RECURRING", session dates, escalating violation counts. The rule text is enough.
|
|
706
|
+
- Don't reorganize or restructure the file. Minimal, targeted edits only.
|
|
707
|
+
- Don't add one-off rules. Only patterns with 2+ occurrences.
|
|
624
708
|
|
|
625
709
|
## Step 3: Output
|
|
626
710
|
|
|
627
|
-
IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no
|
|
711
|
+
IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no preamble.
|
|
628
712
|
|
|
629
|
-
For "strengthen"
|
|
630
|
-
For "add"
|
|
631
|
-
|
|
713
|
+
For "strengthen": old_text = COMPLETE bullet/rule copied exactly. new_text = shorter/clearer replacement.
|
|
714
|
+
For "add": after_text = COMPLETE bullet/line copied exactly. new_text = concise new bullet (1-2 sentences).
|
|
715
|
+
For "remove": old_text = COMPLETE bullet/rule to delete. new_text = "" (empty string).
|
|
716
|
+
For "merge": merge_sources = array of COMPLETE bullets to consolidate. new_text = single concise replacement. The merged text replaces the first source; others are removed.
|
|
632
717
|
|
|
633
718
|
{
|
|
634
719
|
"corrections_found": <number>,
|
|
635
720
|
"sessions_with_corrections": <number>,
|
|
636
721
|
"edits": [
|
|
637
722
|
{
|
|
638
|
-
"type": "strengthen" | "add",
|
|
723
|
+
"type": "strengthen" | "add" | "remove" | "merge",
|
|
639
724
|
"section": "which section of the file",
|
|
640
|
-
"old_text": "exact text to find (
|
|
641
|
-
"new_text": "replacement text
|
|
642
|
-
"after_text": "
|
|
643
|
-
"
|
|
725
|
+
"old_text": "exact text to find (strengthen/remove) or null (add/merge)",
|
|
726
|
+
"new_text": "replacement/new text, or empty string for remove",
|
|
727
|
+
"after_text": "insertion point (add only) or null",
|
|
728
|
+
"merge_sources": ["exact text 1", "exact text 2"] or null (merge only),
|
|
729
|
+
"reason": "brief reason for this edit"
|
|
644
730
|
}
|
|
645
731
|
],
|
|
646
732
|
"patterns_not_added": [
|
|
647
|
-
{
|
|
648
|
-
"pattern": "description",
|
|
649
|
-
"reason": "why it wasn't added (one-off, already covered, etc.)"
|
|
650
|
-
}
|
|
733
|
+
{ "pattern": "description", "reason": "why not added" }
|
|
651
734
|
],
|
|
652
|
-
"summary": "2-3 sentence summary
|
|
735
|
+
"summary": "2-3 sentence summary"
|
|
653
736
|
}`;
|
|
654
737
|
}
|
|
655
738
|
|
|
@@ -718,6 +801,56 @@ export function applyEdits(content: string, edits: AnalysisEdit[]): EditResult {
|
|
|
718
801
|
|
|
719
802
|
result = result.replace(edit.after_text, edit.after_text + "\n" + edit.new_text);
|
|
720
803
|
applied++;
|
|
804
|
+
} else if (edit.type === "remove" && edit.old_text) {
|
|
805
|
+
if (!result.includes(edit.old_text)) {
|
|
806
|
+
skipped.push(`Could not find text to remove: "${edit.old_text.slice(0, 80)}..."`);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const firstIdx = result.indexOf(edit.old_text);
|
|
811
|
+
const secondIdx = result.indexOf(edit.old_text, firstIdx + 1);
|
|
812
|
+
if (secondIdx !== -1) {
|
|
813
|
+
skipped.push(`Ambiguous match for removal (appears multiple times): "${edit.old_text.slice(0, 80)}..."`);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Remove the text and any trailing blank line
|
|
818
|
+
result = result.replace(edit.old_text + "\n", "");
|
|
819
|
+
if (result.includes(edit.old_text)) {
|
|
820
|
+
result = result.replace(edit.old_text, "");
|
|
821
|
+
}
|
|
822
|
+
applied++;
|
|
823
|
+
} else if (edit.type === "merge" && edit.merge_sources && edit.merge_sources.length > 0 && edit.new_text) {
|
|
824
|
+
// Remove all source texts, then insert the consolidated new_text where the first source was
|
|
825
|
+
let firstSourceIdx = Infinity;
|
|
826
|
+
let firstSourceText = "";
|
|
827
|
+
let allFound = true;
|
|
828
|
+
|
|
829
|
+
for (const src of edit.merge_sources) {
|
|
830
|
+
if (!result.includes(src)) {
|
|
831
|
+
skipped.push(`Merge source not found: "${src.slice(0, 80)}..."`);
|
|
832
|
+
allFound = false;
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
const idx = result.indexOf(src);
|
|
836
|
+
if (idx < firstSourceIdx) {
|
|
837
|
+
firstSourceIdx = idx;
|
|
838
|
+
firstSourceText = src;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
if (!allFound) continue;
|
|
842
|
+
|
|
843
|
+
// Replace the first source with the merged text
|
|
844
|
+
result = result.replace(firstSourceText, edit.new_text);
|
|
845
|
+
// Remove the remaining sources
|
|
846
|
+
for (const src of edit.merge_sources) {
|
|
847
|
+
if (src === firstSourceText) continue;
|
|
848
|
+
result = result.replace(src + "\n", "");
|
|
849
|
+
if (result.includes(src)) {
|
|
850
|
+
result = result.replace(src, "");
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
applied++;
|
|
721
854
|
} else {
|
|
722
855
|
skipped.push(`Invalid edit: ${JSON.stringify(edit).slice(0, 100)}`);
|
|
723
856
|
}
|
|
@@ -726,8 +859,170 @@ export function applyEdits(content: string, edits: AnalysisEdit[]): EditResult {
|
|
|
726
859
|
return { result, applied, skipped };
|
|
727
860
|
}
|
|
728
861
|
|
|
862
|
+
// --- Batch helpers ---
|
|
863
|
+
|
|
864
|
+
/** Split sessions into batches that each fit within maxBytes */
|
|
865
|
+
export function buildTranscriptBatches(sessions: SessionData[], maxBytes: number): string[][] {
|
|
866
|
+
const batches: string[][] = [];
|
|
867
|
+
let currentBatch: string[] = [];
|
|
868
|
+
let currentSize = 0;
|
|
869
|
+
|
|
870
|
+
for (const sd of sessions) {
|
|
871
|
+
const entry = sd.transcript + "\n---\n\n";
|
|
872
|
+
if (currentSize + entry.length > maxBytes && currentBatch.length > 0) {
|
|
873
|
+
batches.push(currentBatch);
|
|
874
|
+
currentBatch = [];
|
|
875
|
+
currentSize = 0;
|
|
876
|
+
}
|
|
877
|
+
currentBatch.push(entry);
|
|
878
|
+
currentSize += entry.length;
|
|
879
|
+
}
|
|
880
|
+
if (currentBatch.length > 0) {
|
|
881
|
+
batches.push(currentBatch);
|
|
882
|
+
}
|
|
883
|
+
return batches;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function formatBatchTranscripts(parts: string[], batchIndex: number, totalBatches: number, totalSessions: number): string {
|
|
887
|
+
const header =
|
|
888
|
+
`# Session Transcripts (batch ${batchIndex + 1}/${totalBatches})\n` +
|
|
889
|
+
`# ${parts.length} sessions in this batch, ${totalSessions} total\n\n`;
|
|
890
|
+
return header + parts.join("");
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
interface AnalysisResult {
|
|
894
|
+
edits: AnalysisEdit[];
|
|
895
|
+
correctionsFound: number;
|
|
896
|
+
sessionsWithCorrections: number;
|
|
897
|
+
summary: string;
|
|
898
|
+
patternsNotAdded?: any[];
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Run a single LLM analysis call on one batch of transcripts */
|
|
902
|
+
export function buildReflectAnalysisTool() {
|
|
903
|
+
return {
|
|
904
|
+
name: "submit_analysis",
|
|
905
|
+
description: "Submit the reflection analysis results",
|
|
906
|
+
parameters: {
|
|
907
|
+
type: "object" as const,
|
|
908
|
+
properties: {
|
|
909
|
+
corrections_found: { type: "number", description: "Number of facts/rules added, updated, or removed" },
|
|
910
|
+
sessions_with_corrections: { type: "number", description: "Number of conversations containing new facts or corrections" },
|
|
911
|
+
edits: {
|
|
912
|
+
type: "array",
|
|
913
|
+
items: {
|
|
914
|
+
type: "object",
|
|
915
|
+
properties: {
|
|
916
|
+
type: { type: "string", enum: ["strengthen", "add", "remove", "merge"], description: "strengthen = update existing text, add = insert new text, remove = delete redundant text, merge = consolidate multiple rules into one" },
|
|
917
|
+
section: { type: "string", description: "Which section of the file" },
|
|
918
|
+
old_text: { type: "string", description: "Exact text to find for strengthen or remove; omit otherwise" },
|
|
919
|
+
new_text: { type: "string", description: "Replacement/new text, or empty string for remove" },
|
|
920
|
+
after_text: { type: "string", description: "Exact insertion point for add; omit otherwise" },
|
|
921
|
+
merge_sources: { type: "array", items: { type: "string" }, description: "Exact source texts for merge; omit otherwise" },
|
|
922
|
+
reason: { type: "string", description: "Brief reason for this edit" },
|
|
923
|
+
},
|
|
924
|
+
required: ["type", "new_text"],
|
|
925
|
+
},
|
|
926
|
+
},
|
|
927
|
+
patterns_not_added: {
|
|
928
|
+
type: "array",
|
|
929
|
+
items: {
|
|
930
|
+
type: "object",
|
|
931
|
+
properties: {
|
|
932
|
+
pattern: { type: "string" },
|
|
933
|
+
reason: { type: "string" },
|
|
934
|
+
},
|
|
935
|
+
},
|
|
936
|
+
},
|
|
937
|
+
summary: { type: "string", description: "2-3 sentence summary of what was added/updated" },
|
|
938
|
+
},
|
|
939
|
+
required: ["corrections_found", "sessions_with_corrections", "edits", "summary"],
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function analyzeTranscriptBatch(
|
|
945
|
+
target: ReflectTarget,
|
|
946
|
+
targetPath: string,
|
|
947
|
+
targetContent: string,
|
|
948
|
+
transcripts: string,
|
|
949
|
+
context: string,
|
|
950
|
+
model: any,
|
|
951
|
+
apiKey: string,
|
|
952
|
+
headers: Record<string, string> | undefined,
|
|
953
|
+
modelLabel: string,
|
|
954
|
+
notify: NotifyFn,
|
|
955
|
+
completeFn: (model: any, request: any, options: any) => Promise<any>,
|
|
956
|
+
): Promise<AnalysisResult | null> {
|
|
957
|
+
const prompt = buildPromptForTarget(target, targetPath, targetContent, transcripts, context);
|
|
958
|
+
|
|
959
|
+
const reflectAnalysisTool = buildReflectAnalysisTool();
|
|
960
|
+
|
|
961
|
+
const response = await completeFn(model, {
|
|
962
|
+
systemPrompt: "You are a behavioral analysis tool that prioritizes CONCISENESS. Your goal is to keep the target file short and scannable — prefer merging, removing, and tightening rules over adding new ones. The file should get shorter or stay the same size, not grow. Analyze the session transcripts and call the submit_analysis tool with your results. Always call the tool — never respond with plain text.",
|
|
963
|
+
messages: [
|
|
964
|
+
{
|
|
965
|
+
role: "user" as const,
|
|
966
|
+
content: [{ type: "text" as const, text: prompt }],
|
|
967
|
+
timestamp: Date.now(),
|
|
968
|
+
},
|
|
969
|
+
],
|
|
970
|
+
tools: [reflectAnalysisTool],
|
|
971
|
+
}, { apiKey, headers, maxTokens: 16384 });
|
|
972
|
+
|
|
973
|
+
if (response.stopReason === "error") {
|
|
974
|
+
notify(`LLM error: ${response.errorMessage ?? 'unknown'}`, "error");
|
|
975
|
+
return null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
let analysis: any;
|
|
979
|
+
const toolCall = response.content.find((c: any) => c.type === "toolCall" && c.name === "submit_analysis");
|
|
980
|
+
if (toolCall && (toolCall as any).arguments) {
|
|
981
|
+
analysis = (toolCall as any).arguments;
|
|
982
|
+
} else {
|
|
983
|
+
const responseText = response.content
|
|
984
|
+
.filter((c: any) => c.type === "text")
|
|
985
|
+
.map((c: any) => c.text)
|
|
986
|
+
.join("")
|
|
987
|
+
.trim();
|
|
988
|
+
try {
|
|
989
|
+
const jsonStr = responseText.replace(/^```json?\s*\n?/m, "").replace(/\n?```\s*$/m, "");
|
|
990
|
+
analysis = JSON.parse(jsonStr);
|
|
991
|
+
} catch {
|
|
992
|
+
notify(`Failed to parse LLM response as JSON. Raw response:\n${responseText.slice(0, 500)}`, "error");
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return {
|
|
998
|
+
edits: analysis.edits ?? [],
|
|
999
|
+
correctionsFound: analysis.corrections_found ?? 0,
|
|
1000
|
+
sessionsWithCorrections: analysis.sessions_with_corrections ?? 0,
|
|
1001
|
+
summary: analysis.summary ?? "",
|
|
1002
|
+
patternsNotAdded: analysis.patterns_not_added,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
729
1006
|
// --- Main reflection logic ---
|
|
730
1007
|
|
|
1008
|
+
export async function resolveModelAuth(
|
|
1009
|
+
modelRegistry: any,
|
|
1010
|
+
model: any,
|
|
1011
|
+
): Promise<{ ok: true; apiKey?: string; headers?: Record<string, string> } | { ok: false; error?: string }> {
|
|
1012
|
+
if (!modelRegistry) return { ok: false, error: "model registry unavailable" };
|
|
1013
|
+
if (typeof modelRegistry.getApiKeyAndHeaders === "function") {
|
|
1014
|
+
const auth = await modelRegistry.getApiKeyAndHeaders(model);
|
|
1015
|
+
return auth?.ok
|
|
1016
|
+
? { ok: true, apiKey: auth.apiKey, headers: auth.headers }
|
|
1017
|
+
: { ok: false, error: auth?.error };
|
|
1018
|
+
}
|
|
1019
|
+
if (typeof modelRegistry.getApiKey === "function") {
|
|
1020
|
+
const apiKey = await modelRegistry.getApiKey(model);
|
|
1021
|
+
return apiKey ? { ok: true, apiKey } : { ok: false, error: "No API key" };
|
|
1022
|
+
}
|
|
1023
|
+
return { ok: false, error: "unsupported model registry" };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
731
1026
|
export interface RunReflectionDeps {
|
|
732
1027
|
completeSimple: (model: any, request: any, options: any) => Promise<any>;
|
|
733
1028
|
getModel: (provider: string, modelId: string) => any;
|
|
@@ -759,9 +1054,11 @@ export async function runReflection(
|
|
|
759
1054
|
let transcripts: string;
|
|
760
1055
|
let sessionCount = 0;
|
|
761
1056
|
let includedCount = 0;
|
|
1057
|
+
let allSessions: SessionData[] | undefined;
|
|
762
1058
|
|
|
763
1059
|
if (options?.transcriptsOverride) {
|
|
764
1060
|
({ transcripts, sessionCount, includedCount } = options.transcriptsOverride);
|
|
1061
|
+
allSessions = options.transcriptsOverride.sessions;
|
|
765
1062
|
} else if (target.transcripts && target.transcripts.length > 0) {
|
|
766
1063
|
// New array-based transcript sources
|
|
767
1064
|
notify(`Extracting transcripts from ${target.transcripts.length} source(s) (last ${target.lookbackDays} day(s))...`, "info");
|
|
@@ -779,6 +1076,7 @@ export async function runReflection(
|
|
|
779
1076
|
target.maxSessionBytes,
|
|
780
1077
|
);
|
|
781
1078
|
({ transcripts, sessionCount, includedCount } = result);
|
|
1079
|
+
allSessions = result.sessions;
|
|
782
1080
|
} else {
|
|
783
1081
|
notify(`Extracting transcripts (last ${target.lookbackDays} day(s))...`, "info");
|
|
784
1082
|
const fn = deps?.collectTranscriptsFn ?? collectTranscripts;
|
|
@@ -787,6 +1085,7 @@ export async function runReflection(
|
|
|
787
1085
|
target.maxSessionBytes,
|
|
788
1086
|
);
|
|
789
1087
|
({ transcripts, sessionCount, includedCount } = result);
|
|
1088
|
+
allSessions = result.sessions;
|
|
790
1089
|
}
|
|
791
1090
|
|
|
792
1091
|
if (!transcripts || includedCount === 0) {
|
|
@@ -794,25 +1093,58 @@ export async function runReflection(
|
|
|
794
1093
|
return null;
|
|
795
1094
|
}
|
|
796
1095
|
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
const [provider, modelId] = target.model.split("/", 2);
|
|
802
|
-
let model = getModelFn(provider as any, modelId as any);
|
|
1096
|
+
// Use total session count from allSessions if available (includes sessions that didn't fit the budget)
|
|
1097
|
+
const totalSessionCount = allSessions ? allSessions.length : includedCount;
|
|
1098
|
+
const totalBytes = allSessions ? allSessions.reduce((sum, s) => sum + s.size, 0) : transcripts.length;
|
|
1099
|
+
notify(`Extracted ${totalSessionCount} sessions (${sessionCount} scanned, ${(totalBytes / 1024).toFixed(0)}KB)`, "info");
|
|
803
1100
|
|
|
804
|
-
if
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
1101
|
+
// Dedup: skip if we already reflected on this target+sourceDate
|
|
1102
|
+
if (!options?.sourceDateOverride) {
|
|
1103
|
+
const sourceDate = new Date();
|
|
1104
|
+
sourceDate.setDate(sourceDate.getDate() - target.lookbackDays);
|
|
1105
|
+
const sourceDateStr = sourceDate.toISOString().slice(0, 10);
|
|
1106
|
+
const history = loadHistory();
|
|
1107
|
+
const alreadyRan = history.some(
|
|
1108
|
+
(r) => r.targetPath === targetPath && r.sourceDate === sourceDateStr,
|
|
1109
|
+
);
|
|
1110
|
+
if (alreadyRan) {
|
|
1111
|
+
notify(`Already reflected on ${sourceDateStr} for this target — skipping`, "info");
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
810
1114
|
}
|
|
811
1115
|
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
1116
|
+
// Resolve model — prefer current session model over target.model config
|
|
1117
|
+
let model: any;
|
|
1118
|
+
let apiKey: string | undefined;
|
|
1119
|
+
let headers: Record<string, string> | undefined;
|
|
1120
|
+
let modelLabel: string;
|
|
1121
|
+
|
|
1122
|
+
if (options?.currentModel && options?.currentModelApiKey) {
|
|
1123
|
+
model = options.currentModel;
|
|
1124
|
+
apiKey = options.currentModelApiKey;
|
|
1125
|
+
headers = options.currentModelHeaders;
|
|
1126
|
+
modelLabel = `${model.provider}/${model.id}`;
|
|
1127
|
+
} else {
|
|
1128
|
+
const getModelFn = deps?.getModel ?? (await import("@mariozechner/pi-ai")).getModel;
|
|
1129
|
+
const [provider, modelId] = target.model.split("/", 2);
|
|
1130
|
+
model = getModelFn(provider as any, modelId as any);
|
|
1131
|
+
|
|
1132
|
+
if (!model) {
|
|
1133
|
+
model = modelRegistry?.find(provider, modelId);
|
|
1134
|
+
}
|
|
1135
|
+
if (!model) {
|
|
1136
|
+
notify(`Model not found: ${target.model}`, "error");
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
const auth = await resolveModelAuth(modelRegistry, model);
|
|
1141
|
+
if (!auth.ok) {
|
|
1142
|
+
notify(`No API key for model: ${target.model}${auth?.error ? ` (${auth.error})` : ''}`, "error");
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
apiKey = auth.apiKey;
|
|
1146
|
+
headers = auth.headers;
|
|
1147
|
+
modelLabel = target.model;
|
|
816
1148
|
}
|
|
817
1149
|
|
|
818
1150
|
// Collect additional context
|
|
@@ -825,41 +1157,71 @@ export async function runReflection(
|
|
|
825
1157
|
}
|
|
826
1158
|
}
|
|
827
1159
|
|
|
828
|
-
// Build
|
|
829
|
-
notify(`Analyzing with ${target.model}...`, "info");
|
|
830
|
-
const prompt = buildPromptForTarget(target, targetPath, targetContent, transcripts, context);
|
|
831
|
-
|
|
1160
|
+
// Build batches and call LLM
|
|
832
1161
|
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
1162
|
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1163
|
+
// Determine if we need multiple batches
|
|
1164
|
+
// Reserve space for target file, system prompt, tool schema, and context
|
|
1165
|
+
const overhead = targetContent.length + (context?.length ?? 0) + 20_000; // 20KB for prompt/tool schema
|
|
1166
|
+
const batchBudget = Math.max(target.maxSessionBytes - overhead, 100_000); // at least 100KB per batch
|
|
1167
|
+
const needsBatching = allSessions && allSessions.length > 0 && totalBytes > batchBudget;
|
|
1168
|
+
let allEdits: AnalysisEdit[] = [];
|
|
1169
|
+
let totalCorrectionsFound = 0;
|
|
1170
|
+
let allSummaries: string[] = [];
|
|
1171
|
+
|
|
1172
|
+
if (needsBatching) {
|
|
1173
|
+
const batches = buildTranscriptBatches(allSessions!, batchBudget);
|
|
1174
|
+
notify(`Sessions exceed context budget — splitting into ${batches.length} batches`, "info");
|
|
1175
|
+
|
|
1176
|
+
for (let i = 0; i < batches.length; i++) {
|
|
1177
|
+
const batchTranscripts = formatBatchTranscripts(batches[i], i, batches.length, totalSessionCount);
|
|
1178
|
+
// Re-read target content for each batch so later batches see earlier edits
|
|
1179
|
+
const currentContent = i === 0 ? targetContent : fs.readFileSync(targetPath, "utf-8");
|
|
1180
|
+
notify(`Analyzing batch ${i + 1}/${batches.length} (${batches[i].length} sessions, ${(batchTranscripts.length / 1024).toFixed(0)}KB) with ${modelLabel}...`, "info");
|
|
1181
|
+
|
|
1182
|
+
const result = await analyzeTranscriptBatch(
|
|
1183
|
+
target, targetPath, currentContent, batchTranscripts, context,
|
|
1184
|
+
model, apiKey!, headers, modelLabel, notify, completeFn,
|
|
1185
|
+
);
|
|
1186
|
+
|
|
1187
|
+
if (!result) continue;
|
|
1188
|
+
|
|
1189
|
+
allEdits.push(...result.edits);
|
|
1190
|
+
totalCorrectionsFound += result.correctionsFound;
|
|
1191
|
+
if (result.summary) allSummaries.push(result.summary);
|
|
1192
|
+
|
|
1193
|
+
// Apply this batch's edits immediately so the next batch sees the updated file
|
|
1194
|
+
if (result.edits.length > 0) {
|
|
1195
|
+
const currentForApply = fs.readFileSync(targetPath, "utf-8");
|
|
1196
|
+
const { result: updated, applied } = applyEdits(currentForApply, result.edits);
|
|
1197
|
+
if (applied > 0) {
|
|
1198
|
+
// Backup before first edit
|
|
1199
|
+
if (i === 0) {
|
|
1200
|
+
const bkDir = resolvePath(target.backupDir);
|
|
1201
|
+
fs.mkdirSync(bkDir, { recursive: true });
|
|
1202
|
+
const bkPath = path.join(bkDir, `${path.basename(targetPath, ".md")}_${formatTimestamp()}.md`);
|
|
1203
|
+
fs.copyFileSync(targetPath, bkPath);
|
|
1204
|
+
}
|
|
1205
|
+
fs.writeFileSync(targetPath, updated, "utf-8");
|
|
1206
|
+
notify(`Batch ${i + 1}: applied ${applied} edit(s)`, "info");
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
} else {
|
|
1211
|
+
notify(`Analyzing with ${modelLabel}...`, "info");
|
|
1212
|
+
const result = await analyzeTranscriptBatch(
|
|
1213
|
+
target, targetPath, targetContent, transcripts, context,
|
|
1214
|
+
model, apiKey!, headers, modelLabel, notify, completeFn,
|
|
1215
|
+
);
|
|
1216
|
+
if (!result) return null;
|
|
1217
|
+
allEdits = result.edits;
|
|
1218
|
+
totalCorrectionsFound = result.correctionsFound;
|
|
1219
|
+
if (result.summary) allSummaries.push(result.summary);
|
|
858
1220
|
}
|
|
859
1221
|
|
|
860
|
-
const edits
|
|
861
|
-
const correctionsFound =
|
|
862
|
-
const correctionRate =
|
|
1222
|
+
const edits = allEdits;
|
|
1223
|
+
const correctionsFound = totalCorrectionsFound;
|
|
1224
|
+
const correctionRate = totalSessionCount > 0 ? correctionsFound / totalSessionCount : 0;
|
|
863
1225
|
|
|
864
1226
|
// sourceDate = the date of sessions being analyzed, not when reflect ran
|
|
865
1227
|
let sourceDateStr: string;
|
|
@@ -871,19 +1233,22 @@ export async function runReflection(
|
|
|
871
1233
|
sourceDateStr = sourceDate.toISOString().slice(0, 10);
|
|
872
1234
|
}
|
|
873
1235
|
|
|
1236
|
+
const combinedSummary = allSummaries.join(" ") || `${edits.length} edits from ${totalSessionCount} sessions.`;
|
|
1237
|
+
|
|
874
1238
|
if (edits.length === 0) {
|
|
875
|
-
notify(`No edits needed. ${
|
|
1239
|
+
notify(`No edits needed. ${combinedSummary}`, "info");
|
|
876
1240
|
return {
|
|
877
1241
|
timestamp: new Date().toISOString(),
|
|
878
1242
|
targetPath,
|
|
879
|
-
sessionsAnalyzed:
|
|
1243
|
+
sessionsAnalyzed: totalSessionCount,
|
|
880
1244
|
correctionsFound,
|
|
881
1245
|
editsApplied: 0,
|
|
882
|
-
summary:
|
|
1246
|
+
summary: combinedSummary,
|
|
883
1247
|
diffLines: 0,
|
|
884
1248
|
correctionRate,
|
|
885
1249
|
edits: [],
|
|
886
1250
|
sourceDate: sourceDateStr,
|
|
1251
|
+
fileSize: computeFileMetrics(fs.readFileSync(targetPath, "utf-8")),
|
|
887
1252
|
};
|
|
888
1253
|
}
|
|
889
1254
|
|
|
@@ -897,98 +1262,107 @@ export async function runReflection(
|
|
|
897
1262
|
reason: e.reason,
|
|
898
1263
|
}));
|
|
899
1264
|
|
|
900
|
-
|
|
901
|
-
notify(`[dry run] ${summary}`, "info");
|
|
1265
|
+
notify(`[dry run] ${combinedSummary}`, "info");
|
|
902
1266
|
|
|
903
1267
|
return {
|
|
904
1268
|
timestamp: new Date().toISOString(),
|
|
905
1269
|
targetPath,
|
|
906
|
-
sessionsAnalyzed:
|
|
1270
|
+
sessionsAnalyzed: totalSessionCount,
|
|
907
1271
|
correctionsFound,
|
|
908
1272
|
editsApplied: 0,
|
|
909
|
-
summary,
|
|
1273
|
+
summary: combinedSummary,
|
|
910
1274
|
diffLines: 0,
|
|
911
1275
|
correctionRate,
|
|
912
1276
|
edits: editRecords,
|
|
913
1277
|
sourceDate: sourceDateStr,
|
|
1278
|
+
fileSize: computeFileMetrics(fs.readFileSync(targetPath, "utf-8")),
|
|
914
1279
|
};
|
|
915
1280
|
}
|
|
916
1281
|
|
|
917
|
-
//
|
|
1282
|
+
// Apply edits
|
|
918
1283
|
const backupDir = resolvePath(target.backupDir);
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
1284
|
+
let totalApplied = 0;
|
|
1285
|
+
|
|
1286
|
+
if (needsBatching) {
|
|
1287
|
+
// Batched: edits were already applied inline in the loop above.
|
|
1288
|
+
// Count total applied from the diff.
|
|
1289
|
+
const finalContent = fs.readFileSync(targetPath, "utf-8");
|
|
1290
|
+
const origLines = targetContent.split("\n");
|
|
1291
|
+
const finalLines = finalContent.split("\n");
|
|
1292
|
+
for (let i = 0; i < Math.max(origLines.length, finalLines.length); i++) {
|
|
1293
|
+
if (origLines[i] !== finalLines[i]) totalApplied++;
|
|
1294
|
+
}
|
|
1295
|
+
// Use edit count as applied since we tracked them per-batch
|
|
1296
|
+
totalApplied = edits.length; // best estimate — individual batch applied counts were logged
|
|
1297
|
+
} else {
|
|
1298
|
+
// Single batch — backup and apply
|
|
1299
|
+
fs.mkdirSync(backupDir, { recursive: true });
|
|
1300
|
+
const backupPath = path.join(backupDir, `${path.basename(targetPath, ".md")}_${formatTimestamp()}.md`);
|
|
1301
|
+
fs.copyFileSync(targetPath, backupPath);
|
|
922
1302
|
|
|
923
|
-
|
|
924
|
-
const { result, applied, skipped } = applyEdits(targetContent, edits);
|
|
1303
|
+
const { result, applied, skipped } = applyEdits(targetContent, edits);
|
|
925
1304
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1305
|
+
if (applied === 0) {
|
|
1306
|
+
notify(`All ${edits.length} edits failed to apply. Skipped: ${skipped.join("; ")}`, "warning");
|
|
1307
|
+
try { fs.unlinkSync(backupPath); } catch {}
|
|
1308
|
+
return null;
|
|
1309
|
+
}
|
|
931
1310
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
}
|
|
1311
|
+
if (result.length < targetContent.length * 0.5) {
|
|
1312
|
+
notify(`Result is suspiciously small (${result.length} vs ${targetContent.length} bytes). Aborting.`, "error");
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
937
1315
|
|
|
938
|
-
|
|
1316
|
+
fs.writeFileSync(targetPath, result, "utf-8");
|
|
1317
|
+
totalApplied = applied;
|
|
939
1318
|
|
|
940
|
-
|
|
1319
|
+
if (skipped.length > 0) {
|
|
1320
|
+
notify(`Applied ${applied}/${edits.length} edits (${skipped.length} skipped). Backup: ${backupPath}`, "warning");
|
|
1321
|
+
} else {
|
|
1322
|
+
notify(`Applied ${applied} edit(s). Backup: ${backupPath}`, "info");
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// Compute final diff
|
|
1327
|
+
const finalContent = fs.readFileSync(targetPath, "utf-8");
|
|
941
1328
|
const originalLines = targetContent.split("\n");
|
|
942
|
-
const resultLines =
|
|
1329
|
+
const resultLines = finalContent.split("\n");
|
|
943
1330
|
let diffLines = 0;
|
|
944
1331
|
const maxLen = Math.max(originalLines.length, resultLines.length);
|
|
945
1332
|
for (let i = 0; i < maxLen; i++) {
|
|
946
1333
|
if (originalLines[i] !== resultLines[i]) diffLines++;
|
|
947
1334
|
}
|
|
948
1335
|
|
|
949
|
-
|
|
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");
|
|
1336
|
+
notify(combinedSummary, "info");
|
|
957
1337
|
|
|
958
|
-
//
|
|
1338
|
+
// Git commit
|
|
959
1339
|
try {
|
|
960
1340
|
const realPath = fs.realpathSync(targetPath);
|
|
961
1341
|
const repoDir = path.dirname(realPath);
|
|
962
1342
|
if (fs.existsSync(path.join(repoDir, ".git"))) {
|
|
963
1343
|
const { execFileSync } = require("node:child_process");
|
|
964
1344
|
execFileSync("git", ["add", "-A"], { cwd: repoDir, stdio: "ignore", timeout: 5000 });
|
|
965
|
-
execFileSync("git", ["commit", "-m", `reflect: ${path.basename(realPath)} — ${
|
|
1345
|
+
execFileSync("git", ["commit", "-m", `reflect: ${path.basename(realPath)} — ${totalApplied} edits from ${totalSessionCount} sessions`, "--no-verify"], { cwd: repoDir, stdio: "ignore", timeout: 5000 });
|
|
966
1346
|
notify(`Committed to ${path.basename(repoDir)}`, "info");
|
|
967
1347
|
}
|
|
968
|
-
} catch {
|
|
969
|
-
// Not in a git repo or commit failed — that's fine
|
|
970
|
-
}
|
|
1348
|
+
} catch {}
|
|
971
1349
|
|
|
972
|
-
// Extract per-edit detail for recidivism tracking
|
|
973
1350
|
const editRecords: EditRecord[] = edits
|
|
974
1351
|
.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
|
-
}));
|
|
1352
|
+
.map((e: any) => ({ type: e.type ?? "add", section: e.section, reason: e.reason }));
|
|
980
1353
|
|
|
981
1354
|
return {
|
|
982
1355
|
timestamp: new Date().toISOString(),
|
|
983
1356
|
targetPath,
|
|
984
|
-
sessionsAnalyzed:
|
|
1357
|
+
sessionsAnalyzed: totalSessionCount,
|
|
985
1358
|
correctionsFound,
|
|
986
|
-
editsApplied:
|
|
987
|
-
summary,
|
|
1359
|
+
editsApplied: totalApplied,
|
|
1360
|
+
summary: combinedSummary,
|
|
988
1361
|
diffLines,
|
|
989
1362
|
correctionRate,
|
|
990
1363
|
edits: editRecords,
|
|
991
1364
|
sourceDate: sourceDateStr,
|
|
1365
|
+
fileSize: computeFileMetrics(fs.readFileSync(targetPath, "utf-8")),
|
|
992
1366
|
};
|
|
993
1367
|
}
|
|
994
1368
|
|