@askjo/pi-reflect 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -63,7 +63,7 @@ export interface ReflectConfig {
63
63
  }
64
64
 
65
65
  export interface EditRecord {
66
- type: "strengthen" | "add";
66
+ type: "strengthen" | "add" | "remove" | "merge";
67
67
  section: string;
68
68
  reason: string;
69
69
  }
@@ -80,6 +80,8 @@ export interface ReflectRun {
80
80
  edits?: EditRecord[];
81
81
  sourceDate?: string;
82
82
  date?: string; // legacy field from bash-script/batch runs
83
+ /** File size metrics after this run */
84
+ fileSize?: { chars: number; words: number; lines: number; estTokens: number };
83
85
  }
84
86
 
85
87
  export interface SessionExchange {
@@ -112,11 +114,13 @@ export interface EditResult {
112
114
  }
113
115
 
114
116
  export interface AnalysisEdit {
115
- type: "strengthen" | "add";
117
+ type: "strengthen" | "add" | "remove" | "merge";
116
118
  section?: string;
117
119
  old_text?: string | null;
118
120
  new_text: string;
119
121
  after_text?: string | null;
122
+ /** For "merge": array of exact text strings to remove (they're consolidated into new_text) */
123
+ merge_sources?: string[];
120
124
  reason?: string;
121
125
  }
122
126
 
@@ -127,6 +131,7 @@ export interface ReflectionOptions {
127
131
  /** Override model — use the current session model instead of target.model */
128
132
  currentModel?: any;
129
133
  currentModelApiKey?: string;
134
+ currentModelHeaders?: Record<string, string>;
130
135
  }
131
136
 
132
137
  export type NotifyFn = (msg: string, level: "info" | "warning" | "error") => void;
@@ -146,6 +151,17 @@ export const DEFAULT_TARGET: ReflectTarget = {
146
151
  export const MAX_ASSISTANT_MSG_CHARS = 2000;
147
152
  export const MAX_THINKING_MSG_CHARS = 1500;
148
153
 
154
+ // --- File size metrics ---
155
+
156
+ export function computeFileMetrics(content: string): { chars: number; words: number; lines: number; estTokens: number } {
157
+ const chars = content.length;
158
+ const words = content.split(/\s+/).filter(Boolean).length;
159
+ const lines = content.split("\n").length;
160
+ // Rough estimate: ~4 chars per token for English/code mix
161
+ const estTokens = Math.round(chars / 4);
162
+ return { chars, words, lines, estTokens };
163
+ }
164
+
149
165
  // --- Config ---
150
166
 
151
167
  export function loadConfig(): ReflectConfig {
@@ -489,6 +505,68 @@ function isWithinLookback(filename: string, cutoff: string): boolean {
489
505
  return match[1] >= cutoff;
490
506
  }
491
507
 
508
+ function findGlobBaseDir(globPattern: string): string {
509
+ const normalized = path.normalize(globPattern);
510
+ const segments = normalized.split(path.sep);
511
+ const globIndex = segments.findIndex((segment) => segment.includes("*"));
512
+
513
+ if (globIndex === -1) return path.dirname(normalized);
514
+
515
+ const baseSegments = segments.slice(0, globIndex).filter(Boolean);
516
+ if (normalized.startsWith(path.sep)) {
517
+ return baseSegments.length > 0 ? path.join(path.sep, ...baseSegments) : path.sep;
518
+ }
519
+ return baseSegments.length > 0 ? path.join(...baseSegments) : ".";
520
+ }
521
+
522
+ function globToRegex(globPattern: string): RegExp {
523
+ const normalized = globPattern.split(path.sep).join("/");
524
+ const escaped = normalized.replace(/[.+^${}()|[\]\\]/g, "\\$&");
525
+ const globstarToken = "__PI_REFLECT_GLOBSTAR__";
526
+ const withGlobstarToken = escaped.replace(/\*\*\/?/g, globstarToken);
527
+ const withWildcards = withGlobstarToken.replace(/\*/g, "[^/]*");
528
+ const withGlobstar = withWildcards.replaceAll(globstarToken, "(?:.*/)?");
529
+ return new RegExp(`^${withGlobstar}$`);
530
+ }
531
+
532
+ function walkFilesRecursive(dir: string): string[] {
533
+ const files: string[] = [];
534
+
535
+ try {
536
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
537
+ for (const entry of entries) {
538
+ const fullPath = path.join(dir, entry.name);
539
+ if (entry.isDirectory()) {
540
+ files.push(...walkFilesRecursive(fullPath));
541
+ } else if (entry.isFile()) {
542
+ files.push(fullPath);
543
+ }
544
+ }
545
+ } catch {}
546
+
547
+ return files;
548
+ }
549
+
550
+ function findMatchingFiles(globPattern: string): { name: string; full: string }[] {
551
+ const baseDir = findGlobBaseDir(globPattern);
552
+ if (!fs.existsSync(baseDir)) return [];
553
+
554
+ const relativePattern = path.relative(baseDir, globPattern);
555
+ const relativeRegex = globToRegex(relativePattern);
556
+ const basenameRegex = !relativePattern.includes(path.sep) ? globToRegex(path.basename(globPattern)) : null;
557
+
558
+ return walkFilesRecursive(baseDir)
559
+ .filter((fullPath) => {
560
+ const relativePath = path.relative(baseDir, fullPath).split(path.sep).join("/");
561
+ const baseName = path.basename(fullPath);
562
+ return relativeRegex.test(relativePath) || (basenameRegex?.test(baseName) ?? false);
563
+ })
564
+ .map((fullPath) => ({
565
+ name: path.relative(baseDir, fullPath).split(path.sep).join("/"),
566
+ full: fullPath,
567
+ }));
568
+ }
569
+
492
570
  export async function collectContext(sources: ContextSource[], lookbackDays: number): Promise<string> {
493
571
  const parts: string[] = [];
494
572
  const cutoff = lookbackCutoff(lookbackDays);
@@ -507,14 +585,7 @@ export async function collectContext(sources: ContextSource[], lookbackDays: num
507
585
  let candidates: { name: string; full: string }[] = [];
508
586
 
509
587
  if (expanded.includes("*")) {
510
- const dir = path.dirname(expanded);
511
- const filePattern = path.basename(expanded);
512
- const regex = new RegExp("^" + filePattern.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
513
- try {
514
- candidates = fs.readdirSync(dir)
515
- .filter(f => regex.test(f))
516
- .map(f => ({ name: f, full: path.join(dir, f) }));
517
- } catch {}
588
+ candidates = findMatchingFiles(expanded);
518
589
  } else if (fs.existsSync(expanded)) {
519
590
  candidates = [{ name: path.basename(expanded), full: expanded }];
520
591
  }
@@ -588,8 +659,20 @@ export function getAvailableSessionDates(): string[] {
588
659
 
589
660
  export function buildReflectionPrompt(targetPath: string, targetContent: string, transcripts: string): string {
590
661
  const fileName = path.basename(targetPath);
662
+ const charCount = targetContent.length;
663
+ const lineCount = targetContent.split("\n").length;
591
664
  return `You are reviewing recent agent session transcripts to improve ${fileName}.
592
665
 
666
+ ## CRITICAL: Conciseness
667
+
668
+ The target file is ${lineCount} lines / ${charCount} chars. Your #1 job is to keep it CONCISE.
669
+ - Every rule should be 1-2 sentences max. If a rule is longer, condense it.
670
+ - Remove session counts, escalating repetition tallies, and "this happened N times" histories — the rule itself is what matters, not how many times it was violated.
671
+ - Remove verbose examples when the rule is self-explanatory.
672
+ - Merge rules that say the same thing in different words.
673
+ - Remove rules that are subsumed by other, better-worded rules.
674
+ - A good rule file is SHORT and scannable. Walls of text get ignored by agents.
675
+
593
676
  ## Input
594
677
 
595
678
  ### Target file: ${fileName}
@@ -604,58 +687,52 @@ ${transcripts}
604
687
 
605
688
  ## Step 1: Identify Correction Patterns
606
689
 
607
- Read through all the transcripts carefully. Look for:
608
- - User redirecting the agent ("no", "not that", "I said...", "wrong", "actually...")
609
- - User expressing frustration ("bro", "wtf", "seriously", "come on", "sigh")
610
- - User having to repeat themselves or re-explain
611
- - User asking the agent to undo or revert something
612
- - User telling the agent to simplify or stop over-engineering
613
- - User correcting the agent's approach or understanding
614
- - Agent thinking that reveals a misunderstanding that the user then corrects
690
+ Read the transcripts for genuine corrections — user redirecting the agent, expressing frustration, repeating themselves, or correcting approach. Ignore normal flow ("no worries", "actually that looks good").
615
691
 
616
- For each real correction, note: what the agent did wrong, what the user wanted, and which rule in ${fileName} (if any) already covers this.
692
+ For each correction: what the agent did wrong, what the user wanted, and which rule (if any) already covers it.
617
693
 
618
- Ignore normal conversation flow "no" in "no worries" or "actually, that looks good" are NOT corrections. Focus on genuine friction where the agent's behavior wasted the user's time.
694
+ ## Step 2: Propose Edits (prioritize conciseness)
619
695
 
620
- ## Step 2: Propose Edits
696
+ Four edit types available:
697
+ 1. **strengthen**: Tighten an existing rule's wording (make it clearer/shorter, not longer).
698
+ 2. **add**: Add a new rule for a pattern with 2+ occurrences. Keep it to 1-2 sentences.
699
+ 3. **remove**: Delete a rule that is redundant (covered by another rule), obsolete, or overly verbose noise.
700
+ 4. **merge**: Consolidate 2+ rules that overlap into one concise rule.
621
701
 
622
- Based on the patterns you found:
623
- - Only propose edits that address ACTUAL patterns in the transcripts. Don't invent hypothetical rules.
624
- - If an existing rule already covers the pattern but the agent still violated it, STRENGTHEN the wording (make it more prominent, add emphasis, add a concrete example).
625
- - If a correction pattern has no matching rule, propose a new bullet in the most appropriate existing section.
626
- - Do NOT reorganize, rewrite, or restructure the file. Propose minimal, targeted edits.
627
- - Do NOT remove any existing rules.
628
- - Do NOT add rules for one-off situations. Only add rules for patterns (2+ occurrences across different sessions).
629
- - Keep the same tone and style as the existing file.
702
+ Guidelines:
703
+ - Prefer strengthen/merge/remove over add. The file should get SHORTER or stay the same size, not grow.
704
+ - When strengthening, make the rule SHORTER and CLEARER don't add history or examples unless essential.
705
+ - Strip "This happened in N sessions", "RECURRING", session dates, escalating violation counts. The rule text is enough.
706
+ - Don't reorganize or restructure the file. Minimal, targeted edits only.
707
+ - Don't add one-off rules. Only patterns with 2+ occurrences.
630
708
 
631
709
  ## Step 3: Output
632
710
 
633
- IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no explanation, no preamble. Start with { and end with }.
711
+ IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no preamble.
634
712
 
635
- For "strengthen" edits: old_text must be a COMPLETE bullet point or rule from the file, copied character-for-character. new_text is the full replacement. Do NOT use partial strings — always include the complete line/bullet from "- **" to the end of the bullet point.
636
- For "add" edits: after_text must be a COMPLETE bullet point or line from the file, copied exactly. new_text is inserted on a new line after it. The new_text should be a complete new bullet point.
637
- CRITICAL: Never duplicate content. new_text should EXTEND or REPLACE old_text, not repeat it.
713
+ For "strengthen": old_text = COMPLETE bullet/rule copied exactly. new_text = shorter/clearer replacement.
714
+ For "add": after_text = COMPLETE bullet/line copied exactly. new_text = concise new bullet (1-2 sentences).
715
+ For "remove": old_text = COMPLETE bullet/rule to delete. new_text = "" (empty string).
716
+ For "merge": merge_sources = array of COMPLETE bullets to consolidate. new_text = single concise replacement. The merged text replaces the first source; others are removed.
638
717
 
639
718
  {
640
719
  "corrections_found": <number>,
641
720
  "sessions_with_corrections": <number>,
642
721
  "edits": [
643
722
  {
644
- "type": "strengthen" | "add",
723
+ "type": "strengthen" | "add" | "remove" | "merge",
645
724
  "section": "which section of the file",
646
- "old_text": "exact text to find (for strengthen) or null (for add)",
647
- "new_text": "replacement text (for strengthen) or new text to insert (for add)",
648
- "after_text": "text after which to insert (for add) or null (for strengthen)",
649
- "reason": "why this edit is needed, with session evidence"
725
+ "old_text": "exact text to find (strengthen/remove) or null (add/merge)",
726
+ "new_text": "replacement/new text, or empty string for remove",
727
+ "after_text": "insertion point (add only) or null",
728
+ "merge_sources": ["exact text 1", "exact text 2"] or null (merge only),
729
+ "reason": "brief reason for this edit"
650
730
  }
651
731
  ],
652
732
  "patterns_not_added": [
653
- {
654
- "pattern": "description",
655
- "reason": "why it wasn't added (one-off, already covered, etc.)"
656
- }
733
+ { "pattern": "description", "reason": "why not added" }
657
734
  ],
658
- "summary": "2-3 sentence summary of what was found and changed"
735
+ "summary": "2-3 sentence summary"
659
736
  }`;
660
737
  }
661
738
 
@@ -724,6 +801,56 @@ export function applyEdits(content: string, edits: AnalysisEdit[]): EditResult {
724
801
 
725
802
  result = result.replace(edit.after_text, edit.after_text + "\n" + edit.new_text);
726
803
  applied++;
804
+ } else if (edit.type === "remove" && edit.old_text) {
805
+ if (!result.includes(edit.old_text)) {
806
+ skipped.push(`Could not find text to remove: "${edit.old_text.slice(0, 80)}..."`);
807
+ continue;
808
+ }
809
+
810
+ const firstIdx = result.indexOf(edit.old_text);
811
+ const secondIdx = result.indexOf(edit.old_text, firstIdx + 1);
812
+ if (secondIdx !== -1) {
813
+ skipped.push(`Ambiguous match for removal (appears multiple times): "${edit.old_text.slice(0, 80)}..."`);
814
+ continue;
815
+ }
816
+
817
+ // Remove the text and any trailing blank line
818
+ result = result.replace(edit.old_text + "\n", "");
819
+ if (result.includes(edit.old_text)) {
820
+ result = result.replace(edit.old_text, "");
821
+ }
822
+ applied++;
823
+ } else if (edit.type === "merge" && edit.merge_sources && edit.merge_sources.length > 0 && edit.new_text) {
824
+ // Remove all source texts, then insert the consolidated new_text where the first source was
825
+ let firstSourceIdx = Infinity;
826
+ let firstSourceText = "";
827
+ let allFound = true;
828
+
829
+ for (const src of edit.merge_sources) {
830
+ if (!result.includes(src)) {
831
+ skipped.push(`Merge source not found: "${src.slice(0, 80)}..."`);
832
+ allFound = false;
833
+ break;
834
+ }
835
+ const idx = result.indexOf(src);
836
+ if (idx < firstSourceIdx) {
837
+ firstSourceIdx = idx;
838
+ firstSourceText = src;
839
+ }
840
+ }
841
+ if (!allFound) continue;
842
+
843
+ // Replace the first source with the merged text
844
+ result = result.replace(firstSourceText, edit.new_text);
845
+ // Remove the remaining sources
846
+ for (const src of edit.merge_sources) {
847
+ if (src === firstSourceText) continue;
848
+ result = result.replace(src + "\n", "");
849
+ if (result.includes(src)) {
850
+ result = result.replace(src, "");
851
+ }
852
+ }
853
+ applied++;
727
854
  } else {
728
855
  skipped.push(`Invalid edit: ${JSON.stringify(edit).slice(0, 100)}`);
729
856
  }
@@ -772,21 +899,8 @@ interface AnalysisResult {
772
899
  }
773
900
 
774
901
  /** 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 = {
902
+ export function buildReflectAnalysisTool() {
903
+ return {
790
904
  name: "submit_analysis",
791
905
  description: "Submit the reflection analysis results",
792
906
  parameters: {
@@ -799,12 +913,13 @@ async function analyzeTranscriptBatch(
799
913
  items: {
800
914
  type: "object",
801
915
  properties: {
802
- type: { type: "string", enum: ["strengthen", "add"], description: "strengthen = update existing text, add = insert new text" },
916
+ type: { type: "string", enum: ["strengthen", "add", "remove", "merge"], description: "strengthen = update existing text, add = insert new text, remove = delete redundant text, merge = consolidate multiple rules into one" },
803
917
  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" },
918
+ old_text: { type: "string", description: "Exact text to find for strengthen or remove; omit otherwise" },
919
+ new_text: { type: "string", description: "Replacement/new text, or empty string for remove" },
920
+ after_text: { type: "string", description: "Exact insertion point for add; omit otherwise" },
921
+ merge_sources: { type: "array", items: { type: "string" }, description: "Exact source texts for merge; omit otherwise" },
922
+ reason: { type: "string", description: "Brief reason for this edit" },
808
923
  },
809
924
  required: ["type", "new_text"],
810
925
  },
@@ -824,9 +939,27 @@ async function analyzeTranscriptBatch(
824
939
  required: ["corrections_found", "sessions_with_corrections", "edits", "summary"],
825
940
  },
826
941
  };
942
+ }
943
+
944
+ async function analyzeTranscriptBatch(
945
+ target: ReflectTarget,
946
+ targetPath: string,
947
+ targetContent: string,
948
+ transcripts: string,
949
+ context: string,
950
+ model: any,
951
+ apiKey: string,
952
+ headers: Record<string, string> | undefined,
953
+ modelLabel: string,
954
+ notify: NotifyFn,
955
+ completeFn: (model: any, request: any, options: any) => Promise<any>,
956
+ ): Promise<AnalysisResult | null> {
957
+ const prompt = buildPromptForTarget(target, targetPath, targetContent, transcripts, context);
958
+
959
+ const reflectAnalysisTool = buildReflectAnalysisTool();
827
960
 
828
961
  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.",
962
+ systemPrompt: "You are a behavioral analysis tool that prioritizes CONCISENESS. Your goal is to keep the target file short and scannable — prefer merging, removing, and tightening rules over adding new ones. The file should get shorter or stay the same size, not grow. Analyze the session transcripts and call the submit_analysis tool with your results. Always call the tool — never respond with plain text.",
830
963
  messages: [
831
964
  {
832
965
  role: "user" as const,
@@ -835,7 +968,7 @@ async function analyzeTranscriptBatch(
835
968
  },
836
969
  ],
837
970
  tools: [reflectAnalysisTool],
838
- }, { apiKey, maxTokens: 16384 });
971
+ }, { apiKey, headers, maxTokens: 16384 });
839
972
 
840
973
  if (response.stopReason === "error") {
841
974
  notify(`LLM error: ${response.errorMessage ?? 'unknown'}`, "error");
@@ -872,6 +1005,24 @@ async function analyzeTranscriptBatch(
872
1005
 
873
1006
  // --- Main reflection logic ---
874
1007
 
1008
+ export async function resolveModelAuth(
1009
+ modelRegistry: any,
1010
+ model: any,
1011
+ ): Promise<{ ok: true; apiKey?: string; headers?: Record<string, string> } | { ok: false; error?: string }> {
1012
+ if (!modelRegistry) return { ok: false, error: "model registry unavailable" };
1013
+ if (typeof modelRegistry.getApiKeyAndHeaders === "function") {
1014
+ const auth = await modelRegistry.getApiKeyAndHeaders(model);
1015
+ return auth?.ok
1016
+ ? { ok: true, apiKey: auth.apiKey, headers: auth.headers }
1017
+ : { ok: false, error: auth?.error };
1018
+ }
1019
+ if (typeof modelRegistry.getApiKey === "function") {
1020
+ const apiKey = await modelRegistry.getApiKey(model);
1021
+ return apiKey ? { ok: true, apiKey } : { ok: false, error: "No API key" };
1022
+ }
1023
+ return { ok: false, error: "unsupported model registry" };
1024
+ }
1025
+
875
1026
  export interface RunReflectionDeps {
876
1027
  completeSimple: (model: any, request: any, options: any) => Promise<any>;
877
1028
  getModel: (provider: string, modelId: string) => any;
@@ -947,14 +1098,31 @@ export async function runReflection(
947
1098
  const totalBytes = allSessions ? allSessions.reduce((sum, s) => sum + s.size, 0) : transcripts.length;
948
1099
  notify(`Extracted ${totalSessionCount} sessions (${sessionCount} scanned, ${(totalBytes / 1024).toFixed(0)}KB)`, "info");
949
1100
 
1101
+ // Dedup: skip if we already reflected on this target+sourceDate
1102
+ if (!options?.sourceDateOverride) {
1103
+ const sourceDate = new Date();
1104
+ sourceDate.setDate(sourceDate.getDate() - target.lookbackDays);
1105
+ const sourceDateStr = sourceDate.toISOString().slice(0, 10);
1106
+ const history = loadHistory();
1107
+ const alreadyRan = history.some(
1108
+ (r) => r.targetPath === targetPath && r.sourceDate === sourceDateStr,
1109
+ );
1110
+ if (alreadyRan) {
1111
+ notify(`Already reflected on ${sourceDateStr} for this target — skipping`, "info");
1112
+ return null;
1113
+ }
1114
+ }
1115
+
950
1116
  // Resolve model — prefer current session model over target.model config
951
1117
  let model: any;
952
1118
  let apiKey: string | undefined;
1119
+ let headers: Record<string, string> | undefined;
953
1120
  let modelLabel: string;
954
1121
 
955
1122
  if (options?.currentModel && options?.currentModelApiKey) {
956
1123
  model = options.currentModel;
957
1124
  apiKey = options.currentModelApiKey;
1125
+ headers = options.currentModelHeaders;
958
1126
  modelLabel = `${model.provider}/${model.id}`;
959
1127
  } else {
960
1128
  const getModelFn = deps?.getModel ?? (await import("@mariozechner/pi-ai")).getModel;
@@ -969,11 +1137,13 @@ export async function runReflection(
969
1137
  return null;
970
1138
  }
971
1139
 
972
- apiKey = await modelRegistry?.getApiKey(model);
973
- if (!apiKey) {
974
- notify(`No API key for model: ${target.model}`, "error");
1140
+ const auth = await resolveModelAuth(modelRegistry, model);
1141
+ if (!auth.ok) {
1142
+ notify(`No API key for model: ${target.model}${auth?.error ? ` (${auth.error})` : ''}`, "error");
975
1143
  return null;
976
1144
  }
1145
+ apiKey = auth.apiKey;
1146
+ headers = auth.headers;
977
1147
  modelLabel = target.model;
978
1148
  }
979
1149
 
@@ -1011,7 +1181,7 @@ export async function runReflection(
1011
1181
 
1012
1182
  const result = await analyzeTranscriptBatch(
1013
1183
  target, targetPath, currentContent, batchTranscripts, context,
1014
- model, apiKey!, modelLabel, notify, completeFn,
1184
+ model, apiKey!, headers, modelLabel, notify, completeFn,
1015
1185
  );
1016
1186
 
1017
1187
  if (!result) continue;
@@ -1041,7 +1211,7 @@ export async function runReflection(
1041
1211
  notify(`Analyzing with ${modelLabel}...`, "info");
1042
1212
  const result = await analyzeTranscriptBatch(
1043
1213
  target, targetPath, targetContent, transcripts, context,
1044
- model, apiKey!, modelLabel, notify, completeFn,
1214
+ model, apiKey!, headers, modelLabel, notify, completeFn,
1045
1215
  );
1046
1216
  if (!result) return null;
1047
1217
  allEdits = result.edits;
@@ -1078,6 +1248,7 @@ export async function runReflection(
1078
1248
  correctionRate,
1079
1249
  edits: [],
1080
1250
  sourceDate: sourceDateStr,
1251
+ fileSize: computeFileMetrics(fs.readFileSync(targetPath, "utf-8")),
1081
1252
  };
1082
1253
  }
1083
1254
 
@@ -1104,6 +1275,7 @@ export async function runReflection(
1104
1275
  correctionRate,
1105
1276
  edits: editRecords,
1106
1277
  sourceDate: sourceDateStr,
1278
+ fileSize: computeFileMetrics(fs.readFileSync(targetPath, "utf-8")),
1107
1279
  };
1108
1280
  }
1109
1281
 
@@ -1190,6 +1362,7 @@ export async function runReflection(
1190
1362
  correctionRate,
1191
1363
  edits: editRecords,
1192
1364
  sourceDate: sourceDateStr,
1365
+ fileSize: computeFileMetrics(fs.readFileSync(targetPath, "utf-8")),
1193
1366
  };
1194
1367
  }
1195
1368
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askjo/pi-reflect",
3
- "version": "1.1.0",
3
+ "version": "1.2.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",
package/release.sh ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ BUMP="${1:-patch}"
5
+ if [[ "$BUMP" != "patch" && "$BUMP" != "minor" && "$BUMP" != "major" ]]; then
6
+ echo "Usage: ./release.sh [patch|minor|major]"
7
+ exit 1
8
+ fi
9
+
10
+ cd "$(dirname "$0")"
11
+
12
+ echo "🔍 Pre-flight checks..."
13
+ if [[ -n "$(git status --porcelain)" ]]; then
14
+ echo "❌ Working tree is dirty. Commit changes first."
15
+ exit 1
16
+ fi
17
+ if [[ "$(git branch --show-current)" != "main" ]]; then
18
+ echo "❌ Not on main."
19
+ exit 1
20
+ fi
21
+ git fetch origin main --quiet
22
+ if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
23
+ echo "❌ Local main differs from origin/main."
24
+ exit 1
25
+ fi
26
+
27
+ echo "🧪 Running tests..."
28
+ npm test
29
+
30
+ echo "📦 Bumping version: $BUMP"
31
+ npm version "$BUMP" --message "v%s"
32
+ NEW_VERSION=$(node -p "require('./package.json').version")
33
+
34
+ echo "📤 Pushing main and v${NEW_VERSION} tag..."
35
+ git push origin main --follow-tags
36
+
37
+ echo "✅ Release v${NEW_VERSION} triggered"
38
+ echo " Actions: https://github.com/jo-inc/pi-reflect/actions"
39
+ echo " Package: https://www.npmjs.com/package/@askjo/pi-reflect"
@@ -345,4 +345,55 @@ describe("applyEdits", () => {
345
345
  assert.ok(result.includes("Line one, improved."));
346
346
  assert.ok(result.includes("Continuation line, also improved."));
347
347
  });
348
+
349
+ it("removes a rule with type=remove", () => {
350
+ const content = "- Rule A\n- Rule B (redundant)\n- Rule C\n";
351
+ const { result, applied } = applyEdits(content, [
352
+ { type: "remove", old_text: "- Rule B (redundant)", new_text: "" },
353
+ ]);
354
+ assert.equal(applied, 1);
355
+ assert.ok(!result.includes("Rule B"));
356
+ assert.ok(result.includes("Rule A"));
357
+ assert.ok(result.includes("Rule C"));
358
+ });
359
+
360
+ it("skips remove when text not found", () => {
361
+ const content = "- Rule A\n- Rule B\n";
362
+ const { applied, skipped } = applyEdits(content, [
363
+ { type: "remove", old_text: "- Rule X", new_text: "" },
364
+ ]);
365
+ assert.equal(applied, 0);
366
+ assert.equal(skipped.length, 1);
367
+ });
368
+
369
+ it("merges multiple rules into one", () => {
370
+ const content = "- Always check schema before SQL.\n- Verify column names before queries.\n- Other rule.\n";
371
+ const { result, applied } = applyEdits(content, [
372
+ {
373
+ type: "merge",
374
+ merge_sources: [
375
+ "- Always check schema before SQL.",
376
+ "- Verify column names before queries.",
377
+ ],
378
+ new_text: "- Always check DB schema before writing any SQL query.",
379
+ },
380
+ ]);
381
+ assert.equal(applied, 1);
382
+ assert.ok(result.includes("- Always check DB schema before writing any SQL query."));
383
+ assert.ok(!result.includes("Verify column names"));
384
+ assert.ok(result.includes("Other rule"));
385
+ });
386
+
387
+ it("skips merge when a source is not found", () => {
388
+ const content = "- Rule A\n- Rule B\n";
389
+ const { applied, skipped } = applyEdits(content, [
390
+ {
391
+ type: "merge",
392
+ merge_sources: ["- Rule A", "- Rule X"],
393
+ new_text: "- Merged rule.",
394
+ },
395
+ ]);
396
+ assert.equal(applied, 0);
397
+ assert.equal(skipped.length, 1);
398
+ });
348
399
  });
@@ -119,7 +119,7 @@ function makeTarget(overrides: Partial<ReflectTarget> = {}): ReflectTarget {
119
119
  function makeModelRegistry() {
120
120
  return {
121
121
  find: () => ({ provider: "test", id: "test-model" }),
122
- getApiKey: async () => "test-key",
122
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key", headers: undefined }),
123
123
  };
124
124
  }
125
125
 
@@ -0,0 +1,59 @@
1
+ import { afterEach, beforeEach, describe, it } 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 { collectContext } from "../extensions/reflect.js";
6
+ import { cleanup, makeTempDir } from "./helpers.js";
7
+
8
+ describe("collectContext file sources", () => {
9
+ let tmpDir: string;
10
+
11
+ beforeEach(() => {
12
+ tmpDir = makeTempDir();
13
+ });
14
+
15
+ afterEach(() => {
16
+ cleanup(tmpDir);
17
+ });
18
+
19
+ it("finds matching files in nested subdirectories for a filetype glob", async () => {
20
+ fs.mkdirSync(path.join(tmpDir, "nested", "deeper"), { recursive: true });
21
+ fs.writeFileSync(path.join(tmpDir, "top.md"), "top-level note", "utf-8");
22
+ fs.writeFileSync(path.join(tmpDir, "nested", "deeper", "child.md"), "nested note", "utf-8");
23
+ fs.writeFileSync(path.join(tmpDir, "nested", "deeper", "ignore.txt"), "ignore me", "utf-8");
24
+
25
+ const context = await collectContext([
26
+ { type: "files", label: "notes", paths: [path.join(tmpDir, "*.md")] },
27
+ ], 1);
28
+
29
+ assert.match(context, /### top\.md\ntop-level note/);
30
+ assert.match(context, /### nested\/deeper\/child\.md\nnested note/);
31
+ assert.doesNotMatch(context, /ignore me/);
32
+ });
33
+
34
+ it("supports explicit recursive glob patterns", async () => {
35
+ fs.mkdirSync(path.join(tmpDir, "a", "b"), { recursive: true });
36
+ fs.writeFileSync(path.join(tmpDir, "root.md"), "root file", "utf-8");
37
+ fs.writeFileSync(path.join(tmpDir, "a", "b", "deep.md"), "deep file", "utf-8");
38
+
39
+ const context = await collectContext([
40
+ { type: "files", label: "notes", paths: [path.join(tmpDir, "**", "*.md")] },
41
+ ], 1);
42
+
43
+ assert.match(context, /### root\.md\nroot file/);
44
+ assert.match(context, /### a\/b\/deep\.md\ndeep file/);
45
+ });
46
+
47
+ it("prunes nested dated files outside the lookback window", async () => {
48
+ fs.mkdirSync(path.join(tmpDir, "archive"), { recursive: true });
49
+ fs.writeFileSync(path.join(tmpDir, "archive", "2000-01-01-old.md"), "old note", "utf-8");
50
+ fs.writeFileSync(path.join(tmpDir, "archive", "2099-01-01-new.md"), "new note", "utf-8");
51
+
52
+ const context = await collectContext([
53
+ { type: "files", label: "dated", paths: [path.join(tmpDir, "*.md")] },
54
+ ], 1);
55
+
56
+ assert.match(context, /2099-01-01-new\.md\nnew note/);
57
+ assert.doesNotMatch(context, /2000-01-01-old\.md\nold note/);
58
+ });
59
+ });