mitsupi 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 +201 -0
- package/README.md +95 -0
- package/TODO.md +11 -0
- package/commands/handoff.md +100 -0
- package/commands/make-release.md +75 -0
- package/commands/pickup.md +30 -0
- package/commands/update-changelog.md +78 -0
- package/package.json +22 -0
- package/pi-extensions/answer.ts +527 -0
- package/pi-extensions/codex-tuning.ts +632 -0
- package/pi-extensions/commit.ts +248 -0
- package/pi-extensions/cwd-history.ts +237 -0
- package/pi-extensions/issues.ts +548 -0
- package/pi-extensions/loop.ts +446 -0
- package/pi-extensions/qna.ts +167 -0
- package/pi-extensions/reveal.ts +689 -0
- package/pi-extensions/review.ts +807 -0
- package/pi-themes/armin.json +81 -0
- package/pi-themes/nightowl.json +82 -0
- package/skills/anachb/SKILL.md +183 -0
- package/skills/anachb/departures.sh +79 -0
- package/skills/anachb/disruptions.sh +53 -0
- package/skills/anachb/route.sh +87 -0
- package/skills/anachb/search.sh +43 -0
- package/skills/ghidra/SKILL.md +254 -0
- package/skills/ghidra/scripts/find-ghidra.sh +54 -0
- package/skills/ghidra/scripts/ghidra-analyze.sh +239 -0
- package/skills/ghidra/scripts/ghidra_scripts/ExportAll.java +278 -0
- package/skills/ghidra/scripts/ghidra_scripts/ExportCalls.java +148 -0
- package/skills/ghidra/scripts/ghidra_scripts/ExportDecompiled.java +84 -0
- package/skills/ghidra/scripts/ghidra_scripts/ExportFunctions.java +114 -0
- package/skills/ghidra/scripts/ghidra_scripts/ExportStrings.java +123 -0
- package/skills/ghidra/scripts/ghidra_scripts/ExportSymbols.java +135 -0
- package/skills/github/SKILL.md +47 -0
- package/skills/improve-skill/SKILL.md +155 -0
- package/skills/improve-skill/scripts/extract-session.js +349 -0
- package/skills/oebb-scotty/SKILL.md +429 -0
- package/skills/oebb-scotty/arrivals.sh +83 -0
- package/skills/oebb-scotty/departures.sh +83 -0
- package/skills/oebb-scotty/disruptions.sh +33 -0
- package/skills/oebb-scotty/search-station.sh +36 -0
- package/skills/oebb-scotty/trip.sh +119 -0
- package/skills/openscad/SKILL.md +232 -0
- package/skills/openscad/examples/parametric_box.scad +92 -0
- package/skills/openscad/examples/phone_stand.scad +95 -0
- package/skills/openscad/tools/common.sh +50 -0
- package/skills/openscad/tools/export-stl.sh +56 -0
- package/skills/openscad/tools/extract-params.sh +147 -0
- package/skills/openscad/tools/multi-preview.sh +68 -0
- package/skills/openscad/tools/preview.sh +74 -0
- package/skills/openscad/tools/render-with-params.sh +91 -0
- package/skills/openscad/tools/validate.sh +46 -0
- package/skills/pi-share/SKILL.md +105 -0
- package/skills/pi-share/fetch-session.mjs +322 -0
- package/skills/sentry/SKILL.md +239 -0
- package/skills/sentry/lib/auth.js +99 -0
- package/skills/sentry/scripts/fetch-event.js +329 -0
- package/skills/sentry/scripts/fetch-issue.js +356 -0
- package/skills/sentry/scripts/list-issues.js +239 -0
- package/skills/sentry/scripts/search-events.js +291 -0
- package/skills/sentry/scripts/search-logs.js +240 -0
- package/skills/tmux/SKILL.md +105 -0
- package/skills/tmux/scripts/find-sessions.sh +112 -0
- package/skills/tmux/scripts/wait-for-text.sh +83 -0
- package/skills/web-browser/SKILL.md +91 -0
- package/skills/web-browser/scripts/cdp.js +210 -0
- package/skills/web-browser/scripts/dismiss-cookies.js +373 -0
- package/skills/web-browser/scripts/eval.js +68 -0
- package/skills/web-browser/scripts/logs-tail.js +69 -0
- package/skills/web-browser/scripts/nav.js +65 -0
- package/skills/web-browser/scripts/net-summary.js +94 -0
- package/skills/web-browser/scripts/package-lock.json +33 -0
- package/skills/web-browser/scripts/package.json +6 -0
- package/skills/web-browser/scripts/pick.js +165 -0
- package/skills/web-browser/scripts/screenshot.js +52 -0
- package/skills/web-browser/scripts/start.js +80 -0
- package/skills/web-browser/scripts/watch.js +266 -0
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop Extension
|
|
3
|
+
*
|
|
4
|
+
* Provides a /loop command that starts a follow-up loop with a breakout condition.
|
|
5
|
+
* The loop keeps sending a prompt on turn end until the agent calls the
|
|
6
|
+
* signal_loop_success tool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Type } from "@sinclair/typebox";
|
|
10
|
+
import { complete, type Api, type Model, type UserMessage } from "@mariozechner/pi-ai";
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext, SessionSwitchEvent } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { compact } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
import { Container, type SelectItem, SelectList, Text } from "@mariozechner/pi-tui";
|
|
14
|
+
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
type LoopMode = "tests" | "custom" | "self";
|
|
17
|
+
|
|
18
|
+
type LoopStateData = {
|
|
19
|
+
active: boolean;
|
|
20
|
+
mode?: LoopMode;
|
|
21
|
+
condition?: string;
|
|
22
|
+
prompt?: string;
|
|
23
|
+
summary?: string;
|
|
24
|
+
loopCount?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const LOOP_PRESETS = [
|
|
28
|
+
{ value: "tests", label: "Until tests pass", description: "" },
|
|
29
|
+
{ value: "custom", label: "Until custom condition", description: "" },
|
|
30
|
+
{ value: "self", label: "Self driven (agent decides)", description: "" },
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const LOOP_STATE_ENTRY = "loop-state";
|
|
34
|
+
|
|
35
|
+
const HAIKU_MODEL_ID = "claude-haiku-4-5";
|
|
36
|
+
|
|
37
|
+
const SUMMARY_SYSTEM_PROMPT = `You summarize loop breakout conditions for a status widget.
|
|
38
|
+
Return a concise phrase (max 6 words) that says when the loop should stop.
|
|
39
|
+
Use plain text only, no quotes, no punctuation, no prefix.
|
|
40
|
+
|
|
41
|
+
Form should be "breaks when ...", "loops until ...", "stops on ...", "runs until ...", or similar.
|
|
42
|
+
Use the best form that makes sense for the loop condition.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
function buildPrompt(mode: LoopMode, condition?: string): string {
|
|
46
|
+
switch (mode) {
|
|
47
|
+
case "tests":
|
|
48
|
+
return (
|
|
49
|
+
"Run all tests. If they are passing, call the signal_loop_success tool. " +
|
|
50
|
+
"Otherwise continue until the tests pass."
|
|
51
|
+
);
|
|
52
|
+
case "custom": {
|
|
53
|
+
const customCondition = condition?.trim() || "the custom condition is satisfied";
|
|
54
|
+
return (
|
|
55
|
+
`Continue until the following condition is satisfied: ${customCondition}. ` +
|
|
56
|
+
"When it is satisfied, call the signal_loop_success tool."
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
case "self":
|
|
60
|
+
return "Continue until you are done. When finished, call the signal_loop_success tool.";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function summarizeCondition(mode: LoopMode, condition?: string): string {
|
|
65
|
+
switch (mode) {
|
|
66
|
+
case "tests":
|
|
67
|
+
return "tests pass";
|
|
68
|
+
case "custom": {
|
|
69
|
+
const summary = condition?.trim() || "custom condition";
|
|
70
|
+
return summary.length > 48 ? `${summary.slice(0, 45)}...` : summary;
|
|
71
|
+
}
|
|
72
|
+
case "self":
|
|
73
|
+
return "done";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getConditionText(mode: LoopMode, condition?: string): string {
|
|
78
|
+
switch (mode) {
|
|
79
|
+
case "tests":
|
|
80
|
+
return "tests pass";
|
|
81
|
+
case "custom":
|
|
82
|
+
return condition?.trim() || "custom condition";
|
|
83
|
+
case "self":
|
|
84
|
+
return "you are done";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function selectSummaryModel(
|
|
89
|
+
ctx: ExtensionContext,
|
|
90
|
+
): Promise<{ model: Model<Api>; apiKey: string } | null> {
|
|
91
|
+
if (!ctx.model) return null;
|
|
92
|
+
|
|
93
|
+
if (ctx.model.provider === "anthropic") {
|
|
94
|
+
const haikuModel = ctx.modelRegistry.find("anthropic", HAIKU_MODEL_ID);
|
|
95
|
+
if (haikuModel) {
|
|
96
|
+
const apiKey = await ctx.modelRegistry.getApiKey(haikuModel);
|
|
97
|
+
if (apiKey) {
|
|
98
|
+
return { model: haikuModel, apiKey };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
|
|
104
|
+
if (!apiKey) return null;
|
|
105
|
+
return { model: ctx.model, apiKey };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function summarizeBreakoutCondition(
|
|
109
|
+
ctx: ExtensionContext,
|
|
110
|
+
mode: LoopMode,
|
|
111
|
+
condition?: string,
|
|
112
|
+
): Promise<string> {
|
|
113
|
+
const fallback = summarizeCondition(mode, condition);
|
|
114
|
+
const selection = await selectSummaryModel(ctx);
|
|
115
|
+
if (!selection) return fallback;
|
|
116
|
+
|
|
117
|
+
const conditionText = getConditionText(mode, condition);
|
|
118
|
+
const userMessage: UserMessage = {
|
|
119
|
+
role: "user",
|
|
120
|
+
content: [{ type: "text", text: conditionText }],
|
|
121
|
+
timestamp: Date.now(),
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const response = await complete(
|
|
125
|
+
selection.model,
|
|
126
|
+
{ systemPrompt: SUMMARY_SYSTEM_PROMPT, messages: [userMessage] },
|
|
127
|
+
{ apiKey: selection.apiKey },
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (response.stopReason === "aborted" || response.stopReason === "error") {
|
|
131
|
+
return fallback;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const summary = response.content
|
|
135
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
136
|
+
.map((c) => c.text)
|
|
137
|
+
.join(" ")
|
|
138
|
+
.replace(/\s+/g, " ")
|
|
139
|
+
.trim();
|
|
140
|
+
|
|
141
|
+
if (!summary) return fallback;
|
|
142
|
+
return summary.length > 60 ? `${summary.slice(0, 57)}...` : summary;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getCompactionInstructions(mode: LoopMode, condition?: string): string {
|
|
146
|
+
const conditionText = getConditionText(mode, condition);
|
|
147
|
+
return `Loop active. Breakout condition: ${conditionText}. Preserve this loop state and breakout condition in the summary.`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function updateStatus(ctx: ExtensionContext, state: LoopStateData): void {
|
|
151
|
+
if (!ctx.hasUI) return;
|
|
152
|
+
if (!state.active || !state.mode) {
|
|
153
|
+
ctx.ui.setWidget("loop", undefined);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const loopCount = state.loopCount ?? 0;
|
|
157
|
+
const turnText = `(turn ${loopCount})`;
|
|
158
|
+
const summary = state.summary?.trim();
|
|
159
|
+
const text = summary
|
|
160
|
+
? `Loop active: ${summary} ${turnText}`
|
|
161
|
+
: `Loop active ${turnText}`;
|
|
162
|
+
ctx.ui.setWidget("loop", [ctx.ui.theme.fg("accent", text)]);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function loadState(ctx: ExtensionContext): Promise<LoopStateData> {
|
|
166
|
+
const entries = ctx.sessionManager.getEntries();
|
|
167
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
168
|
+
const entry = entries[i] as { type: string; customType?: string; data?: LoopStateData };
|
|
169
|
+
if (entry.type === "custom" && entry.customType === LOOP_STATE_ENTRY && entry.data) {
|
|
170
|
+
return entry.data;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { active: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default function loopExtension(pi: ExtensionAPI): void {
|
|
177
|
+
let loopState: LoopStateData = { active: false };
|
|
178
|
+
|
|
179
|
+
function persistState(state: LoopStateData): void {
|
|
180
|
+
pi.appendEntry(LOOP_STATE_ENTRY, state);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function setLoopState(state: LoopStateData, ctx: ExtensionContext): void {
|
|
184
|
+
loopState = state;
|
|
185
|
+
persistState(state);
|
|
186
|
+
updateStatus(ctx, state);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function clearLoopState(ctx: ExtensionContext): void {
|
|
190
|
+
const cleared: LoopStateData = { active: false };
|
|
191
|
+
loopState = cleared;
|
|
192
|
+
persistState(cleared);
|
|
193
|
+
updateStatus(ctx, cleared);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function breakLoop(ctx: ExtensionContext): void {
|
|
197
|
+
clearLoopState(ctx);
|
|
198
|
+
ctx.ui.notify("Loop ended", "info");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function wasLastAssistantAborted(messages: Array<{ role?: string; stopReason?: string }>): boolean {
|
|
202
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
203
|
+
const message = messages[i];
|
|
204
|
+
if (message?.role === "assistant") {
|
|
205
|
+
return message.stopReason === "aborted";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function triggerLoopPrompt(ctx: ExtensionContext): void {
|
|
212
|
+
if (!loopState.active || !loopState.mode || !loopState.prompt) return;
|
|
213
|
+
if (ctx.hasPendingMessages()) return;
|
|
214
|
+
|
|
215
|
+
const loopCount = (loopState.loopCount ?? 0) + 1;
|
|
216
|
+
loopState = { ...loopState, loopCount };
|
|
217
|
+
persistState(loopState);
|
|
218
|
+
updateStatus(ctx, loopState);
|
|
219
|
+
|
|
220
|
+
pi.sendMessage({
|
|
221
|
+
customType: "loop",
|
|
222
|
+
content: loopState.prompt,
|
|
223
|
+
display: true
|
|
224
|
+
}, {
|
|
225
|
+
deliverAs: "followUp",
|
|
226
|
+
triggerTurn: true
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function showLoopSelector(ctx: ExtensionContext): Promise<LoopStateData | null> {
|
|
231
|
+
const items: SelectItem[] = LOOP_PRESETS.map((preset) => ({
|
|
232
|
+
value: preset.value,
|
|
233
|
+
label: preset.label,
|
|
234
|
+
description: preset.description,
|
|
235
|
+
}));
|
|
236
|
+
|
|
237
|
+
const selection = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
238
|
+
const container = new Container();
|
|
239
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
240
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("Select a loop preset"))));
|
|
241
|
+
|
|
242
|
+
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
|
243
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
244
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
245
|
+
description: (text) => theme.fg("muted", text),
|
|
246
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
247
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
selectList.onSelect = (item) => done(item.value);
|
|
251
|
+
selectList.onCancel = () => done(null);
|
|
252
|
+
|
|
253
|
+
container.addChild(selectList);
|
|
254
|
+
container.addChild(new Text(theme.fg("dim", "Press enter to confirm or esc to cancel")));
|
|
255
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
render(width: number) {
|
|
259
|
+
return container.render(width);
|
|
260
|
+
},
|
|
261
|
+
invalidate() {
|
|
262
|
+
container.invalidate();
|
|
263
|
+
},
|
|
264
|
+
handleInput(data: string) {
|
|
265
|
+
selectList.handleInput(data);
|
|
266
|
+
tui.requestRender();
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (!selection) return null;
|
|
272
|
+
|
|
273
|
+
switch (selection) {
|
|
274
|
+
case "tests":
|
|
275
|
+
return { active: true, mode: "tests", prompt: buildPrompt("tests") };
|
|
276
|
+
case "self":
|
|
277
|
+
return { active: true, mode: "self", prompt: buildPrompt("self") };
|
|
278
|
+
case "custom": {
|
|
279
|
+
const condition = await ctx.ui.editor("Enter loop breakout condition:", "");
|
|
280
|
+
if (!condition?.trim()) return null;
|
|
281
|
+
return {
|
|
282
|
+
active: true,
|
|
283
|
+
mode: "custom",
|
|
284
|
+
condition: condition.trim(),
|
|
285
|
+
prompt: buildPrompt("custom", condition.trim()),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
default:
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseArgs(args: string | undefined): LoopStateData | null {
|
|
294
|
+
if (!args?.trim()) return null;
|
|
295
|
+
const parts = args.trim().split(/\s+/);
|
|
296
|
+
const mode = parts[0]?.toLowerCase();
|
|
297
|
+
|
|
298
|
+
switch (mode) {
|
|
299
|
+
case "tests":
|
|
300
|
+
return { active: true, mode: "tests", prompt: buildPrompt("tests") };
|
|
301
|
+
case "self":
|
|
302
|
+
return { active: true, mode: "self", prompt: buildPrompt("self") };
|
|
303
|
+
case "custom": {
|
|
304
|
+
const condition = parts.slice(1).join(" ").trim();
|
|
305
|
+
if (!condition) return null;
|
|
306
|
+
return {
|
|
307
|
+
active: true,
|
|
308
|
+
mode: "custom",
|
|
309
|
+
condition,
|
|
310
|
+
prompt: buildPrompt("custom", condition),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
default:
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
pi.registerTool({
|
|
319
|
+
name: "signal_loop_success",
|
|
320
|
+
label: "Signal Loop Success",
|
|
321
|
+
description: "Stop the active loop when the breakout condition is satisfied. Only call this tool when explicitly instructed to do so by the user, tool or system prompt.",
|
|
322
|
+
parameters: Type.Object({}),
|
|
323
|
+
async execute(_toolCallId, _params, _onUpdate, ctx) {
|
|
324
|
+
if (!loopState.active) {
|
|
325
|
+
return {
|
|
326
|
+
content: [{ type: "text", text: "No active loop is running." }],
|
|
327
|
+
details: { active: false },
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
clearLoopState(ctx);
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
content: [{ type: "text", text: "Loop ended." }],
|
|
335
|
+
details: { active: false },
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
pi.registerCommand("loop", {
|
|
341
|
+
description: "Start a follow-up loop until a breakout condition is met",
|
|
342
|
+
handler: async (args, ctx) => {
|
|
343
|
+
let nextState = parseArgs(args);
|
|
344
|
+
if (!nextState) {
|
|
345
|
+
if (!ctx.hasUI) {
|
|
346
|
+
ctx.ui.notify("Usage: /loop tests | /loop custom <condition> | /loop self", "warning");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
nextState = await showLoopSelector(ctx);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (!nextState) {
|
|
353
|
+
ctx.ui.notify("Loop cancelled", "info");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (loopState.active) {
|
|
358
|
+
const confirm = ctx.hasUI
|
|
359
|
+
? await ctx.ui.confirm("Replace active loop?", "A loop is already active. Replace it?")
|
|
360
|
+
: true;
|
|
361
|
+
if (!confirm) {
|
|
362
|
+
ctx.ui.notify("Loop unchanged", "info");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const summarizedState: LoopStateData = { ...nextState, summary: undefined, loopCount: 0 };
|
|
368
|
+
setLoopState(summarizedState, ctx);
|
|
369
|
+
ctx.ui.notify("Loop active", "info");
|
|
370
|
+
triggerLoopPrompt(ctx);
|
|
371
|
+
|
|
372
|
+
const mode = nextState.mode!;
|
|
373
|
+
const condition = nextState.condition;
|
|
374
|
+
void (async () => {
|
|
375
|
+
const summary = await summarizeBreakoutCondition(ctx, mode, condition);
|
|
376
|
+
if (!loopState.active || loopState.mode !== mode || loopState.condition !== condition) return;
|
|
377
|
+
loopState = { ...loopState, summary };
|
|
378
|
+
persistState(loopState);
|
|
379
|
+
updateStatus(ctx, loopState);
|
|
380
|
+
})();
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
385
|
+
if (!loopState.active) return;
|
|
386
|
+
|
|
387
|
+
if (ctx.hasUI && wasLastAssistantAborted(event.messages)) {
|
|
388
|
+
const confirm = await ctx.ui.confirm(
|
|
389
|
+
"Break active loop?",
|
|
390
|
+
"Operation aborted. Break out of the loop?",
|
|
391
|
+
);
|
|
392
|
+
if (confirm) {
|
|
393
|
+
breakLoop(ctx);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
triggerLoopPrompt(ctx);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
402
|
+
if (!loopState.active || !loopState.mode || !ctx.model) return;
|
|
403
|
+
const apiKey = await ctx.modelRegistry.getApiKey(ctx.model);
|
|
404
|
+
if (!apiKey) return;
|
|
405
|
+
|
|
406
|
+
const instructionParts = [event.customInstructions, getCompactionInstructions(loopState.mode, loopState.condition)]
|
|
407
|
+
.filter(Boolean)
|
|
408
|
+
.join("\n\n");
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
const compaction = await compact(event.preparation, ctx.model, apiKey, instructionParts, event.signal);
|
|
412
|
+
return { compaction };
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (ctx.hasUI) {
|
|
415
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
416
|
+
ctx.ui.notify(`Loop compaction failed: ${message}`, "warning");
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
async function restoreLoopState(ctx: ExtensionContext): Promise<void> {
|
|
423
|
+
loopState = await loadState(ctx);
|
|
424
|
+
updateStatus(ctx, loopState);
|
|
425
|
+
|
|
426
|
+
if (loopState.active && loopState.mode && !loopState.summary) {
|
|
427
|
+
const mode = loopState.mode;
|
|
428
|
+
const condition = loopState.condition;
|
|
429
|
+
void (async () => {
|
|
430
|
+
const summary = await summarizeBreakoutCondition(ctx, mode, condition);
|
|
431
|
+
if (!loopState.active || loopState.mode !== mode || loopState.condition !== condition) return;
|
|
432
|
+
loopState = { ...loopState, summary };
|
|
433
|
+
persistState(loopState);
|
|
434
|
+
updateStatus(ctx, loopState);
|
|
435
|
+
})();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
440
|
+
await restoreLoopState(ctx);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
pi.on("session_switch", async (_event: SessionSwitchEvent, ctx) => {
|
|
444
|
+
await restoreLoopState(ctx);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Q&A extraction hook - extracts questions from assistant responses
|
|
3
|
+
*
|
|
4
|
+
* Demonstrates the "prompt generator" pattern:
|
|
5
|
+
* 1. /qna command gets the last assistant message
|
|
6
|
+
* 2. Shows a spinner while extracting (hides editor)
|
|
7
|
+
* 3. Loads the result into the editor for user to fill in answers
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { complete, type Model, type Api, type UserMessage } from "@mariozechner/pi-ai";
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
12
|
+
import { BorderedLoader } from "@mariozechner/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const SYSTEM_PROMPT = `You are a question extractor. Given text from a conversation, extract any questions that need answering and format them for the user to fill in.
|
|
15
|
+
|
|
16
|
+
Output format:
|
|
17
|
+
- List each question on its own line, prefixed with "Q: "
|
|
18
|
+
- After each question, add a blank line for the answer prefixed with "A: "
|
|
19
|
+
- If no questions are found, output "No questions found in the last message."
|
|
20
|
+
- When there is important context needed to answer a question, use quote characters (">") and add it into the question block as additional lines.
|
|
21
|
+
|
|
22
|
+
Example output:
|
|
23
|
+
Q: What is your preferred database?
|
|
24
|
+
> we can only configure MySQL and PostgreSQL because of what is implemented.
|
|
25
|
+
A:
|
|
26
|
+
|
|
27
|
+
Q: Should we use TypeScript or JavaScript?
|
|
28
|
+
A:
|
|
29
|
+
|
|
30
|
+
Keep questions in the order they appeared. Be concise.`;
|
|
31
|
+
|
|
32
|
+
const HAIKU_MODEL_ID = "claude-haiku-4-5";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if the current model is a Claude Opus or Sonnet model from Anthropic provider.
|
|
36
|
+
* If so, try to use claude-haiku-4-5 instead for cost efficiency.
|
|
37
|
+
*/
|
|
38
|
+
async function selectExtractionModel(
|
|
39
|
+
currentModel: Model<Api>,
|
|
40
|
+
modelRegistry: { find: (provider: string, modelId: string) => Model<Api> | undefined; getApiKey: (model: Model<Api>) => Promise<string | undefined> },
|
|
41
|
+
): Promise<Model<Api>> {
|
|
42
|
+
// Only consider switching if the provider is anthropic and the model is opus or sonnet
|
|
43
|
+
if (currentModel.provider !== "anthropic") {
|
|
44
|
+
return currentModel;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const modelId = currentModel.id.toLowerCase();
|
|
48
|
+
const isOpusOrSonnet = modelId.includes("opus") || modelId.includes("sonnet");
|
|
49
|
+
if (!isOpusOrSonnet) {
|
|
50
|
+
return currentModel;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Try to find and use claude-haiku-4-5
|
|
54
|
+
const haikuModel = modelRegistry.find("anthropic", HAIKU_MODEL_ID);
|
|
55
|
+
if (!haikuModel) {
|
|
56
|
+
return currentModel;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Check if we have an API key for the haiku model
|
|
60
|
+
const apiKey = await modelRegistry.getApiKey(haikuModel);
|
|
61
|
+
if (!apiKey) {
|
|
62
|
+
return currentModel;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return haikuModel;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default function (pi: ExtensionAPI) {
|
|
69
|
+
const qnaHandler = async (ctx: ExtensionContext) => {
|
|
70
|
+
if (!ctx.hasUI) {
|
|
71
|
+
ctx.ui.notify("qna requires interactive mode", "error");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!ctx.model) {
|
|
76
|
+
ctx.ui.notify("No model selected", "error");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Find the last assistant message on the current branch
|
|
81
|
+
const branch = ctx.sessionManager.getBranch();
|
|
82
|
+
let lastAssistantText: string | undefined;
|
|
83
|
+
|
|
84
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
85
|
+
const entry = branch[i];
|
|
86
|
+
if (entry.type === "message") {
|
|
87
|
+
const msg = entry.message;
|
|
88
|
+
if ("role" in msg && msg.role === "assistant") {
|
|
89
|
+
if (msg.stopReason !== "stop") {
|
|
90
|
+
ctx.ui.notify(`Last assistant message incomplete (${msg.stopReason})`, "error");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const textParts = msg.content
|
|
94
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
95
|
+
.map((c) => c.text);
|
|
96
|
+
if (textParts.length > 0) {
|
|
97
|
+
lastAssistantText = textParts.join("\n");
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!lastAssistantText) {
|
|
105
|
+
ctx.ui.notify("No assistant messages found", "error");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Select the best model for extraction (prefer haiku for cost efficiency)
|
|
110
|
+
const extractionModel = await selectExtractionModel(ctx.model, ctx.modelRegistry);
|
|
111
|
+
|
|
112
|
+
// Run extraction with loader UI
|
|
113
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
114
|
+
const loader = new BorderedLoader(tui, theme, `Extracting questions using ${extractionModel.id}...`);
|
|
115
|
+
loader.onAbort = () => done(null);
|
|
116
|
+
|
|
117
|
+
// Do the work
|
|
118
|
+
const doExtract = async () => {
|
|
119
|
+
const apiKey = await ctx.modelRegistry.getApiKey(extractionModel);
|
|
120
|
+
const userMessage: UserMessage = {
|
|
121
|
+
role: "user",
|
|
122
|
+
content: [{ type: "text", text: lastAssistantText! }],
|
|
123
|
+
timestamp: Date.now(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const response = await complete(
|
|
127
|
+
extractionModel,
|
|
128
|
+
{ systemPrompt: SYSTEM_PROMPT, messages: [userMessage] },
|
|
129
|
+
{ apiKey, signal: loader.signal },
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (response.stopReason === "aborted") {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return response.content
|
|
137
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
138
|
+
.map((c) => c.text)
|
|
139
|
+
.join("\n");
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
doExtract()
|
|
143
|
+
.then(done)
|
|
144
|
+
.catch(() => done(null));
|
|
145
|
+
|
|
146
|
+
return loader;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
if (result === null) {
|
|
150
|
+
ctx.ui.notify("Cancelled", "info");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
ctx.ui.setEditorText(result);
|
|
155
|
+
ctx.ui.notify("Questions loaded. Edit and submit when ready.", "info");
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
pi.registerCommand("qna", {
|
|
159
|
+
description: "Extract questions from last assistant message into editor",
|
|
160
|
+
handler: (_args, ctx) => qnaHandler(ctx),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.registerShortcut("ctrl+,", {
|
|
164
|
+
description: "Extract questions into editor",
|
|
165
|
+
handler: qnaHandler,
|
|
166
|
+
});
|
|
167
|
+
}
|