@askjo/pi-reflect 1.0.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/logo-dark.png ADDED
Binary file
package/logo.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@askjo/pi-reflect",
3
+ "version": "1.0.0",
4
+ "description": "Self-improving behavioral files for pi coding agents. Analyzes session transcripts for correction patterns and makes surgical edits to prevent recurrence.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "coding-agent",
10
+ "self-improvement",
11
+ "agents-md",
12
+ "behavioral-rules",
13
+ "reflection"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Pradeep Elankumaran <pradeepe@gmail.com>",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/jo-inc/pi-reflect.git"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/jo-inc/pi-reflect/issues"
23
+ },
24
+ "homepage": "https://github.com/jo-inc/pi-reflect#readme",
25
+ "type": "module",
26
+ "scripts": {
27
+ "test": "node --import tsx --test tests/*.test.ts"
28
+ },
29
+ "pi": {
30
+ "extensions": [
31
+ "./extensions/index.ts"
32
+ ],
33
+ "image": "https://raw.githubusercontent.com/jo-inc/pi-reflect/main/logo.png"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "peerDependencies": {
39
+ "@mariozechner/pi-coding-agent": "*",
40
+ "@mariozechner/pi-ai": "*"
41
+ },
42
+ "devDependencies": {
43
+ "tsx": "^4.19.0",
44
+ "@types/node": "^22.0.0"
45
+ }
46
+ }
@@ -0,0 +1,348 @@
1
+ import { describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import { applyEdits, type AnalysisEdit } from "../extensions/reflect.js";
4
+ import { SAMPLE_AGENTS_MD } from "./helpers.js";
5
+
6
+ describe("applyEdits", () => {
7
+ // --- strengthen ---
8
+
9
+ it("applies a basic strengthen edit", () => {
10
+ const content = "- **Rule one**: Do the thing.\n- **Rule two**: Do another thing.";
11
+ const edits: AnalysisEdit[] = [{
12
+ type: "strengthen",
13
+ old_text: "- **Rule one**: Do the thing.",
14
+ new_text: "- **Rule one** (CRITICAL): Do the thing immediately and without hesitation.",
15
+ }];
16
+ const { result, applied, skipped } = applyEdits(content, edits);
17
+ assert.equal(applied, 1);
18
+ assert.equal(skipped.length, 0);
19
+ assert.ok(result.includes("(CRITICAL): Do the thing immediately"));
20
+ assert.ok(!result.includes("Do the thing."));
21
+ assert.ok(result.includes("- **Rule two**: Do another thing."));
22
+ });
23
+
24
+ it("skips strengthen when old_text not found", () => {
25
+ const content = "- **Rule one**: Do the thing.";
26
+ const edits: AnalysisEdit[] = [{
27
+ type: "strengthen",
28
+ old_text: "- **Rule one**: Do something completely different.",
29
+ new_text: "- **Rule one**: Replacement.",
30
+ }];
31
+ const { result, applied, skipped } = applyEdits(content, edits);
32
+ assert.equal(applied, 0);
33
+ assert.equal(skipped.length, 1);
34
+ assert.ok(skipped[0].includes("Could not find"));
35
+ assert.equal(result, content);
36
+ });
37
+
38
+ it("skips strengthen when old_text appears multiple times (ambiguous)", () => {
39
+ const content = "- Do the thing.\n- Do the thing.";
40
+ const edits: AnalysisEdit[] = [{
41
+ type: "strengthen",
42
+ old_text: "- Do the thing.",
43
+ new_text: "- Do the thing better.",
44
+ }];
45
+ const { result, applied, skipped } = applyEdits(content, edits);
46
+ assert.equal(applied, 0);
47
+ assert.equal(skipped.length, 1);
48
+ assert.ok(skipped[0].includes("Ambiguous"));
49
+ assert.equal(result, content);
50
+ });
51
+
52
+ it("detects duplication in replacement text", () => {
53
+ // old_text is >50 chars, and new_text contains the first 50 chars twice
54
+ const oldText = "- **ALWAYS read existing code before writing any code**. The #1 source of rework is acting before understanding.";
55
+ const newText = oldText + " " + oldText; // obvious duplication
56
+ const content = `# Rules\n${oldText}\n- Other rule.`;
57
+ const edits: AnalysisEdit[] = [{
58
+ type: "strengthen",
59
+ old_text: oldText,
60
+ new_text: newText,
61
+ }];
62
+ const { applied, skipped } = applyEdits(content, edits);
63
+ assert.equal(applied, 0);
64
+ assert.equal(skipped.length, 1);
65
+ assert.ok(skipped[0].includes("Duplication"));
66
+ });
67
+
68
+ it("allows strengthen when old_text < 50 chars (skips duplication check)", () => {
69
+ const content = "- Short rule here.\n- Another rule.";
70
+ const edits: AnalysisEdit[] = [{
71
+ type: "strengthen",
72
+ old_text: "- Short rule here.",
73
+ new_text: "- Short rule here, but stronger.",
74
+ }];
75
+ const { applied, skipped } = applyEdits(content, edits);
76
+ assert.equal(applied, 1);
77
+ assert.equal(skipped.length, 0);
78
+ });
79
+
80
+ it("applies strengthen on the realistic AGENTS.md fixture", () => {
81
+ const edits: AnalysisEdit[] = [{
82
+ type: "strengthen",
83
+ old_text: '- **Execute first, explain minimally**: Do the work, then say what you did in 1-2 sentences.',
84
+ new_text: '- **Execute first, explain minimally**: Do the work, then say what you did in 1-2 sentences. Skip "Let me explain..." and "Here\'s what I\'ll do..." intros — just do it.',
85
+ }];
86
+ const { result, applied } = applyEdits(SAMPLE_AGENTS_MD, edits);
87
+ assert.equal(applied, 1);
88
+ assert.ok(result.includes('Skip "Let me explain..."'));
89
+ // Original rule is replaced, not duplicated
90
+ const count = (result.match(/Execute first, explain minimally/g) || []).length;
91
+ assert.equal(count, 1);
92
+ });
93
+
94
+ // --- add ---
95
+
96
+ it("applies a basic add edit", () => {
97
+ const content = "- Rule A.\n- Rule B.";
98
+ const edits: AnalysisEdit[] = [{
99
+ type: "add",
100
+ after_text: "- Rule A.",
101
+ new_text: "- Rule A-prime: A new rule inserted after A.",
102
+ }];
103
+ const { result, applied, skipped } = applyEdits(content, edits);
104
+ assert.equal(applied, 1);
105
+ assert.equal(skipped.length, 0);
106
+ const lines = result.split("\n");
107
+ assert.equal(lines[0], "- Rule A.");
108
+ assert.equal(lines[1], "- Rule A-prime: A new rule inserted after A.");
109
+ assert.equal(lines[2], "- Rule B.");
110
+ });
111
+
112
+ it("skips add when after_text not found", () => {
113
+ const content = "- Rule A.\n- Rule B.";
114
+ const edits: AnalysisEdit[] = [{
115
+ type: "add",
116
+ after_text: "- Nonexistent rule.",
117
+ new_text: "- New rule.",
118
+ }];
119
+ const { applied, skipped } = applyEdits(content, edits);
120
+ assert.equal(applied, 0);
121
+ assert.ok(skipped[0].includes("Could not find insertion point"));
122
+ });
123
+
124
+ it("skips add when after_text is ambiguous", () => {
125
+ const content = "- Same rule.\n- Other stuff.\n- Same rule.";
126
+ const edits: AnalysisEdit[] = [{
127
+ type: "add",
128
+ after_text: "- Same rule.",
129
+ new_text: "- New rule.",
130
+ }];
131
+ const { applied, skipped } = applyEdits(content, edits);
132
+ assert.equal(applied, 0);
133
+ assert.ok(skipped[0].includes("Ambiguous"));
134
+ });
135
+
136
+ it("skips add when new_text already exists in file (dedup)", () => {
137
+ const content = "- Rule A.\n- Rule B.\n- Already here.";
138
+ const edits: AnalysisEdit[] = [{
139
+ type: "add",
140
+ after_text: "- Rule A.",
141
+ new_text: "- Already here.",
142
+ }];
143
+ const { applied, skipped } = applyEdits(content, edits);
144
+ assert.equal(applied, 0);
145
+ assert.ok(skipped[0].includes("already exists"));
146
+ });
147
+
148
+ it("dedup check trims whitespace before comparing", () => {
149
+ const content = "- Rule A.\n- Rule B.\n- Already here.";
150
+ const edits: AnalysisEdit[] = [{
151
+ type: "add",
152
+ after_text: "- Rule A.",
153
+ new_text: " - Already here. ",
154
+ }];
155
+ const { applied, skipped } = applyEdits(content, edits);
156
+ assert.equal(applied, 0);
157
+ assert.ok(skipped[0].includes("already exists"));
158
+ });
159
+
160
+ it("adds to realistic AGENTS.md fixture", () => {
161
+ const edits: AnalysisEdit[] = [{
162
+ type: "add",
163
+ after_text: "- **Keep code DRY**: NEVER duplicate logic.",
164
+ new_text: '- **Check recent git commits when debugging**: ALWAYS run `git log --oneline -10` before diagnosing issues.',
165
+ }];
166
+ const { result, applied } = applyEdits(SAMPLE_AGENTS_MD, edits);
167
+ assert.equal(applied, 1);
168
+ assert.ok(result.includes("Check recent git commits"));
169
+ // Verify it's after the DRY rule
170
+ const dryIdx = result.indexOf("Keep code DRY");
171
+ const newIdx = result.indexOf("Check recent git commits");
172
+ assert.ok(newIdx > dryIdx);
173
+ });
174
+
175
+ // --- multiple edits ---
176
+
177
+ it("applies multiple edits in sequence", () => {
178
+ const edits: AnalysisEdit[] = [
179
+ {
180
+ type: "strengthen",
181
+ old_text: "- **Keep code DRY**: NEVER duplicate logic.",
182
+ new_text: "- **Keep code DRY (CRITICAL)**: NEVER duplicate logic. If two call sites need different behavior, add parameters.",
183
+ },
184
+ {
185
+ type: "add",
186
+ after_text: "- **NEVER push to main/master**: Pushing to main triggers production deployment.",
187
+ new_text: "- **Never deploy without explicit instruction**: Commit changes and report ready — user decides when to push.",
188
+ },
189
+ ];
190
+ const { result, applied, skipped } = applyEdits(SAMPLE_AGENTS_MD, edits);
191
+ assert.equal(applied, 2);
192
+ assert.equal(skipped.length, 0);
193
+ assert.ok(result.includes("Keep code DRY (CRITICAL)"));
194
+ assert.ok(result.includes("Never deploy without explicit instruction"));
195
+ });
196
+
197
+ it("later edits see results of earlier edits (sequential application)", () => {
198
+ const content = "- Rule A: original.\n- Rule B: original.";
199
+ const edits: AnalysisEdit[] = [
200
+ {
201
+ type: "strengthen",
202
+ old_text: "- Rule A: original.",
203
+ new_text: "- Rule A: modified.",
204
+ },
205
+ {
206
+ type: "add",
207
+ after_text: "- Rule A: modified.",
208
+ new_text: "- Rule A-bis: added after modified A.",
209
+ },
210
+ ];
211
+ const { result, applied } = applyEdits(content, edits);
212
+ assert.equal(applied, 2);
213
+ assert.ok(result.includes("Rule A: modified."));
214
+ assert.ok(result.includes("Rule A-bis: added after modified A."));
215
+ });
216
+
217
+ it("partial success: some edits apply, some skip", () => {
218
+ const content = "- Real rule.\n- Other rule.";
219
+ const edits: AnalysisEdit[] = [
220
+ {
221
+ type: "strengthen",
222
+ old_text: "- Real rule.",
223
+ new_text: "- Real rule, now stronger.",
224
+ },
225
+ {
226
+ type: "strengthen",
227
+ old_text: "- Ghost rule that does not exist.",
228
+ new_text: "- This should not apply.",
229
+ },
230
+ ];
231
+ const { result, applied, skipped } = applyEdits(content, edits);
232
+ assert.equal(applied, 1);
233
+ assert.equal(skipped.length, 1);
234
+ assert.ok(result.includes("now stronger"));
235
+ assert.ok(!result.includes("should not apply"));
236
+ });
237
+
238
+ // --- invalid edits ---
239
+
240
+ it("skips edit with missing type", () => {
241
+ const content = "- Rule.";
242
+ const edits = [{ new_text: "- Replacement." }] as any;
243
+ const { applied, skipped } = applyEdits(content, edits);
244
+ assert.equal(applied, 0);
245
+ assert.ok(skipped[0].includes("Invalid edit"));
246
+ });
247
+
248
+ it("skips strengthen with null old_text", () => {
249
+ const content = "- Rule.";
250
+ const edits: AnalysisEdit[] = [{
251
+ type: "strengthen",
252
+ old_text: null,
253
+ new_text: "- Replacement.",
254
+ }];
255
+ const { applied, skipped } = applyEdits(content, edits);
256
+ assert.equal(applied, 0);
257
+ assert.equal(skipped.length, 1);
258
+ });
259
+
260
+ it("skips add with null after_text", () => {
261
+ const content = "- Rule.";
262
+ const edits: AnalysisEdit[] = [{
263
+ type: "add",
264
+ after_text: null,
265
+ new_text: "- New rule.",
266
+ }];
267
+ const { applied, skipped } = applyEdits(content, edits);
268
+ assert.equal(applied, 0);
269
+ assert.equal(skipped.length, 1);
270
+ });
271
+
272
+ it("skips add with empty new_text", () => {
273
+ const content = "- Rule.";
274
+ const edits: AnalysisEdit[] = [{
275
+ type: "add",
276
+ after_text: "- Rule.",
277
+ new_text: "",
278
+ }];
279
+ const { applied, skipped } = applyEdits(content, edits);
280
+ assert.equal(applied, 0);
281
+ assert.equal(skipped.length, 1);
282
+ });
283
+
284
+ // --- edge cases ---
285
+
286
+ it("handles empty edits array", () => {
287
+ const content = "- Rule.";
288
+ const { result, applied, skipped } = applyEdits(content, []);
289
+ assert.equal(applied, 0);
290
+ assert.equal(skipped.length, 0);
291
+ assert.equal(result, content);
292
+ });
293
+
294
+ it("handles empty content", () => {
295
+ const edits: AnalysisEdit[] = [{
296
+ type: "strengthen",
297
+ old_text: "- Rule.",
298
+ new_text: "- Better rule.",
299
+ }];
300
+ const { applied, skipped } = applyEdits("", edits);
301
+ assert.equal(applied, 0);
302
+ assert.equal(skipped.length, 1);
303
+ });
304
+
305
+ it("preserves content before and after the edit region exactly", () => {
306
+ const before = "# Header\n\nSome preamble text.\n\n";
307
+ const target = "- **Target rule**: Original wording.";
308
+ const after = "\n\n## Footer\n\nClosing text.";
309
+ const content = before + target + after;
310
+ const edits: AnalysisEdit[] = [{
311
+ type: "strengthen",
312
+ old_text: target,
313
+ new_text: "- **Target rule**: Improved wording.",
314
+ }];
315
+ const { result, applied } = applyEdits(content, edits);
316
+ assert.equal(applied, 1);
317
+ assert.ok(result.startsWith(before));
318
+ assert.ok(result.endsWith(after));
319
+ assert.ok(result.includes("Improved wording."));
320
+ });
321
+
322
+ it("handles regex special characters in old_text for duplication check", () => {
323
+ // old_text contains chars that are regex-special: ( ) . * + ?
324
+ const oldText = "- **Rule (important)**: Use regex.* patterns? Yes, absolutely. Use $HOME and [brackets].";
325
+ const content = `# Rules\n${oldText}\n- Other.`;
326
+ const edits: AnalysisEdit[] = [{
327
+ type: "strengthen",
328
+ old_text: oldText,
329
+ new_text: "- **Rule (important, CRITICAL)**: Use regex.* patterns? Yes, absolutely. Use $HOME and [brackets]. Always.",
330
+ }];
331
+ const { applied, skipped } = applyEdits(content, edits);
332
+ assert.equal(applied, 1);
333
+ assert.equal(skipped.length, 0);
334
+ });
335
+
336
+ it("handles multiline old_text in strengthen", () => {
337
+ const content = "- **Rule**: Line one.\n Continuation line.\n- Next rule.";
338
+ const edits: AnalysisEdit[] = [{
339
+ type: "strengthen",
340
+ old_text: "- **Rule**: Line one.\n Continuation line.",
341
+ new_text: "- **Rule**: Line one, improved.\n Continuation line, also improved.",
342
+ }];
343
+ const { result, applied } = applyEdits(content, edits);
344
+ assert.equal(applied, 1);
345
+ assert.ok(result.includes("Line one, improved."));
346
+ assert.ok(result.includes("Continuation line, also improved."));
347
+ });
348
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it } from "node:test";
2
+ import * as assert from "node:assert/strict";
3
+ import { collectTranscriptsFromCommand } from "../extensions/reflect.js";
4
+
5
+ describe("collectTranscriptsFromCommand", () => {
6
+ it("executes a command and returns its output", async () => {
7
+ const result = await collectTranscriptsFromCommand("echo 'hello world'", 1, 1024 * 1024);
8
+ assert.ok(result.transcripts.includes("hello world"));
9
+ assert.equal(result.sessionCount, 1); // no ### Session: headers, defaults to 1
10
+ assert.equal(result.includedCount, 1);
11
+ });
12
+
13
+ it("interpolates {lookbackDays} in command", async () => {
14
+ const result = await collectTranscriptsFromCommand("echo 'days={lookbackDays}'", 7, 1024 * 1024);
15
+ assert.ok(result.transcripts.includes("days=7"));
16
+ });
17
+
18
+ it("interpolates multiple {lookbackDays} occurrences", async () => {
19
+ const result = await collectTranscriptsFromCommand("echo '{lookbackDays} and {lookbackDays}'", 3, 1024 * 1024);
20
+ assert.ok(result.transcripts.includes("3 and 3"));
21
+ });
22
+
23
+ it("counts ### Session: headers for session count", async () => {
24
+ const multiSession = "echo '### Session: one\\n### Session: two\\n### Session: three'";
25
+ const result = await collectTranscriptsFromCommand(multiSession, 1, 1024 * 1024);
26
+ assert.equal(result.sessionCount, 3);
27
+ });
28
+
29
+ it("truncates output exceeding maxBytes", async () => {
30
+ // maxBytes=500, maxBuffer=1000. Command outputs 800 bytes (> maxBytes but < maxBuffer).
31
+ // Output should succeed but get truncated to 500 + truncation message.
32
+ const result = await collectTranscriptsFromCommand(
33
+ "printf '%0800d' 0",
34
+ 1,
35
+ 500,
36
+ );
37
+ assert.ok(result.transcripts.length > 500, `Expected > 500 bytes, got ${result.transcripts.length}`);
38
+ assert.ok(result.transcripts.length < 800, `Expected < 800 bytes (trimmed), got ${result.transcripts.length}`);
39
+ assert.ok(result.transcripts.includes("[...truncated"));
40
+ });
41
+
42
+ it("returns empty on command failure", async () => {
43
+ const result = await collectTranscriptsFromCommand("false", 1, 1024);
44
+ assert.equal(result.transcripts, "");
45
+ assert.equal(result.sessionCount, 0);
46
+ assert.equal(result.includedCount, 0);
47
+ });
48
+
49
+ it("returns empty on nonexistent command", async () => {
50
+ const result = await collectTranscriptsFromCommand("nonexistent_command_xyz_123", 1, 1024);
51
+ assert.equal(result.transcripts, "");
52
+ assert.equal(result.sessionCount, 0);
53
+ });
54
+ });
@@ -0,0 +1,175 @@
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 { makeTempDir, cleanup } from "./helpers.js";
6
+
7
+ /**
8
+ * Config and history tests.
9
+ *
10
+ * loadConfig/saveConfig/loadHistory/saveHistory read from module-level constants
11
+ * (CONFIG_FILE, HISTORY_FILE). We can't easily redirect those without env var overrides.
12
+ * Instead, we test the serialization/deserialization logic by writing temp files
13
+ * and verifying the JSON structure matches what the functions expect.
14
+ */
15
+
16
+ describe("config serialization", () => {
17
+ let tmpDir: string;
18
+
19
+ beforeEach(() => {
20
+ tmpDir = makeTempDir();
21
+ });
22
+
23
+ afterEach(() => {
24
+ cleanup(tmpDir);
25
+ });
26
+
27
+ it("config round-trips through JSON correctly", () => {
28
+ const config = {
29
+ targets: [
30
+ {
31
+ path: "/path/to/AGENTS.md",
32
+ schedule: "daily",
33
+ model: "anthropic/claude-sonnet-4-5",
34
+ lookbackDays: 1,
35
+ maxSessionBytes: 614400,
36
+ backupDir: "~/.pi/agent/reflect-backups",
37
+ transcriptSource: { type: "pi-sessions" },
38
+ },
39
+ ],
40
+ };
41
+ const fp = path.join(tmpDir, "config.json");
42
+ fs.writeFileSync(fp, JSON.stringify(config, null, 2));
43
+ const parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
44
+ assert.deepEqual(parsed, config);
45
+ });
46
+
47
+ it("config with command transcript source round-trips", () => {
48
+ const config = {
49
+ targets: [
50
+ {
51
+ path: "/path/to/SOUL.md",
52
+ schedule: "daily",
53
+ model: "anthropic/claude-sonnet-4-5",
54
+ lookbackDays: 7,
55
+ maxSessionBytes: 614400,
56
+ backupDir: "~/.pi/agent/reflect-backups",
57
+ transcriptSource: {
58
+ type: "command",
59
+ command: "python extract.py {lookbackDays}",
60
+ },
61
+ },
62
+ ],
63
+ };
64
+ const fp = path.join(tmpDir, "config.json");
65
+ fs.writeFileSync(fp, JSON.stringify(config, null, 2));
66
+ const parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
67
+ assert.equal(parsed.targets[0].transcriptSource.type, "command");
68
+ assert.equal(parsed.targets[0].transcriptSource.command, "python extract.py {lookbackDays}");
69
+ });
70
+
71
+ it("partial config gets defaults merged", () => {
72
+ // Simulate what loadConfig does: merge with DEFAULT_TARGET
73
+ const DEFAULT_TARGET = {
74
+ path: "",
75
+ schedule: "daily",
76
+ model: "anthropic/claude-sonnet-4-5",
77
+ lookbackDays: 1,
78
+ maxSessionBytes: 600 * 1024,
79
+ backupDir: "default-backup",
80
+ transcriptSource: { type: "pi-sessions" },
81
+ };
82
+ const partial = { path: "/my/file.md" };
83
+ const merged = { ...DEFAULT_TARGET, ...partial };
84
+ assert.equal(merged.path, "/my/file.md");
85
+ assert.equal(merged.model, "anthropic/claude-sonnet-4-5");
86
+ assert.equal(merged.lookbackDays, 1);
87
+ assert.equal(merged.transcriptSource.type, "pi-sessions");
88
+ });
89
+
90
+ it("target-level overrides take precedence over defaults", () => {
91
+ const DEFAULT_TARGET = {
92
+ path: "",
93
+ schedule: "daily" as const,
94
+ model: "anthropic/claude-sonnet-4-5",
95
+ lookbackDays: 1,
96
+ maxSessionBytes: 600 * 1024,
97
+ backupDir: "default-backup",
98
+ transcriptSource: { type: "pi-sessions" as const },
99
+ };
100
+ const override = {
101
+ path: "/my/file.md",
102
+ model: "google/gemini-2.5-pro",
103
+ lookbackDays: 7,
104
+ };
105
+ const merged = { ...DEFAULT_TARGET, ...override };
106
+ assert.equal(merged.model, "google/gemini-2.5-pro");
107
+ assert.equal(merged.lookbackDays, 7);
108
+ assert.equal(merged.schedule, "daily"); // kept default
109
+ });
110
+ });
111
+
112
+ describe("history serialization", () => {
113
+ let tmpDir: string;
114
+
115
+ beforeEach(() => {
116
+ tmpDir = makeTempDir();
117
+ });
118
+
119
+ afterEach(() => {
120
+ cleanup(tmpDir);
121
+ });
122
+
123
+ it("history round-trips through JSON", () => {
124
+ const runs = [
125
+ {
126
+ timestamp: "2026-02-12T20:22:25.000Z",
127
+ targetPath: "/path/to/AGENTS.md",
128
+ sessionsAnalyzed: 48,
129
+ correctionsFound: 135,
130
+ editsApplied: 7,
131
+ summary: "7 edits applied.",
132
+ diffLines: 20,
133
+ },
134
+ ];
135
+ const fp = path.join(tmpDir, "history.json");
136
+ fs.writeFileSync(fp, JSON.stringify(runs, null, 2));
137
+ const parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
138
+ assert.deepEqual(parsed, runs);
139
+ });
140
+
141
+ it("history is trimmed to last 100 runs", () => {
142
+ const runs = Array.from({ length: 150 }, (_, i) => ({
143
+ timestamp: `2026-01-${String(i).padStart(3, "0")}`,
144
+ targetPath: "/path/to/AGENTS.md",
145
+ sessionsAnalyzed: 10,
146
+ correctionsFound: 5,
147
+ editsApplied: 2,
148
+ summary: `Run ${i}`,
149
+ diffLines: 3,
150
+ }));
151
+ const trimmed = runs.slice(-100);
152
+ assert.equal(trimmed.length, 100);
153
+ assert.equal(trimmed[0].summary, "Run 50");
154
+ assert.equal(trimmed[99].summary, "Run 149");
155
+ });
156
+
157
+ it("empty history returns empty array from JSON parse", () => {
158
+ const fp = path.join(tmpDir, "history.json");
159
+ fs.writeFileSync(fp, "[]");
160
+ const parsed = JSON.parse(fs.readFileSync(fp, "utf-8"));
161
+ assert.deepEqual(parsed, []);
162
+ });
163
+
164
+ it("malformed history file returns fallback empty array", () => {
165
+ const fp = path.join(tmpDir, "history.json");
166
+ fs.writeFileSync(fp, "not json");
167
+ let result: any[];
168
+ try {
169
+ result = JSON.parse(fs.readFileSync(fp, "utf-8"));
170
+ } catch {
171
+ result = [];
172
+ }
173
+ assert.deepEqual(result, []);
174
+ });
175
+ });