@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.
- package/extensions/index.ts +19 -3
- package/extensions/reflect.ts +297 -96
- package/package.json +1 -1
- package/tests/batching.test.ts +642 -0
package/extensions/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/extensions/reflect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
801
|
-
|
|
802
|
-
let
|
|
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 (
|
|
805
|
-
model =
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
|
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
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
let
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
861
|
-
const correctionsFound =
|
|
862
|
-
const correctionRate =
|
|
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. ${
|
|
1069
|
+
notify(`No edits needed. ${combinedSummary}`, "info");
|
|
876
1070
|
return {
|
|
877
1071
|
timestamp: new Date().toISOString(),
|
|
878
1072
|
targetPath,
|
|
879
|
-
sessionsAnalyzed:
|
|
1073
|
+
sessionsAnalyzed: totalSessionCount,
|
|
880
1074
|
correctionsFound,
|
|
881
1075
|
editsApplied: 0,
|
|
882
|
-
summary:
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
1110
|
+
// Apply edits
|
|
918
1111
|
const backupDir = resolvePath(target.backupDir);
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
924
|
-
const { result, applied, skipped } = applyEdits(targetContent, edits);
|
|
1131
|
+
const { result, applied, skipped } = applyEdits(targetContent, edits);
|
|
925
1132
|
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1154
|
+
// Compute final diff
|
|
1155
|
+
const finalContent = fs.readFileSync(targetPath, "utf-8");
|
|
941
1156
|
const originalLines = targetContent.split("\n");
|
|
942
|
-
const resultLines =
|
|
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
|
-
|
|
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
|
-
//
|
|
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)} — ${
|
|
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:
|
|
1185
|
+
sessionsAnalyzed: totalSessionCount,
|
|
985
1186
|
correctionsFound,
|
|
986
|
-
editsApplied:
|
|
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.
|
|
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
|
+
});
|