@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.
- package/.github/FUNDING.yml +1 -0
- package/.github/workflows/publish.yml +46 -0
- package/README.md +14 -0
- package/extensions/index.ts +100 -33
- package/extensions/reflect.ts +243 -70
- package/package.json +1 -1
- package/release.sh +39 -0
- package/tests/apply-edits.test.ts +51 -0
- package/tests/batching.test.ts +1 -1
- package/tests/context-files.test.ts +59 -0
- package/tests/helpers-and-utils.test.ts +29 -0
- package/tests/prompt-building.test.ts +9 -17
- package/tests/reflect.test.ts +539 -0
- package/tests/run-reflection.test.ts +33 -4
package/extensions/reflect.ts
CHANGED
|
@@ -63,7 +63,7 @@ export interface ReflectConfig {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export interface EditRecord {
|
|
66
|
-
type: "strengthen" | "add";
|
|
66
|
+
type: "strengthen" | "add" | "remove" | "merge";
|
|
67
67
|
section: string;
|
|
68
68
|
reason: string;
|
|
69
69
|
}
|
|
@@ -80,6 +80,8 @@ export interface ReflectRun {
|
|
|
80
80
|
edits?: EditRecord[];
|
|
81
81
|
sourceDate?: string;
|
|
82
82
|
date?: string; // legacy field from bash-script/batch runs
|
|
83
|
+
/** File size metrics after this run */
|
|
84
|
+
fileSize?: { chars: number; words: number; lines: number; estTokens: number };
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
export interface SessionExchange {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
692
|
+
For each correction: what the agent did wrong, what the user wanted, and which rule (if any) already covers it.
|
|
617
693
|
|
|
618
|
-
|
|
694
|
+
## Step 2: Propose Edits (prioritize conciseness)
|
|
619
695
|
|
|
620
|
-
|
|
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
|
-
|
|
623
|
-
-
|
|
624
|
-
-
|
|
625
|
-
-
|
|
626
|
-
-
|
|
627
|
-
-
|
|
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
|
|
711
|
+
IMPORTANT: Your ENTIRE response must be a single JSON object. No markdown, no preamble.
|
|
634
712
|
|
|
635
|
-
For "strengthen"
|
|
636
|
-
For "add"
|
|
637
|
-
|
|
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 (
|
|
647
|
-
"new_text": "replacement text
|
|
648
|
-
"after_text": "
|
|
649
|
-
"
|
|
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
|
|
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
|
-
|
|
776
|
-
|
|
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:
|
|
805
|
-
new_text: { type: "string", description: "Replacement text
|
|
806
|
-
after_text: { type:
|
|
807
|
-
|
|
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
|
-
|
|
973
|
-
if (!
|
|
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.
|
|
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
|
});
|
package/tests/batching.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
});
|