@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/LICENSE +21 -0
- package/README.md +167 -0
- package/SETUP.md +168 -0
- package/extensions/index.ts +441 -0
- package/extensions/reflect.ts +996 -0
- package/logo-dark.png +0 -0
- package/logo.png +0 -0
- package/package.json +46 -0
- package/tests/apply-edits.test.ts +348 -0
- package/tests/command-source.test.ts +54 -0
- package/tests/config-and-history.test.ts +175 -0
- package/tests/helpers-and-utils.test.ts +174 -0
- package/tests/helpers.ts +84 -0
- package/tests/prompt-building.test.ts +85 -0
- package/tests/realistic-edits.test.ts +227 -0
- package/tests/run-reflection.test.ts +337 -0
- package/tests/session-extraction.test.ts +484 -0
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reflect — Self-improving behavioral files for pi coding agents.
|
|
3
|
+
*
|
|
4
|
+
* Commands:
|
|
5
|
+
* /reflect [path] — Run reflection on a file (or configured default)
|
|
6
|
+
* /reflect-config — Show/edit reflection configuration
|
|
7
|
+
* /reflect-history — Show recent reflection runs
|
|
8
|
+
* /reflect-stats — Show impact metrics (correction rate trend + rule recidivism)
|
|
9
|
+
* /reflect-backfill — Backfill stats for all historical session dates
|
|
10
|
+
*
|
|
11
|
+
* Headless execution for cron/launchd:
|
|
12
|
+
* pi -p --no-session "/reflect /path/to/AGENTS.md"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type ReflectTarget,
|
|
20
|
+
type ReflectRun,
|
|
21
|
+
type NotifyFn,
|
|
22
|
+
DEFAULT_TARGET,
|
|
23
|
+
loadConfig,
|
|
24
|
+
saveConfig,
|
|
25
|
+
loadHistory,
|
|
26
|
+
saveHistory,
|
|
27
|
+
resolvePath,
|
|
28
|
+
runReflection,
|
|
29
|
+
collectTranscriptsForDate,
|
|
30
|
+
getAvailableSessionDates,
|
|
31
|
+
CONFIG_FILE,
|
|
32
|
+
} from "./reflect.js";
|
|
33
|
+
|
|
34
|
+
export default function (pi: ExtensionAPI) {
|
|
35
|
+
let modelRegistryRef: any = null;
|
|
36
|
+
|
|
37
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
38
|
+
modelRegistryRef = ctx.modelRegistry;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
pi.registerCommand("reflect", {
|
|
42
|
+
description: "Reflect on recent sessions and improve a behavioral markdown file",
|
|
43
|
+
handler: async (args, ctx) => {
|
|
44
|
+
modelRegistryRef = ctx.modelRegistry;
|
|
45
|
+
const targetPath = args?.trim();
|
|
46
|
+
|
|
47
|
+
let target: ReflectTarget;
|
|
48
|
+
|
|
49
|
+
if (targetPath) {
|
|
50
|
+
const config = loadConfig();
|
|
51
|
+
const existing = config.targets.find(
|
|
52
|
+
(t) => resolvePath(t.path) === resolvePath(targetPath),
|
|
53
|
+
);
|
|
54
|
+
target = existing ?? { ...DEFAULT_TARGET, path: targetPath };
|
|
55
|
+
} else {
|
|
56
|
+
const config = loadConfig();
|
|
57
|
+
if (config.targets.length === 0) {
|
|
58
|
+
if (ctx.hasUI) {
|
|
59
|
+
const filePath = await ctx.ui.input(
|
|
60
|
+
"No targets configured. Enter path to a markdown file to reflect on:",
|
|
61
|
+
);
|
|
62
|
+
if (!filePath) return;
|
|
63
|
+
target = { ...DEFAULT_TARGET, path: filePath };
|
|
64
|
+
|
|
65
|
+
const save = await ctx.ui.confirm(
|
|
66
|
+
"Save target?",
|
|
67
|
+
`Save ${filePath} as a reflection target for next time?`,
|
|
68
|
+
);
|
|
69
|
+
if (save) {
|
|
70
|
+
config.targets.push(target);
|
|
71
|
+
saveConfig(config);
|
|
72
|
+
ctx.ui.notify("Saved to reflect.json", "info");
|
|
73
|
+
}
|
|
74
|
+
} else {
|
|
75
|
+
console.error("No targets configured. Use: /reflect <path>");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
} else if (config.targets.length === 1) {
|
|
79
|
+
target = config.targets[0];
|
|
80
|
+
} else if (ctx.hasUI) {
|
|
81
|
+
const choice = await ctx.ui.select(
|
|
82
|
+
"Which target?",
|
|
83
|
+
config.targets.map((t) => path.basename(t.path)),
|
|
84
|
+
);
|
|
85
|
+
if (choice === undefined) return;
|
|
86
|
+
target = config.targets[choice];
|
|
87
|
+
} else {
|
|
88
|
+
target = config.targets[0];
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const notify: NotifyFn = ctx.hasUI
|
|
93
|
+
? (msg, level) => ctx.ui.notify(msg, level)
|
|
94
|
+
: (msg, level) => console.log(`[reflect] [${level}] ${msg}`);
|
|
95
|
+
|
|
96
|
+
const run = await runReflection(target, modelRegistryRef, notify);
|
|
97
|
+
|
|
98
|
+
if (run) {
|
|
99
|
+
const history = loadHistory();
|
|
100
|
+
history.push(run);
|
|
101
|
+
saveHistory(history);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.registerCommand("reflect-config", {
|
|
107
|
+
description: "Show and manage reflection targets",
|
|
108
|
+
handler: async (_args, ctx) => {
|
|
109
|
+
const config = loadConfig();
|
|
110
|
+
|
|
111
|
+
if (!ctx.hasUI) {
|
|
112
|
+
console.log(JSON.stringify(config, null, 2));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (config.targets.length === 0) {
|
|
117
|
+
ctx.ui.notify("No targets configured. Use /reflect <path> to add one.", "info");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const lines = config.targets.map((t, i) => {
|
|
122
|
+
return `${i + 1}. **${path.basename(t.path)}** — ${t.schedule}, ${t.model}, ${t.lookbackDays}d lookback\n ${t.path}`;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
ctx.ui.notify(
|
|
126
|
+
`Reflection targets:\n${lines.join("\n")}\n\nEdit: ${CONFIG_FILE}`,
|
|
127
|
+
"info",
|
|
128
|
+
);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
pi.registerCommand("reflect-history", {
|
|
133
|
+
description: "Show recent reflection runs",
|
|
134
|
+
handler: async (_args, ctx) => {
|
|
135
|
+
const history = loadHistory();
|
|
136
|
+
|
|
137
|
+
if (history.length === 0) {
|
|
138
|
+
if (ctx.hasUI) {
|
|
139
|
+
ctx.ui.notify("No reflection runs yet. Use /reflect to run one.", "info");
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const recent = history.slice(-10).reverse();
|
|
145
|
+
const lines = recent.map((r) => {
|
|
146
|
+
const date = r.timestamp.slice(0, 16).replace("T", " ");
|
|
147
|
+
const file = path.basename(r.targetPath);
|
|
148
|
+
return `- **${date}** ${file}: ${r.editsApplied} edits, ${r.correctionsFound} corrections (${r.sessionsAnalyzed} sessions)\n ${r.summary}`;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (ctx.hasUI) {
|
|
152
|
+
ctx.ui.notify(`Recent reflections:\n${lines.join("\n")}`, "info");
|
|
153
|
+
} else {
|
|
154
|
+
console.log(lines.join("\n"));
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// /reflect-stats — show impact metrics
|
|
160
|
+
pi.registerCommand("reflect-stats", {
|
|
161
|
+
description: "Show reflection impact metrics: correction rate trend and rule recidivism, grouped by target file",
|
|
162
|
+
handler: async (args, ctx) => {
|
|
163
|
+
const history = loadHistory();
|
|
164
|
+
|
|
165
|
+
if (history.length < 2) {
|
|
166
|
+
const msg = "Need at least 2 reflection runs for stats. Use /reflect to build history.";
|
|
167
|
+
if (ctx.hasUI) { ctx.ui.notify(msg, "info"); } else { console.log(msg); }
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getSourceDate(r: ReflectRun): string {
|
|
172
|
+
return r.sourceDate ?? (r as any).date ?? r.timestamp.slice(0, 10);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Group runs by target file
|
|
176
|
+
const byTarget = new Map<string, ReflectRun[]>();
|
|
177
|
+
for (const run of history) {
|
|
178
|
+
const key = run.targetPath;
|
|
179
|
+
const list = byTarget.get(key) ?? [];
|
|
180
|
+
list.push(run);
|
|
181
|
+
byTarget.set(key, list);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const output: string[] = [];
|
|
185
|
+
let targetIdx = 0;
|
|
186
|
+
|
|
187
|
+
for (const [targetPath, runs] of byTarget) {
|
|
188
|
+
const fileName = path.basename(targetPath);
|
|
189
|
+
if (targetIdx > 0) output.push("", "---", "");
|
|
190
|
+
output.push(`# ${fileName}`);
|
|
191
|
+
output.push(`_${targetPath}_`);
|
|
192
|
+
output.push("");
|
|
193
|
+
|
|
194
|
+
// --- Correction Rate Trend ---
|
|
195
|
+
output.push("### Correction Rate (corrections per session)");
|
|
196
|
+
output.push("");
|
|
197
|
+
|
|
198
|
+
const ratesWithDates = runs.map((r) => ({
|
|
199
|
+
sourceDate: getSourceDate(r),
|
|
200
|
+
rate: r.correctionRate ?? (r.sessionsAnalyzed > 0 ? r.correctionsFound / r.sessionsAnalyzed : 0),
|
|
201
|
+
corrections: r.correctionsFound,
|
|
202
|
+
sessions: r.sessionsAnalyzed,
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
ratesWithDates.sort((a, b) => a.sourceDate.localeCompare(b.sourceDate));
|
|
206
|
+
|
|
207
|
+
for (const r of ratesWithDates) {
|
|
208
|
+
const bar = "\u2588".repeat(Math.round(r.rate * 10));
|
|
209
|
+
const rateStr = r.rate.toFixed(2);
|
|
210
|
+
output.push(`${r.sourceDate} ${rateStr} ${bar} (${r.corrections}/${r.sessions} sessions)`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (ratesWithDates.length >= 3) {
|
|
214
|
+
const firstHalf = ratesWithDates.slice(0, Math.floor(ratesWithDates.length / 2));
|
|
215
|
+
const secondHalf = ratesWithDates.slice(Math.floor(ratesWithDates.length / 2));
|
|
216
|
+
const avgFirst = firstHalf.reduce((s, r) => s + r.rate, 0) / firstHalf.length;
|
|
217
|
+
const avgSecond = secondHalf.reduce((s, r) => s + r.rate, 0) / secondHalf.length;
|
|
218
|
+
const delta = avgSecond - avgFirst;
|
|
219
|
+
const pct = avgFirst > 0 ? Math.abs(delta / avgFirst * 100).toFixed(0) : "N/A";
|
|
220
|
+
|
|
221
|
+
output.push("");
|
|
222
|
+
if (delta < -0.01) {
|
|
223
|
+
output.push(`Trend: \u2193 improving (${pct}% fewer corrections per session)`);
|
|
224
|
+
} else if (delta > 0.01) {
|
|
225
|
+
output.push(`Trend: \u2191 worsening (${pct}% more corrections per session)`);
|
|
226
|
+
} else {
|
|
227
|
+
output.push(`Trend: \u2194 flat`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Rule Recidivism ---
|
|
232
|
+
output.push("");
|
|
233
|
+
output.push("### Rule Recidivism (sections edited multiple times)");
|
|
234
|
+
output.push("");
|
|
235
|
+
|
|
236
|
+
const sectionCounts = new Map<string, { count: number; types: string[]; reasons: string[]; dates: string[] }>();
|
|
237
|
+
|
|
238
|
+
for (const run of runs) {
|
|
239
|
+
if (!run.edits) continue;
|
|
240
|
+
for (const edit of run.edits) {
|
|
241
|
+
const key = edit.section.toLowerCase().trim();
|
|
242
|
+
const existing = sectionCounts.get(key) ?? { count: 0, types: [], reasons: [], dates: [] };
|
|
243
|
+
existing.count++;
|
|
244
|
+
existing.types.push(edit.type);
|
|
245
|
+
existing.reasons.push(edit.reason);
|
|
246
|
+
existing.dates.push(getSourceDate(run));
|
|
247
|
+
sectionCounts.set(key, existing);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (sectionCounts.size === 0) {
|
|
252
|
+
output.push("No per-edit data yet. Run /reflect to start collecting.");
|
|
253
|
+
} else {
|
|
254
|
+
const sorted = [...sectionCounts.entries()].sort((a, b) => b[1].count - a[1].count);
|
|
255
|
+
const recidivists = sorted.filter(([, v]) => v.count >= 2);
|
|
256
|
+
const resolved = sorted.filter(([, v]) => v.count === 1);
|
|
257
|
+
|
|
258
|
+
if (recidivists.length > 0) {
|
|
259
|
+
output.push("**Recurring (not sticking):**");
|
|
260
|
+
for (const [section, data] of recidivists) {
|
|
261
|
+
const strengthened = data.types.filter((t) => t === "strengthen").length;
|
|
262
|
+
const added = data.types.filter((t) => t === "add").length;
|
|
263
|
+
const dateRange = `${data.dates[0]} \u2192 ${data.dates[data.dates.length - 1]}`;
|
|
264
|
+
output.push(`- **${section}** \u00d7${data.count} (${strengthened} strengthen, ${added} add) [${dateRange}]`);
|
|
265
|
+
const lastReason = data.reasons[data.reasons.length - 1];
|
|
266
|
+
if (lastReason) {
|
|
267
|
+
output.push(` Last: ${lastReason.length > 120 ? lastReason.slice(0, 120) + "..." : lastReason}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
output.push("**No recurring violations.** All rules stuck after first edit.");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (resolved.length > 0) {
|
|
275
|
+
output.push("");
|
|
276
|
+
output.push(`**Resolved (edited once, not repeated):** ${resolved.length} rule(s)`);
|
|
277
|
+
for (const [section] of resolved.slice(0, 5)) {
|
|
278
|
+
output.push(`- ${section}`);
|
|
279
|
+
}
|
|
280
|
+
if (resolved.length > 5) {
|
|
281
|
+
output.push(` ...and ${resolved.length - 5} more`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
targetIdx++;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const text = output.join("\n");
|
|
290
|
+
if (ctx.hasUI) {
|
|
291
|
+
ctx.ui.notify(text, "info");
|
|
292
|
+
} else {
|
|
293
|
+
console.log(text);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// /reflect-backfill — analyze all historical session dates to bootstrap stats
|
|
299
|
+
pi.registerCommand("reflect-backfill", {
|
|
300
|
+
description: "Backfill reflection stats for all available session dates (dry run — no file edits)",
|
|
301
|
+
handler: async (_args, ctx) => {
|
|
302
|
+
modelRegistryRef = ctx.modelRegistry;
|
|
303
|
+
|
|
304
|
+
if (!ctx.hasUI) {
|
|
305
|
+
console.error("reflect-backfill requires interactive mode.");
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const config = loadConfig();
|
|
310
|
+
if (config.targets.length === 0) {
|
|
311
|
+
ctx.ui.notify("No targets configured. Use /reflect <path> to add one.", "info");
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Only backfill pi-sessions targets
|
|
316
|
+
const piSessionTargets = config.targets.filter(
|
|
317
|
+
(t) => !t.transcriptSource || t.transcriptSource.type === "pi-sessions",
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
if (piSessionTargets.length === 0) {
|
|
321
|
+
ctx.ui.notify("No pi-sessions targets to backfill. Command-based transcript sources are not supported for backfill.", "info");
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const allDates = getAvailableSessionDates();
|
|
326
|
+
if (allDates.length === 0) {
|
|
327
|
+
ctx.ui.notify("No session files found.", "info");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// For each target, find dates already covered
|
|
332
|
+
const history = loadHistory();
|
|
333
|
+
const plan: { target: ReflectTarget; dates: string[] }[] = [];
|
|
334
|
+
|
|
335
|
+
for (const target of piSessionTargets) {
|
|
336
|
+
const targetPath = resolvePath(target.path);
|
|
337
|
+
const coveredDates = new Set(
|
|
338
|
+
history
|
|
339
|
+
.filter((r) => r.targetPath === targetPath)
|
|
340
|
+
.map((r) => r.sourceDate ?? (r as any).date)
|
|
341
|
+
.filter(Boolean),
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
const missingDates = allDates.filter((d) => !coveredDates.has(d));
|
|
345
|
+
if (missingDates.length > 0) {
|
|
346
|
+
plan.push({ target, dates: missingDates });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (plan.length === 0) {
|
|
351
|
+
ctx.ui.notify("All dates already covered. Nothing to backfill.", "info");
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Resolve model for cost estimation
|
|
356
|
+
const { getModel } = await import("@mariozechner/pi-ai");
|
|
357
|
+
const target0 = plan[0].target;
|
|
358
|
+
const [provider, modelId] = target0.model.split("/", 2);
|
|
359
|
+
let model = getModel(provider as any, modelId as any);
|
|
360
|
+
if (!model) { model = modelRegistryRef?.find(provider, modelId); }
|
|
361
|
+
|
|
362
|
+
const totalCalls = plan.reduce((s, p) => s + p.dates.length, 0);
|
|
363
|
+
const estInputTokensPerCall = 150_000;
|
|
364
|
+
const estOutputTokensPerCall = 2_000;
|
|
365
|
+
|
|
366
|
+
let costEstimate = "unknown";
|
|
367
|
+
if (model?.cost) {
|
|
368
|
+
const inputCost = (totalCalls * estInputTokensPerCall * model.cost.input) / 1_000_000;
|
|
369
|
+
const outputCost = (totalCalls * estOutputTokensPerCall * model.cost.output) / 1_000_000;
|
|
370
|
+
const totalCost = inputCost + outputCost;
|
|
371
|
+
costEstimate = `$${totalCost.toFixed(2)}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const planLines: string[] = [];
|
|
375
|
+
planLines.push("**Backfill plan (dry run — no file edits):**");
|
|
376
|
+
planLines.push("");
|
|
377
|
+
for (const p of plan) {
|
|
378
|
+
const fileName = path.basename(p.target.path);
|
|
379
|
+
planLines.push(`- **${fileName}**: ${p.dates.length} date(s) [${p.dates[0]} \u2192 ${p.dates[p.dates.length - 1]}]`);
|
|
380
|
+
}
|
|
381
|
+
planLines.push("");
|
|
382
|
+
planLines.push(`**Total:** ${totalCalls} LLM call(s) using ${target0.model}`);
|
|
383
|
+
planLines.push(`**Estimated cost:** ${costEstimate}`);
|
|
384
|
+
|
|
385
|
+
ctx.ui.notify(planLines.join("\n"), "info");
|
|
386
|
+
|
|
387
|
+
const proceed = await ctx.ui.confirm(
|
|
388
|
+
"Run backfill?",
|
|
389
|
+
`This will make ${totalCalls} LLM calls (~${costEstimate}). No files will be modified — only stats history is updated.`,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
if (!proceed) {
|
|
393
|
+
ctx.ui.notify("Backfill cancelled.", "info");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
let completed = 0;
|
|
398
|
+
let failed = 0;
|
|
399
|
+
const updatedHistory = loadHistory();
|
|
400
|
+
|
|
401
|
+
for (const p of plan) {
|
|
402
|
+
const fileName = path.basename(p.target.path);
|
|
403
|
+
|
|
404
|
+
for (const date of p.dates) {
|
|
405
|
+
ctx.ui.notify(`[${completed + failed + 1}/${totalCalls}] ${fileName} — ${date}...`, "info");
|
|
406
|
+
|
|
407
|
+
const transcriptResult = await collectTranscriptsForDate(date, p.target.maxSessionBytes);
|
|
408
|
+
|
|
409
|
+
if (!transcriptResult.transcripts || transcriptResult.includedCount === 0) {
|
|
410
|
+
ctx.ui.notify(` ${date}: no substantive sessions, skipping`, "info");
|
|
411
|
+
failed++;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const notify: NotifyFn = (msg, level) => {
|
|
416
|
+
ctx.ui.notify(` ${date}: ${msg}`, level);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const run = await runReflection(p.target, modelRegistryRef, notify, undefined, {
|
|
420
|
+
sourceDateOverride: date,
|
|
421
|
+
transcriptsOverride: transcriptResult,
|
|
422
|
+
dryRun: true,
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
if (run) {
|
|
426
|
+
updatedHistory.push(run);
|
|
427
|
+
saveHistory(updatedHistory);
|
|
428
|
+
completed++;
|
|
429
|
+
} else {
|
|
430
|
+
failed++;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
ctx.ui.notify(
|
|
436
|
+
`Backfill complete: ${completed} succeeded, ${failed} skipped/failed out of ${totalCalls} dates.`,
|
|
437
|
+
completed > 0 ? "info" : "warning",
|
|
438
|
+
);
|
|
439
|
+
},
|
|
440
|
+
});
|
|
441
|
+
}
|