@askjo/pi-reflect 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -82,8 +82,10 @@ export default function (pi: ExtensionAPI) {
82
82
  "Which target?",
83
83
  config.targets.map((t) => path.basename(t.path)),
84
84
  );
85
- if (choice === undefined) return;
86
- target = config.targets[choice];
85
+ if (choice === undefined || choice === null) return;
86
+ const chosenTarget = config.targets.find((t) => path.basename(t.path) === choice);
87
+ if (!chosenTarget) return;
88
+ target = chosenTarget;
87
89
  } else {
88
90
  target = config.targets[0];
89
91
  }
@@ -93,7 +95,21 @@ export default function (pi: ExtensionAPI) {
93
95
  ? (msg, level) => ctx.ui.notify(msg, level)
94
96
  : (msg, level) => console.log(`[reflect] [${level}] ${msg}`);
95
97
 
96
- const run = await runReflection(target, modelRegistryRef, notify);
98
+ // Use the current session model if available
99
+ let currentModel: any;
100
+ let currentModelApiKey: string | undefined;
101
+ if (ctx.model) {
102
+ const key = await ctx.modelRegistry.getApiKey(ctx.model);
103
+ if (key) {
104
+ currentModel = ctx.model;
105
+ currentModelApiKey = key;
106
+ }
107
+ }
108
+
109
+ const run = await runReflection(target, modelRegistryRef, notify, undefined, {
110
+ currentModel,
111
+ currentModelApiKey,
112
+ });
97
113
 
98
114
  if (run) {
99
115
  const history = loadHistory();
@@ -101,6 +101,8 @@ export interface TranscriptResult {
101
101
  transcripts: string;
102
102
  sessionCount: number;
103
103
  includedCount: number;
104
+ /** Individual sessions for chunked processing when transcripts exceed context budget */
105
+ sessions?: SessionData[];
104
106
  }
105
107
 
106
108
  export interface EditResult {
@@ -122,6 +124,9 @@ export interface ReflectionOptions {
122
124
  sourceDateOverride?: string;
123
125
  transcriptsOverride?: TranscriptResult;
124
126
  dryRun?: boolean;
127
+ /** Override model — use the current session model instead of target.model */
128
+ currentModel?: any;
129
+ currentModelApiKey?: string;
125
130
  }
126
131
 
127
132
  export type NotifyFn = (msg: string, level: "info" | "warning" | "error") => void;
@@ -395,7 +400,7 @@ async function collectSessionsForDates(
395
400
  }
396
401
 
397
402
  if (allSessions.length === 0) {
398
- return { transcripts: "", sessionCount: totalScanned, includedCount: 0 };
403
+ return { transcripts: "", sessionCount: totalScanned, includedCount: 0, sessions: [] };
399
404
  }
400
405
 
401
406
  allSessions.sort((a, b) => {
@@ -426,6 +431,7 @@ async function collectSessionsForDates(
426
431
  transcripts: header + parts.join(""),
427
432
  sessionCount: totalScanned,
428
433
  includedCount: included,
434
+ sessions: allSessions,
429
435
  };
430
436
  }
431
437
 
@@ -726,6 +732,144 @@ export function applyEdits(content: string, edits: AnalysisEdit[]): EditResult {
726
732
  return { result, applied, skipped };
727
733
  }
728
734
 
735
+ // --- Batch helpers ---
736
+
737
+ /** Split sessions into batches that each fit within maxBytes */
738
+ export function buildTranscriptBatches(sessions: SessionData[], maxBytes: number): string[][] {
739
+ const batches: string[][] = [];
740
+ let currentBatch: string[] = [];
741
+ let currentSize = 0;
742
+
743
+ for (const sd of sessions) {
744
+ const entry = sd.transcript + "\n---\n\n";
745
+ if (currentSize + entry.length > maxBytes && currentBatch.length > 0) {
746
+ batches.push(currentBatch);
747
+ currentBatch = [];
748
+ currentSize = 0;
749
+ }
750
+ currentBatch.push(entry);
751
+ currentSize += entry.length;
752
+ }
753
+ if (currentBatch.length > 0) {
754
+ batches.push(currentBatch);
755
+ }
756
+ return batches;
757
+ }
758
+
759
+ function formatBatchTranscripts(parts: string[], batchIndex: number, totalBatches: number, totalSessions: number): string {
760
+ const header =
761
+ `# Session Transcripts (batch ${batchIndex + 1}/${totalBatches})\n` +
762
+ `# ${parts.length} sessions in this batch, ${totalSessions} total\n\n`;
763
+ return header + parts.join("");
764
+ }
765
+
766
+ interface AnalysisResult {
767
+ edits: AnalysisEdit[];
768
+ correctionsFound: number;
769
+ sessionsWithCorrections: number;
770
+ summary: string;
771
+ patternsNotAdded?: any[];
772
+ }
773
+
774
+ /** Run a single LLM analysis call on one batch of transcripts */
775
+ async function analyzeTranscriptBatch(
776
+ target: ReflectTarget,
777
+ targetPath: string,
778
+ targetContent: string,
779
+ transcripts: string,
780
+ context: string,
781
+ model: any,
782
+ apiKey: string,
783
+ modelLabel: string,
784
+ notify: NotifyFn,
785
+ completeFn: (model: any, request: any, options: any) => Promise<any>,
786
+ ): Promise<AnalysisResult | null> {
787
+ const prompt = buildPromptForTarget(target, targetPath, targetContent, transcripts, context);
788
+
789
+ const reflectAnalysisTool = {
790
+ name: "submit_analysis",
791
+ description: "Submit the reflection analysis results",
792
+ parameters: {
793
+ type: "object" as const,
794
+ properties: {
795
+ corrections_found: { type: "number", description: "Number of facts/rules added, updated, or removed" },
796
+ sessions_with_corrections: { type: "number", description: "Number of conversations containing new facts or corrections" },
797
+ edits: {
798
+ type: "array",
799
+ items: {
800
+ type: "object",
801
+ properties: {
802
+ type: { type: "string", enum: ["strengthen", "add"], description: "strengthen = update existing text, add = insert new text" },
803
+ section: { type: "string", description: "Which section of the file" },
804
+ old_text: { type: ["string", "null"], description: "Exact text to find (for strengthen) or null (for add)" },
805
+ new_text: { type: "string", description: "Replacement text (for strengthen) or new text to insert (for add)" },
806
+ after_text: { type: ["string", "null"], description: "Text after which to insert (for add) or null (for strengthen)" },
807
+ reason: { type: "string", description: "What fact/rule this captures and where in the conversations it appeared" },
808
+ },
809
+ required: ["type", "new_text"],
810
+ },
811
+ },
812
+ patterns_not_added: {
813
+ type: "array",
814
+ items: {
815
+ type: "object",
816
+ properties: {
817
+ pattern: { type: "string" },
818
+ reason: { type: "string" },
819
+ },
820
+ },
821
+ },
822
+ summary: { type: "string", description: "2-3 sentence summary of what was added/updated" },
823
+ },
824
+ required: ["corrections_found", "sessions_with_corrections", "edits", "summary"],
825
+ },
826
+ };
827
+
828
+ const response = await completeFn(model, {
829
+ systemPrompt: "You are a behavioral analysis tool. Analyze the session transcripts and call the submit_analysis tool with your results. Always call the tool — never respond with plain text.",
830
+ messages: [
831
+ {
832
+ role: "user" as const,
833
+ content: [{ type: "text" as const, text: prompt }],
834
+ timestamp: Date.now(),
835
+ },
836
+ ],
837
+ tools: [reflectAnalysisTool],
838
+ }, { apiKey, maxTokens: 16384 });
839
+
840
+ if (response.stopReason === "error") {
841
+ notify(`LLM error: ${response.errorMessage ?? 'unknown'}`, "error");
842
+ return null;
843
+ }
844
+
845
+ let analysis: any;
846
+ const toolCall = response.content.find((c: any) => c.type === "toolCall" && c.name === "submit_analysis");
847
+ if (toolCall && (toolCall as any).arguments) {
848
+ analysis = (toolCall as any).arguments;
849
+ } else {
850
+ const responseText = response.content
851
+ .filter((c: any) => c.type === "text")
852
+ .map((c: any) => c.text)
853
+ .join("")
854
+ .trim();
855
+ try {
856
+ const jsonStr = responseText.replace(/^```json?\s*\n?/m, "").replace(/\n?```\s*$/m, "");
857
+ analysis = JSON.parse(jsonStr);
858
+ } catch {
859
+ notify(`Failed to parse LLM response as JSON. Raw response:\n${responseText.slice(0, 500)}`, "error");
860
+ return null;
861
+ }
862
+ }
863
+
864
+ return {
865
+ edits: analysis.edits ?? [],
866
+ correctionsFound: analysis.corrections_found ?? 0,
867
+ sessionsWithCorrections: analysis.sessions_with_corrections ?? 0,
868
+ summary: analysis.summary ?? "",
869
+ patternsNotAdded: analysis.patterns_not_added,
870
+ };
871
+ }
872
+
729
873
  // --- Main reflection logic ---
730
874
 
731
875
  export interface RunReflectionDeps {
@@ -759,9 +903,11 @@ export async function runReflection(
759
903
  let transcripts: string;
760
904
  let sessionCount = 0;
761
905
  let includedCount = 0;
906
+ let allSessions: SessionData[] | undefined;
762
907
 
763
908
  if (options?.transcriptsOverride) {
764
909
  ({ transcripts, sessionCount, includedCount } = options.transcriptsOverride);
910
+ allSessions = options.transcriptsOverride.sessions;
765
911
  } else if (target.transcripts && target.transcripts.length > 0) {
766
912
  // New array-based transcript sources
767
913
  notify(`Extracting transcripts from ${target.transcripts.length} source(s) (last ${target.lookbackDays} day(s))...`, "info");
@@ -779,6 +925,7 @@ export async function runReflection(
779
925
  target.maxSessionBytes,
780
926
  );
781
927
  ({ transcripts, sessionCount, includedCount } = result);
928
+ allSessions = result.sessions;
782
929
  } else {
783
930
  notify(`Extracting transcripts (last ${target.lookbackDays} day(s))...`, "info");
784
931
  const fn = deps?.collectTranscriptsFn ?? collectTranscripts;
@@ -787,6 +934,7 @@ export async function runReflection(
787
934
  target.maxSessionBytes,
788
935
  );
789
936
  ({ transcripts, sessionCount, includedCount } = result);
937
+ allSessions = result.sessions;
790
938
  }
791
939
 
792
940
  if (!transcripts || includedCount === 0) {
@@ -794,25 +942,39 @@ export async function runReflection(
794
942
  return null;
795
943
  }
796
944
 
797
- notify(`Extracted ${includedCount} sessions (${sessionCount} scanned, ${(transcripts.length / 1024).toFixed(0)}KB)`, "info");
945
+ // Use total session count from allSessions if available (includes sessions that didn't fit the budget)
946
+ const totalSessionCount = allSessions ? allSessions.length : includedCount;
947
+ const totalBytes = allSessions ? allSessions.reduce((sum, s) => sum + s.size, 0) : transcripts.length;
948
+ notify(`Extracted ${totalSessionCount} sessions (${sessionCount} scanned, ${(totalBytes / 1024).toFixed(0)}KB)`, "info");
798
949
 
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);
950
+ // Resolve model — prefer current session model over target.model config
951
+ let model: any;
952
+ let apiKey: string | undefined;
953
+ let modelLabel: string;
803
954
 
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
- }
955
+ if (options?.currentModel && options?.currentModelApiKey) {
956
+ model = options.currentModel;
957
+ apiKey = options.currentModelApiKey;
958
+ modelLabel = `${model.provider}/${model.id}`;
959
+ } else {
960
+ const getModelFn = deps?.getModel ?? (await import("@mariozechner/pi-ai")).getModel;
961
+ const [provider, modelId] = target.model.split("/", 2);
962
+ model = getModelFn(provider as any, modelId as any);
811
963
 
812
- const apiKey = await modelRegistry?.getApiKey(model);
813
- if (!apiKey) {
814
- notify(`No API key for model: ${target.model}`, "error");
815
- return null;
964
+ if (!model) {
965
+ model = modelRegistry?.find(provider, modelId);
966
+ }
967
+ if (!model) {
968
+ notify(`Model not found: ${target.model}`, "error");
969
+ return null;
970
+ }
971
+
972
+ apiKey = await modelRegistry?.getApiKey(model);
973
+ if (!apiKey) {
974
+ notify(`No API key for model: ${target.model}`, "error");
975
+ return null;
976
+ }
977
+ modelLabel = target.model;
816
978
  }
817
979
 
818
980
  // Collect additional context
@@ -825,41 +987,71 @@ export async function runReflection(
825
987
  }
826
988
  }
827
989
 
828
- // Build prompt and call LLM
829
- notify(`Analyzing with ${target.model}...`, "info");
830
- const prompt = buildPromptForTarget(target, targetPath, targetContent, transcripts, context);
831
-
990
+ // Build batches and call LLM
832
991
  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
992
 
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;
993
+ // Determine if we need multiple batches
994
+ // Reserve space for target file, system prompt, tool schema, and context
995
+ const overhead = targetContent.length + (context?.length ?? 0) + 20_000; // 20KB for prompt/tool schema
996
+ const batchBudget = Math.max(target.maxSessionBytes - overhead, 100_000); // at least 100KB per batch
997
+ const needsBatching = allSessions && allSessions.length > 0 && totalBytes > batchBudget;
998
+ let allEdits: AnalysisEdit[] = [];
999
+ let totalCorrectionsFound = 0;
1000
+ let allSummaries: string[] = [];
1001
+
1002
+ if (needsBatching) {
1003
+ const batches = buildTranscriptBatches(allSessions!, batchBudget);
1004
+ notify(`Sessions exceed context budget — splitting into ${batches.length} batches`, "info");
1005
+
1006
+ for (let i = 0; i < batches.length; i++) {
1007
+ const batchTranscripts = formatBatchTranscripts(batches[i], i, batches.length, totalSessionCount);
1008
+ // Re-read target content for each batch so later batches see earlier edits
1009
+ const currentContent = i === 0 ? targetContent : fs.readFileSync(targetPath, "utf-8");
1010
+ notify(`Analyzing batch ${i + 1}/${batches.length} (${batches[i].length} sessions, ${(batchTranscripts.length / 1024).toFixed(0)}KB) with ${modelLabel}...`, "info");
1011
+
1012
+ const result = await analyzeTranscriptBatch(
1013
+ target, targetPath, currentContent, batchTranscripts, context,
1014
+ model, apiKey!, modelLabel, notify, completeFn,
1015
+ );
1016
+
1017
+ if (!result) continue;
1018
+
1019
+ allEdits.push(...result.edits);
1020
+ totalCorrectionsFound += result.correctionsFound;
1021
+ if (result.summary) allSummaries.push(result.summary);
1022
+
1023
+ // Apply this batch's edits immediately so the next batch sees the updated file
1024
+ if (result.edits.length > 0) {
1025
+ const currentForApply = fs.readFileSync(targetPath, "utf-8");
1026
+ const { result: updated, applied } = applyEdits(currentForApply, result.edits);
1027
+ if (applied > 0) {
1028
+ // Backup before first edit
1029
+ if (i === 0) {
1030
+ const bkDir = resolvePath(target.backupDir);
1031
+ fs.mkdirSync(bkDir, { recursive: true });
1032
+ const bkPath = path.join(bkDir, `${path.basename(targetPath, ".md")}_${formatTimestamp()}.md`);
1033
+ fs.copyFileSync(targetPath, bkPath);
1034
+ }
1035
+ fs.writeFileSync(targetPath, updated, "utf-8");
1036
+ notify(`Batch ${i + 1}: applied ${applied} edit(s)`, "info");
1037
+ }
1038
+ }
1039
+ }
1040
+ } else {
1041
+ notify(`Analyzing with ${modelLabel}...`, "info");
1042
+ const result = await analyzeTranscriptBatch(
1043
+ target, targetPath, targetContent, transcripts, context,
1044
+ model, apiKey!, modelLabel, notify, completeFn,
1045
+ );
1046
+ if (!result) return null;
1047
+ allEdits = result.edits;
1048
+ totalCorrectionsFound = result.correctionsFound;
1049
+ if (result.summary) allSummaries.push(result.summary);
858
1050
  }
859
1051
 
860
- const edits: AnalysisEdit[] = analysis.edits ?? [];
861
- const correctionsFound = analysis.corrections_found ?? 0;
862
- const correctionRate = includedCount > 0 ? correctionsFound / includedCount : 0;
1052
+ const edits = allEdits;
1053
+ const correctionsFound = totalCorrectionsFound;
1054
+ const correctionRate = totalSessionCount > 0 ? correctionsFound / totalSessionCount : 0;
863
1055
 
864
1056
  // sourceDate = the date of sessions being analyzed, not when reflect ran
865
1057
  let sourceDateStr: string;
@@ -871,15 +1063,17 @@ export async function runReflection(
871
1063
  sourceDateStr = sourceDate.toISOString().slice(0, 10);
872
1064
  }
873
1065
 
1066
+ const combinedSummary = allSummaries.join(" ") || `${edits.length} edits from ${totalSessionCount} sessions.`;
1067
+
874
1068
  if (edits.length === 0) {
875
- notify(`No edits needed. ${analysis.summary ?? ""}`, "info");
1069
+ notify(`No edits needed. ${combinedSummary}`, "info");
876
1070
  return {
877
1071
  timestamp: new Date().toISOString(),
878
1072
  targetPath,
879
- sessionsAnalyzed: includedCount,
1073
+ sessionsAnalyzed: totalSessionCount,
880
1074
  correctionsFound,
881
1075
  editsApplied: 0,
882
- summary: analysis.summary ?? "No edits needed.",
1076
+ summary: combinedSummary,
883
1077
  diffLines: 0,
884
1078
  correctionRate,
885
1079
  edits: [],
@@ -897,16 +1091,15 @@ export async function runReflection(
897
1091
  reason: e.reason,
898
1092
  }));
899
1093
 
900
- const summary = analysis.summary ?? `${edits.length} edits proposed (dry run).`;
901
- notify(`[dry run] ${summary}`, "info");
1094
+ notify(`[dry run] ${combinedSummary}`, "info");
902
1095
 
903
1096
  return {
904
1097
  timestamp: new Date().toISOString(),
905
1098
  targetPath,
906
- sessionsAnalyzed: includedCount,
1099
+ sessionsAnalyzed: totalSessionCount,
907
1100
  correctionsFound,
908
1101
  editsApplied: 0,
909
- summary,
1102
+ summary: combinedSummary,
910
1103
  diffLines: 0,
911
1104
  correctionRate,
912
1105
  edits: editRecords,
@@ -914,77 +1107,85 @@ export async function runReflection(
914
1107
  };
915
1108
  }
916
1109
 
917
- // Backup before editing
1110
+ // Apply edits
918
1111
  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);
1112
+ let totalApplied = 0;
1113
+
1114
+ if (needsBatching) {
1115
+ // Batched: edits were already applied inline in the loop above.
1116
+ // Count total applied from the diff.
1117
+ const finalContent = fs.readFileSync(targetPath, "utf-8");
1118
+ const origLines = targetContent.split("\n");
1119
+ const finalLines = finalContent.split("\n");
1120
+ for (let i = 0; i < Math.max(origLines.length, finalLines.length); i++) {
1121
+ if (origLines[i] !== finalLines[i]) totalApplied++;
1122
+ }
1123
+ // Use edit count as applied since we tracked them per-batch
1124
+ totalApplied = edits.length; // best estimate — individual batch applied counts were logged
1125
+ } else {
1126
+ // Single batch — backup and apply
1127
+ fs.mkdirSync(backupDir, { recursive: true });
1128
+ const backupPath = path.join(backupDir, `${path.basename(targetPath, ".md")}_${formatTimestamp()}.md`);
1129
+ fs.copyFileSync(targetPath, backupPath);
922
1130
 
923
- // Apply edits with safety checks
924
- const { result, applied, skipped } = applyEdits(targetContent, edits);
1131
+ const { result, applied, skipped } = applyEdits(targetContent, edits);
925
1132
 
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
- }
1133
+ if (applied === 0) {
1134
+ notify(`All ${edits.length} edits failed to apply. Skipped: ${skipped.join("; ")}`, "warning");
1135
+ try { fs.unlinkSync(backupPath); } catch {}
1136
+ return null;
1137
+ }
931
1138
 
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
- }
1139
+ if (result.length < targetContent.length * 0.5) {
1140
+ notify(`Result is suspiciously small (${result.length} vs ${targetContent.length} bytes). Aborting.`, "error");
1141
+ return null;
1142
+ }
1143
+
1144
+ fs.writeFileSync(targetPath, result, "utf-8");
1145
+ totalApplied = applied;
937
1146
 
938
- fs.writeFileSync(targetPath, result, "utf-8");
1147
+ if (skipped.length > 0) {
1148
+ notify(`Applied ${applied}/${edits.length} edits (${skipped.length} skipped). Backup: ${backupPath}`, "warning");
1149
+ } else {
1150
+ notify(`Applied ${applied} edit(s). Backup: ${backupPath}`, "info");
1151
+ }
1152
+ }
939
1153
 
940
- // Count changed lines
1154
+ // Compute final diff
1155
+ const finalContent = fs.readFileSync(targetPath, "utf-8");
941
1156
  const originalLines = targetContent.split("\n");
942
- const resultLines = result.split("\n");
1157
+ const resultLines = finalContent.split("\n");
943
1158
  let diffLines = 0;
944
1159
  const maxLen = Math.max(originalLines.length, resultLines.length);
945
1160
  for (let i = 0; i < maxLen; i++) {
946
1161
  if (originalLines[i] !== resultLines[i]) diffLines++;
947
1162
  }
948
1163
 
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");
1164
+ notify(combinedSummary, "info");
957
1165
 
958
- // If target is in a git repo, commit the change
1166
+ // Git commit
959
1167
  try {
960
1168
  const realPath = fs.realpathSync(targetPath);
961
1169
  const repoDir = path.dirname(realPath);
962
1170
  if (fs.existsSync(path.join(repoDir, ".git"))) {
963
1171
  const { execFileSync } = require("node:child_process");
964
1172
  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 });
1173
+ execFileSync("git", ["commit", "-m", `reflect: ${path.basename(realPath)} — ${totalApplied} edits from ${totalSessionCount} sessions`, "--no-verify"], { cwd: repoDir, stdio: "ignore", timeout: 5000 });
966
1174
  notify(`Committed to ${path.basename(repoDir)}`, "info");
967
1175
  }
968
- } catch {
969
- // Not in a git repo or commit failed — that's fine
970
- }
1176
+ } catch {}
971
1177
 
972
- // Extract per-edit detail for recidivism tracking
973
1178
  const editRecords: EditRecord[] = edits
974
1179
  .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
- }));
1180
+ .map((e: any) => ({ type: e.type ?? "add", section: e.section, reason: e.reason }));
980
1181
 
981
1182
  return {
982
1183
  timestamp: new Date().toISOString(),
983
1184
  targetPath,
984
- sessionsAnalyzed: includedCount,
1185
+ sessionsAnalyzed: totalSessionCount,
985
1186
  correctionsFound,
986
- editsApplied: applied,
987
- summary,
1187
+ editsApplied: totalApplied,
1188
+ summary: combinedSummary,
988
1189
  diffLines,
989
1190
  correctionRate,
990
1191
  edits: editRecords,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askjo/pi-reflect",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Self-improving behavioral files for pi coding agents. Analyzes session transcripts for correction patterns and makes surgical edits to prevent recurrence.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,642 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import {
6
+ buildTranscriptBatches,
7
+ runReflection,
8
+ type ReflectTarget,
9
+ type RunReflectionDeps,
10
+ type NotifyFn,
11
+ type SessionData,
12
+ type TranscriptResult,
13
+ DEFAULT_TARGET,
14
+ } from "../extensions/reflect.js";
15
+ import { makeTempDir, cleanup, SAMPLE_AGENTS_MD } from "./helpers.js";
16
+
17
+ // --- buildTranscriptBatches tests ---
18
+
19
+ function makeSession(id: string, sizeBytes: number): SessionData {
20
+ const transcript = "x".repeat(sizeBytes);
21
+ return {
22
+ transcript,
23
+ project: "test-project",
24
+ sessionId: id,
25
+ date: "2026-02-20",
26
+ size: sizeBytes,
27
+ };
28
+ }
29
+
30
+ describe("buildTranscriptBatches", () => {
31
+ it("puts all sessions in one batch when they fit", () => {
32
+ const sessions = [makeSession("a", 100), makeSession("b", 100), makeSession("c", 100)];
33
+ const batches = buildTranscriptBatches(sessions, 1000);
34
+ assert.equal(batches.length, 1);
35
+ assert.equal(batches[0].length, 3);
36
+ });
37
+
38
+ it("splits sessions across batches when they exceed maxBytes", () => {
39
+ const sessions = [makeSession("a", 500), makeSession("b", 500), makeSession("c", 500)];
40
+ // Each entry is transcript + "\n---\n\n" = 506 bytes
41
+ const batches = buildTranscriptBatches(sessions, 600);
42
+ assert.equal(batches.length, 3);
43
+ assert.equal(batches[0].length, 1);
44
+ assert.equal(batches[1].length, 1);
45
+ assert.equal(batches[2].length, 1);
46
+ });
47
+
48
+ it("groups multiple small sessions into one batch", () => {
49
+ const sessions = [
50
+ makeSession("a", 100),
51
+ makeSession("b", 100),
52
+ makeSession("c", 100),
53
+ makeSession("d", 100),
54
+ ];
55
+ // Each entry ~106 bytes, 4 × 106 = 424 bytes
56
+ const batches = buildTranscriptBatches(sessions, 250);
57
+ assert.equal(batches.length, 2);
58
+ assert.equal(batches[0].length, 2);
59
+ assert.equal(batches[1].length, 2);
60
+ });
61
+
62
+ it("returns empty array for empty sessions", () => {
63
+ const batches = buildTranscriptBatches([], 1000);
64
+ assert.equal(batches.length, 0);
65
+ });
66
+
67
+ it("handles a single session larger than maxBytes", () => {
68
+ const sessions = [makeSession("a", 2000)];
69
+ const batches = buildTranscriptBatches(sessions, 500);
70
+ // Single session still gets its own batch even if it exceeds budget
71
+ assert.equal(batches.length, 1);
72
+ assert.equal(batches[0].length, 1);
73
+ });
74
+
75
+ it("preserves session order across batches", () => {
76
+ const sessions = [
77
+ { ...makeSession("1st", 300), transcript: "FIRST_SESSION_CONTENT" + "x".repeat(279) },
78
+ { ...makeSession("2nd", 300), transcript: "SECOND_SESSION_CONTENT" + "x".repeat(278) },
79
+ { ...makeSession("3rd", 300), transcript: "THIRD_SESSION_CONTENT" + "x".repeat(279) },
80
+ ];
81
+ const batches = buildTranscriptBatches(sessions, 400);
82
+ assert.equal(batches.length, 3);
83
+ assert.ok(batches[0][0].includes("FIRST_SESSION"));
84
+ assert.ok(batches[1][0].includes("SECOND_SESSION"));
85
+ assert.ok(batches[2][0].includes("THIRD_SESSION"));
86
+ });
87
+
88
+ it("each entry includes separator suffix", () => {
89
+ const sessions = [makeSession("a", 50)];
90
+ const batches = buildTranscriptBatches(sessions, 1000);
91
+ assert.ok(batches[0][0].endsWith("\n---\n\n"));
92
+ });
93
+ });
94
+
95
+ // --- Tool call parsing + batched runReflection tests ---
96
+
97
+ let tmpDir: string;
98
+ let notifications: Array<{ msg: string; level: string }>;
99
+ let notify: NotifyFn;
100
+
101
+ beforeEach(() => {
102
+ tmpDir = makeTempDir();
103
+ notifications = [];
104
+ notify = (msg, level) => notifications.push({ msg, level });
105
+ });
106
+
107
+ afterEach(() => {
108
+ cleanup(tmpDir);
109
+ });
110
+
111
+ function makeTarget(overrides: Partial<ReflectTarget> = {}): ReflectTarget {
112
+ return {
113
+ ...DEFAULT_TARGET,
114
+ backupDir: path.join(tmpDir, "backups"),
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function makeModelRegistry() {
120
+ return {
121
+ find: () => ({ provider: "test", id: "test-model" }),
122
+ getApiKey: async () => "test-key",
123
+ };
124
+ }
125
+
126
+ function makeToolCallResponse(analysis: any) {
127
+ return {
128
+ content: [{
129
+ type: "toolCall",
130
+ name: "submit_analysis",
131
+ arguments: analysis,
132
+ }],
133
+ };
134
+ }
135
+
136
+ function makeTextResponse(text: string) {
137
+ return {
138
+ content: [{ type: "text", text }],
139
+ };
140
+ }
141
+
142
+ // batchBudget = Math.max(maxSessionBytes - overhead, 100_000)
143
+ // overhead = targetContent.length + context.length + 20_000
144
+ // SAMPLE_AGENTS_MD is ~900 bytes, so overhead ≈ 20_900
145
+ // To force batching: totalBytes must exceed batchBudget
146
+ // With maxSessionBytes=120_000, batchBudget = max(120_000 - 20_900, 100_000) = 100_000
147
+ // So sessions totaling >100KB will trigger batching.
148
+ const LARGE_SESSION_SIZE = 40_000; // 40KB each — 3 sessions = 120KB > 100KB budget
149
+
150
+ function makeLargeSessions(count: number): SessionData[] {
151
+ return Array.from({ length: count }, (_, i) =>
152
+ makeSession(`s${i + 1}`, LARGE_SESSION_SIZE)
153
+ );
154
+ }
155
+
156
+ function makeBatchDeps(
157
+ responsePerCall: any[],
158
+ sessions: SessionData[],
159
+ ): RunReflectionDeps {
160
+ let callIndex = 0;
161
+ return {
162
+ completeSimple: async () => {
163
+ const resp = responsePerCall[Math.min(callIndex, responsePerCall.length - 1)];
164
+ callIndex++;
165
+ return resp;
166
+ },
167
+ getModel: () => ({ provider: "test", id: "test-model" }),
168
+ collectTranscriptsFn: async () => ({
169
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
170
+ sessionCount: sessions.length,
171
+ includedCount: sessions.length,
172
+ sessions,
173
+ }),
174
+ };
175
+ }
176
+
177
+ describe("analyzeTranscriptBatch — tool call parsing", () => {
178
+ it("parses structured tool call response", async () => {
179
+ const fp = path.join(tmpDir, "AGENTS.md");
180
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
181
+ const target = makeTarget({ path: fp });
182
+
183
+ const deps: RunReflectionDeps = {
184
+ completeSimple: async () => makeToolCallResponse({
185
+ corrections_found: 1,
186
+ sessions_with_corrections: 1,
187
+ edits: [{
188
+ type: "strengthen",
189
+ section: "Rules",
190
+ old_text: "- **Keep code DRY**: NEVER duplicate logic.",
191
+ new_text: "- **Keep code DRY**: NEVER duplicate logic. Always use shared helpers.",
192
+ reason: "Agent duplicated code in 2 sessions",
193
+ }],
194
+ summary: "Added DRY emphasis.",
195
+ }),
196
+ getModel: () => ({ provider: "test", id: "test-model" }),
197
+ collectTranscriptsFn: async () => ({
198
+ transcripts: "### Session\n\n**USER:** test\n",
199
+ sessionCount: 1,
200
+ includedCount: 1,
201
+ }),
202
+ };
203
+
204
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
205
+ assert.notEqual(result, null);
206
+ assert.equal(result!.editsApplied, 1);
207
+ const updated = fs.readFileSync(fp, "utf-8");
208
+ assert.ok(updated.includes("shared helpers"));
209
+ });
210
+
211
+ it("falls back to JSON text when no tool call in response", async () => {
212
+ const fp = path.join(tmpDir, "AGENTS.md");
213
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
214
+ const target = makeTarget({ path: fp });
215
+
216
+ const jsonResponse = JSON.stringify({
217
+ corrections_found: 1,
218
+ sessions_with_corrections: 1,
219
+ edits: [{
220
+ type: "strengthen",
221
+ section: "Rules",
222
+ old_text: "- **Keep code DRY**: NEVER duplicate logic.",
223
+ new_text: "- **Keep code DRY**: NEVER duplicate logic. Use parameterized functions.",
224
+ reason: "test",
225
+ }],
226
+ summary: "Fallback test.",
227
+ });
228
+
229
+ const deps: RunReflectionDeps = {
230
+ completeSimple: async () => makeTextResponse(jsonResponse),
231
+ getModel: () => ({ provider: "test", id: "test-model" }),
232
+ collectTranscriptsFn: async () => ({
233
+ transcripts: "### Session\n\n**USER:** test\n",
234
+ sessionCount: 1,
235
+ includedCount: 1,
236
+ }),
237
+ };
238
+
239
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
240
+ assert.notEqual(result, null);
241
+ assert.equal(result!.editsApplied, 1);
242
+ });
243
+
244
+ it("handles LLM error response gracefully", async () => {
245
+ const fp = path.join(tmpDir, "AGENTS.md");
246
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
247
+ const target = makeTarget({ path: fp });
248
+
249
+ const deps: RunReflectionDeps = {
250
+ completeSimple: async () => ({
251
+ stopReason: "error",
252
+ errorMessage: "Rate limit exceeded",
253
+ content: [],
254
+ }),
255
+ getModel: () => ({ provider: "test", id: "test-model" }),
256
+ collectTranscriptsFn: async () => ({
257
+ transcripts: "### Session\n\n**USER:** test\n",
258
+ sessionCount: 1,
259
+ includedCount: 1,
260
+ }),
261
+ };
262
+
263
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
264
+ assert.equal(result, null);
265
+ assert.ok(notifications.some(n => n.level === "error" && n.msg.includes("Rate limit")));
266
+ });
267
+ });
268
+
269
+ describe("batched runReflection", () => {
270
+ it("triggers batching when sessions exceed context budget", async () => {
271
+ const fp = path.join(tmpDir, "AGENTS.md");
272
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
273
+
274
+ const target = makeTarget({
275
+ path: fp,
276
+ maxSessionBytes: 120_000, // batchBudget ≈ 100KB, 3 × 40KB = 120KB > 100KB
277
+ });
278
+
279
+ const sessions = makeLargeSessions(3);
280
+
281
+ // Each batch gets its own LLM call
282
+ let callCount = 0;
283
+ const deps: RunReflectionDeps = {
284
+ completeSimple: async () => {
285
+ callCount++;
286
+ if (callCount === 1) {
287
+ return makeToolCallResponse({
288
+ corrections_found: 1,
289
+ sessions_with_corrections: 1,
290
+ edits: [{
291
+ type: "add",
292
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
293
+ new_text: "- **Batch 1 rule**: From first batch.",
294
+ reason: "batch 1",
295
+ section: "Rules",
296
+ }],
297
+ summary: "Batch 1 done.",
298
+ });
299
+ }
300
+ // Later batches: add after batch 1's rule
301
+ return makeToolCallResponse({
302
+ corrections_found: 1,
303
+ sessions_with_corrections: 1,
304
+ edits: [{
305
+ type: "add",
306
+ after_text: "- **Batch 1 rule**: From first batch.",
307
+ new_text: `- **Batch ${callCount} rule**: From batch ${callCount}.`,
308
+ reason: `batch ${callCount}`,
309
+ section: "Rules",
310
+ }],
311
+ summary: `Batch ${callCount} done.`,
312
+ });
313
+ },
314
+ getModel: () => ({ provider: "test", id: "test-model" }),
315
+ collectTranscriptsFn: async () => ({
316
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
317
+ sessionCount: sessions.length,
318
+ includedCount: sessions.length,
319
+ sessions,
320
+ }),
321
+ };
322
+
323
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
324
+
325
+ assert.notEqual(result, null);
326
+ assert.ok(callCount >= 2, `Expected multiple LLM calls, got ${callCount}`);
327
+ assert.ok(notifications.some(n => n.msg.includes("splitting into")));
328
+ assert.ok(notifications.some(n => n.msg.includes("Batch 1")));
329
+
330
+ const updated = fs.readFileSync(fp, "utf-8");
331
+ assert.ok(updated.includes("Batch 1 rule"));
332
+ });
333
+
334
+ it("creates backup only once for multi-batch edits", async () => {
335
+ const fp = path.join(tmpDir, "AGENTS.md");
336
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
337
+ const backupDir = path.join(tmpDir, "backups");
338
+ const target = makeTarget({
339
+ path: fp,
340
+ backupDir,
341
+ maxSessionBytes: 120_000,
342
+ });
343
+
344
+ const sessions = makeLargeSessions(3);
345
+ let callCount = 0;
346
+ const deps: RunReflectionDeps = {
347
+ completeSimple: async () => {
348
+ callCount++;
349
+ if (callCount === 1) {
350
+ return makeToolCallResponse({
351
+ corrections_found: 1,
352
+ sessions_with_corrections: 1,
353
+ edits: [{
354
+ type: "add",
355
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
356
+ new_text: "- **Rule from batch 1**: Added.",
357
+ reason: "test",
358
+ section: "Rules",
359
+ }],
360
+ summary: "Batch 1.",
361
+ });
362
+ }
363
+ return makeToolCallResponse({
364
+ corrections_found: 1,
365
+ sessions_with_corrections: 1,
366
+ edits: [{
367
+ type: "add",
368
+ after_text: "- **Rule from batch 1**: Added.",
369
+ new_text: `- **Rule from batch ${callCount}**: Also added.`,
370
+ reason: "test",
371
+ section: "Rules",
372
+ }],
373
+ summary: `Batch ${callCount}.`,
374
+ });
375
+ },
376
+ getModel: () => ({ provider: "test", id: "test-model" }),
377
+ collectTranscriptsFn: async () => ({
378
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
379
+ sessionCount: sessions.length,
380
+ includedCount: sessions.length,
381
+ sessions,
382
+ }),
383
+ };
384
+
385
+ await runReflection(target, makeModelRegistry(), notify, deps);
386
+
387
+ // Only one backup file should exist
388
+ const backups = fs.readdirSync(backupDir);
389
+ assert.equal(backups.length, 1);
390
+
391
+ // Backup should have original content (before any batch edits)
392
+ const backupContent = fs.readFileSync(path.join(backupDir, backups[0]), "utf-8");
393
+ assert.equal(backupContent, SAMPLE_AGENTS_MD);
394
+ });
395
+
396
+ it("later batches see edits from earlier batches", async () => {
397
+ const fp = path.join(tmpDir, "AGENTS.md");
398
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
399
+ const target = makeTarget({
400
+ path: fp,
401
+ maxSessionBytes: 120_000,
402
+ });
403
+
404
+ const sessions = makeLargeSessions(3);
405
+
406
+ // Later batches should see edits from batch 1 on disk
407
+ let callIndex = 0;
408
+ let laterBatchSawEdit = false;
409
+ const deps: RunReflectionDeps = {
410
+ completeSimple: async () => {
411
+ callIndex++;
412
+ if (callIndex === 1) {
413
+ return makeToolCallResponse({
414
+ corrections_found: 1,
415
+ sessions_with_corrections: 1,
416
+ edits: [{
417
+ type: "add",
418
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
419
+ new_text: "- **Intermediate rule**: Inserted by batch 1.",
420
+ reason: "test",
421
+ section: "Rules",
422
+ }],
423
+ summary: "Batch 1.",
424
+ });
425
+ }
426
+ // Later batch — verify it sees batch 1's edit by reading the file
427
+ const currentContent = fs.readFileSync(fp, "utf-8");
428
+ if (currentContent.includes("Intermediate rule")) {
429
+ laterBatchSawEdit = true;
430
+ }
431
+ return makeToolCallResponse({
432
+ corrections_found: 0,
433
+ sessions_with_corrections: 0,
434
+ edits: [],
435
+ summary: `Batch ${callIndex} clean.`,
436
+ });
437
+ },
438
+ getModel: () => ({ provider: "test", id: "test-model" }),
439
+ collectTranscriptsFn: async () => ({
440
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
441
+ sessionCount: sessions.length,
442
+ includedCount: sessions.length,
443
+ sessions,
444
+ }),
445
+ };
446
+
447
+ await runReflection(target, makeModelRegistry(), notify, deps);
448
+ assert.ok(callIndex >= 2, "Should have multiple batch calls");
449
+ assert.ok(laterBatchSawEdit, "Later batch should see batch 1's edits on disk");
450
+ });
451
+
452
+ it("combines summaries from multiple batches", async () => {
453
+ const fp = path.join(tmpDir, "AGENTS.md");
454
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
455
+ const target = makeTarget({
456
+ path: fp,
457
+ maxSessionBytes: 120_000,
458
+ });
459
+
460
+ const sessions = makeLargeSessions(3);
461
+ let callCount = 0;
462
+ const deps: RunReflectionDeps = {
463
+ completeSimple: async () => {
464
+ callCount++;
465
+ return makeToolCallResponse({
466
+ corrections_found: 0,
467
+ sessions_with_corrections: 0,
468
+ edits: [],
469
+ summary: `Batch ${callCount} summary.`,
470
+ });
471
+ },
472
+ getModel: () => ({ provider: "test", id: "test-model" }),
473
+ collectTranscriptsFn: async () => ({
474
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
475
+ sessionCount: sessions.length,
476
+ includedCount: sessions.length,
477
+ sessions,
478
+ }),
479
+ };
480
+
481
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
482
+
483
+ assert.notEqual(result, null);
484
+ assert.ok(callCount >= 2, `Expected multiple LLM calls, got ${callCount}`);
485
+ assert.ok(result!.summary.includes("Batch 1 summary."));
486
+ assert.ok(result!.summary.includes("Batch 2 summary."));
487
+ });
488
+
489
+ it("continues processing when one batch fails", async () => {
490
+ const fp = path.join(tmpDir, "AGENTS.md");
491
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
492
+ const target = makeTarget({
493
+ path: fp,
494
+ maxSessionBytes: 120_000,
495
+ });
496
+
497
+ const sessions = makeLargeSessions(3);
498
+
499
+ let callIndex = 0;
500
+ const deps: RunReflectionDeps = {
501
+ completeSimple: async () => {
502
+ callIndex++;
503
+ if (callIndex === 1) {
504
+ // First batch fails
505
+ return { stopReason: "error", errorMessage: "Timeout", content: [] };
506
+ }
507
+ // Later batches succeed
508
+ return makeToolCallResponse({
509
+ corrections_found: 1,
510
+ sessions_with_corrections: 1,
511
+ edits: [{
512
+ type: "add",
513
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
514
+ new_text: "- **Surviving rule**: From later batch.",
515
+ reason: "test",
516
+ section: "Rules",
517
+ }],
518
+ summary: "Later batch succeeded.",
519
+ });
520
+ },
521
+ getModel: () => ({ provider: "test", id: "test-model" }),
522
+ collectTranscriptsFn: async () => ({
523
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
524
+ sessionCount: sessions.length,
525
+ includedCount: sessions.length,
526
+ sessions,
527
+ }),
528
+ };
529
+
530
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
531
+ assert.ok(callIndex >= 2, "Should have made multiple LLM calls");
532
+ // Later batch's edit should still apply even though batch 1 failed
533
+ const updated = fs.readFileSync(fp, "utf-8");
534
+ assert.ok(updated.includes("Surviving rule"));
535
+ assert.ok(notifications.some(n => n.msg.includes("Timeout")));
536
+ });
537
+
538
+ it("does not batch when sessions fit within budget", async () => {
539
+ const fp = path.join(tmpDir, "AGENTS.md");
540
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
541
+ const target = makeTarget({
542
+ path: fp,
543
+ maxSessionBytes: 500_000, // large budget
544
+ });
545
+
546
+ const sessions = [makeSession("s1", 100), makeSession("s2", 100)];
547
+ const deps: RunReflectionDeps = {
548
+ completeSimple: async () => makeToolCallResponse({
549
+ corrections_found: 0,
550
+ sessions_with_corrections: 0,
551
+ edits: [],
552
+ summary: "All clean.",
553
+ }),
554
+ getModel: () => ({ provider: "test", id: "test-model" }),
555
+ collectTranscriptsFn: async () => ({
556
+ transcripts: "### Session\n\n**USER:** test\n",
557
+ sessionCount: 2,
558
+ includedCount: 2,
559
+ sessions,
560
+ }),
561
+ };
562
+
563
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
564
+ assert.notEqual(result, null);
565
+ // Should NOT see batching notification
566
+ assert.ok(!notifications.some(n => n.msg.includes("splitting into")));
567
+ assert.ok(notifications.some(n => n.msg.includes("Analyzing with")));
568
+ });
569
+
570
+ it("accumulates correctionsFound across batches", async () => {
571
+ const fp = path.join(tmpDir, "AGENTS.md");
572
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
573
+ const target = makeTarget({
574
+ path: fp,
575
+ maxSessionBytes: 120_000,
576
+ });
577
+
578
+ const sessions = makeLargeSessions(3);
579
+ let callCount = 0;
580
+ const correctionsPerBatch = [3, 5, 2]; // one per batch
581
+ const deps: RunReflectionDeps = {
582
+ completeSimple: async () => {
583
+ const idx = callCount;
584
+ callCount++;
585
+ return makeToolCallResponse({
586
+ corrections_found: correctionsPerBatch[idx] ?? 0,
587
+ sessions_with_corrections: 1,
588
+ edits: [],
589
+ summary: `Batch ${idx + 1}.`,
590
+ });
591
+ },
592
+ getModel: () => ({ provider: "test", id: "test-model" }),
593
+ collectTranscriptsFn: async () => ({
594
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
595
+ sessionCount: sessions.length,
596
+ includedCount: sessions.length,
597
+ sessions,
598
+ }),
599
+ };
600
+
601
+ const result = await runReflection(target, makeModelRegistry(), notify, deps);
602
+
603
+ assert.notEqual(result, null);
604
+ // Sum all corrections across however many batches were created
605
+ const expectedTotal = correctionsPerBatch.slice(0, callCount).reduce((a, b) => a + b, 0);
606
+ assert.equal(result!.correctionsFound, expectedTotal);
607
+ });
608
+
609
+ it("uses transcriptsOverride sessions for batching", async () => {
610
+ const fp = path.join(tmpDir, "AGENTS.md");
611
+ fs.writeFileSync(fp, SAMPLE_AGENTS_MD);
612
+ const target = makeTarget({
613
+ path: fp,
614
+ maxSessionBytes: 120_000,
615
+ });
616
+
617
+ const sessions = makeLargeSessions(3);
618
+
619
+ const deps: RunReflectionDeps = {
620
+ completeSimple: async () => makeToolCallResponse({
621
+ corrections_found: 0,
622
+ sessions_with_corrections: 0,
623
+ edits: [],
624
+ summary: "Clean.",
625
+ }),
626
+ getModel: () => ({ provider: "test", id: "test-model" }),
627
+ };
628
+
629
+ const result = await runReflection(target, makeModelRegistry(), notify, deps, {
630
+ transcriptsOverride: {
631
+ transcripts: sessions.map(s => s.transcript).join("\n---\n\n"),
632
+ sessionCount: 2,
633
+ includedCount: 2,
634
+ sessions,
635
+ },
636
+ });
637
+
638
+ assert.notEqual(result, null);
639
+ // Should batch since totalBytes (800) > batchBudget with tiny maxSessionBytes
640
+ assert.ok(notifications.some(n => n.msg.includes("splitting into")));
641
+ });
642
+ });