@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.
@@ -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
- 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 {}
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 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
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 real correction, note: what the agent did wrong, what the user wanted, and which rule in ${fileName} (if any) already covers this.
692
+ For each correction: what the agent did wrong, what the user wanted, and which rule (if any) already covers it.
611
693
 
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.
694
+ ## Step 2: Propose Edits (prioritize conciseness)
613
695
 
614
- ## Step 2: Propose Edits
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
- 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.
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 explanation, no preamble. Start with { and end with }.
711
+ IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no preamble.
628
712
 
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.
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 (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"
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 of what was found and changed"
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
- 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);
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 (!model) {
805
- model = modelRegistry?.find(provider, modelId);
806
- }
807
- if (!model) {
808
- notify(`Model not found: ${target.model}`, "error");
809
- return null;
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
- const apiKey = await modelRegistry?.getApiKey(model);
813
- if (!apiKey) {
814
- notify(`No API key for model: ${target.model}`, "error");
815
- return null;
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 prompt and call LLM
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
- // 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;
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: AnalysisEdit[] = analysis.edits ?? [];
861
- const correctionsFound = analysis.corrections_found ?? 0;
862
- const correctionRate = includedCount > 0 ? correctionsFound / includedCount : 0;
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. ${analysis.summary ?? ""}`, "info");
1239
+ notify(`No edits needed. ${combinedSummary}`, "info");
876
1240
  return {
877
1241
  timestamp: new Date().toISOString(),
878
1242
  targetPath,
879
- sessionsAnalyzed: includedCount,
1243
+ sessionsAnalyzed: totalSessionCount,
880
1244
  correctionsFound,
881
1245
  editsApplied: 0,
882
- summary: analysis.summary ?? "No edits needed.",
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
- const summary = analysis.summary ?? `${edits.length} edits proposed (dry run).`;
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: includedCount,
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
- // Backup before editing
1282
+ // Apply edits
918
1283
  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);
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
- // Apply edits with safety checks
924
- const { result, applied, skipped } = applyEdits(targetContent, edits);
1303
+ const { result, applied, skipped } = applyEdits(targetContent, edits);
925
1304
 
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
- }
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
- // 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
- }
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
- fs.writeFileSync(targetPath, result, "utf-8");
1316
+ fs.writeFileSync(targetPath, result, "utf-8");
1317
+ totalApplied = applied;
939
1318
 
940
- // Count changed lines
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 = result.split("\n");
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
- 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");
1336
+ notify(combinedSummary, "info");
957
1337
 
958
- // If target is in a git repo, commit the change
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)} — ${applied} edits from ${includedCount} sessions`, "--no-verify"], { cwd: repoDir, stdio: "ignore", timeout: 5000 });
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: includedCount,
1357
+ sessionsAnalyzed: totalSessionCount,
985
1358
  correctionsFound,
986
- editsApplied: applied,
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