@monotykamary/pi-loop 0.1.12
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +285 -0
- package/media/demo.mp4 +0 -0
- package/media/pi-loop.jpg +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +89 -0
- package/src/core/analyzer.ts +51 -0
- package/src/core/content-extractor.ts +79 -0
- package/src/core/inference.ts +137 -0
- package/src/core/prompt-builder.ts +217 -0
- package/src/core/prompt-loader.ts +126 -0
- package/src/core/reframe.ts +30 -0
- package/src/core/snapshot-builder.ts +252 -0
- package/src/global-config.ts +38 -0
- package/src/index.ts +532 -0
- package/src/session/client.ts +47 -0
- package/src/session/loop-session.ts +102 -0
- package/src/session/response-parser.ts +37 -0
- package/src/state/manager.ts +164 -0
- package/src/state/patterns.ts +81 -0
- package/src/state/reframe.ts +33 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +83 -0
- package/src/ui/animations.ts +70 -0
- package/src/ui/model-picker.ts +79 -0
- package/src/ui/renderer.ts +257 -0
- package/src/ui/status-widget.ts +30 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +754 -0
- package/tests/continue-action-regression.test.ts +456 -0
- package/tests/engine.test.ts +770 -0
- package/tests/ephemeral-supervision.test.ts +391 -0
- package/tests/full-fidelity-snapshot.test.ts +843 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +525 -0
- package/tests/status-widget.test.ts +703 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +381 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-loop — A pi extension that closes the verification loop on task completion.
|
|
3
|
+
*
|
|
4
|
+
* Token-optimal design:
|
|
5
|
+
* - Single trigger: always at agent_settled (when fully idle)
|
|
6
|
+
* - Mid-run: only if just steered (checking if it worked) or safety valve every 8th turn
|
|
7
|
+
* - Session reuse for automatic prompt caching
|
|
8
|
+
* - Incremental 6-message snapshots
|
|
9
|
+
*
|
|
10
|
+
* Commands:
|
|
11
|
+
* /loop — auto-infer goal from conversation
|
|
12
|
+
* /loop <outcome> — start loop mode with explicit goal
|
|
13
|
+
* /loop stop — stop loop mode
|
|
14
|
+
* /loop widget — toggle the status widget on/off
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { truncateToWidth } from '@earendil-works/pi-tui';
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
19
|
+
import { LoopStateManager } from './state/manager.js';
|
|
20
|
+
import { analyze } from './core/analyzer.js';
|
|
21
|
+
import { inferOutcome } from './core/inference.js';
|
|
22
|
+
import { loadSystemPrompt } from './core/prompt-loader.js';
|
|
23
|
+
import { updateUI, toggleWidget } from './ui/renderer.js';
|
|
24
|
+
import { pickModel } from './ui/model-picker.js';
|
|
25
|
+
import { loadGlobalModel } from './global-config.js';
|
|
26
|
+
import { disposeSession } from './session/client.js';
|
|
27
|
+
import { Type } from '@sinclair/typebox';
|
|
28
|
+
import { checkChildPiProcesses, waitForSubagents } from './subagent-detector.js';
|
|
29
|
+
import { createInitialState, type WidgetState } from './ui/types.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Extract partial reasoning text from the supervisor's streaming JSON response.
|
|
33
|
+
* Works on incomplete JSON while the model is still generating.
|
|
34
|
+
*/
|
|
35
|
+
export function extractThinking(accumulated: string): string {
|
|
36
|
+
// Find the "reasoning" key and capture content after the opening quote
|
|
37
|
+
const keyIdx = accumulated.indexOf('"reasoning"');
|
|
38
|
+
if (keyIdx === -1) return '';
|
|
39
|
+
const after = accumulated.slice(keyIdx + '"reasoning"'.length);
|
|
40
|
+
const openMatch = after.match(/^\s*:\s*"/);
|
|
41
|
+
if (!openMatch) return '';
|
|
42
|
+
const content = after.slice(openMatch[0].length);
|
|
43
|
+
// If the closing quote has arrived, take only what's inside; otherwise take all (streaming)
|
|
44
|
+
const closeIdx = content.search(/(?<!\\)"/);
|
|
45
|
+
const raw = closeIdx === -1 ? content : content.slice(0, closeIdx);
|
|
46
|
+
return raw.replace(/\\n/g, ' ').replace(/\\"/g, '"').trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Truncate a message to fit within the terminal width for notifications.
|
|
51
|
+
* Reserves space for the notification prefix and padding.
|
|
52
|
+
*/
|
|
53
|
+
function truncateForNotify(message: string, reserveChars: number = 20): string {
|
|
54
|
+
const terminalWidth = process.stdout.columns || 100;
|
|
55
|
+
const maxContentWidth = Math.max(20, terminalWidth - reserveChars);
|
|
56
|
+
return truncateToWidth(message, maxContentWidth, '…');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Check if the session has any user messages in its history. */
|
|
60
|
+
function hasUserMessages(ctx: ExtensionContext): boolean {
|
|
61
|
+
const entries = ctx.sessionManager.getBranch();
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.type === 'message') {
|
|
64
|
+
const msg = (entry as any).message;
|
|
65
|
+
if (msg?.role === 'user') {
|
|
66
|
+
const content =
|
|
67
|
+
typeof msg.content === 'string'
|
|
68
|
+
? msg.content
|
|
69
|
+
: Array.isArray(msg.content)
|
|
70
|
+
? msg.content
|
|
71
|
+
.filter((b: any) => b.type === 'text')
|
|
72
|
+
.map((b: any) => b.text)
|
|
73
|
+
.join('\n')
|
|
74
|
+
.trim()
|
|
75
|
+
: '';
|
|
76
|
+
if (content && content.length > 0) return true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export default function (pi: ExtensionAPI) {
|
|
84
|
+
const state = new LoopStateManager(pi);
|
|
85
|
+
const widgetState = createInitialState();
|
|
86
|
+
let currentCtx: ExtensionContext | undefined;
|
|
87
|
+
let idleSteers = 0; // consecutive agent_end steers; reset on done/stop/new loop
|
|
88
|
+
|
|
89
|
+
// ---- Session lifecycle: restore state ----
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ephemeral loop rule: loop mode only survives if there's active work.
|
|
93
|
+
* When loading a session (start, switch, fork, tree navigation), if loop
|
|
94
|
+
* was active but the agent is idle, we clear it. Loop mode must be tied to
|
|
95
|
+
* real-time steering needs, not historical session state.
|
|
96
|
+
*/
|
|
97
|
+
const onSessionLoad = (ctx: ExtensionContext) => {
|
|
98
|
+
currentCtx = ctx;
|
|
99
|
+
state.loadFromSession(ctx);
|
|
100
|
+
|
|
101
|
+
// Ephemeral check: if loop restored but agent is idle, stop it
|
|
102
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
103
|
+
state.stop();
|
|
104
|
+
idleSteers = 0;
|
|
105
|
+
disposeSession();
|
|
106
|
+
// Notify that we cleared stale loop
|
|
107
|
+
ctx.ui.notify('Loop mode cleared: agent is idle', 'info');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
updateUI(ctx, widgetState, state.getState());
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
pi.on('session_start', async (_event, ctx) => onSessionLoad(ctx));
|
|
114
|
+
pi.on('session_start', async (event, ctx) => {
|
|
115
|
+
// Handle new, resume, and fork reasons (existing sessions), not startup/reload
|
|
116
|
+
if (event.reason === 'startup' || event.reason === 'reload') return;
|
|
117
|
+
onSessionLoad(ctx);
|
|
118
|
+
});
|
|
119
|
+
pi.on('session_tree', async (_event, ctx) => onSessionLoad(ctx));
|
|
120
|
+
|
|
121
|
+
// ---- Compaction survival: persist state BEFORE compaction ----
|
|
122
|
+
// This ensures supervisor-state is in the "kept" (recent) portion,
|
|
123
|
+
// not the summarized portion, so it survives autocompaction.
|
|
124
|
+
pi.on('session_before_compact', async (_event, ctx) => {
|
|
125
|
+
if (state.isActive()) {
|
|
126
|
+
state.persist();
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ---- After compaction: reload state and continue loop if agent is working ----
|
|
131
|
+
// Auto-compaction during long sessions should NOT stop loop - we want to
|
|
132
|
+
// continue steering the agent toward the goal after compaction completes.
|
|
133
|
+
pi.on('session_compact', async (event, ctx) => {
|
|
134
|
+
currentCtx = ctx;
|
|
135
|
+
state.loadFromSession(ctx);
|
|
136
|
+
|
|
137
|
+
// State should now be found (we persisted before compaction)
|
|
138
|
+
if (!state.isActive()) {
|
|
139
|
+
updateUI(ctx, widgetState, null);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Ephemeral rule: if agent is idle after compaction and no overflow
|
|
144
|
+
// retry is pending, clear loop. Overflow compaction (event.willRetry)
|
|
145
|
+
// resumes the aborted turn; keep the loop alive so agent_settled can
|
|
146
|
+
// analyze/steer the resumed run.
|
|
147
|
+
if (ctx.isIdle() && !event.willRetry) {
|
|
148
|
+
state.stop();
|
|
149
|
+
idleSteers = 0;
|
|
150
|
+
disposeSession();
|
|
151
|
+
ctx.ui.notify('Loop mode cleared: compaction complete, agent idle', 'info');
|
|
152
|
+
updateUI(ctx, widgetState, null);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Agent is still working - show watching state and let loop continue
|
|
157
|
+
// It will analyze/steer at the next agent_settled as normal
|
|
158
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
159
|
+
type: 'watching',
|
|
160
|
+
reframeTier: state.getReframeTier(),
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---- Keep ctx fresh ----
|
|
165
|
+
|
|
166
|
+
pi.on('turn_start', async (_event, ctx) => {
|
|
167
|
+
currentCtx = ctx;
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ---- Mid-run steering: only when necessary ----
|
|
171
|
+
// turn_end fires after each LLM sub-turn (tool-call cycle) while agent is still running.
|
|
172
|
+
// We check only if:
|
|
173
|
+
// 1. We just steered (to verify it worked) - immediate next turn
|
|
174
|
+
// 2. Safety valve every 8th turn (to catch runaway drift)
|
|
175
|
+
|
|
176
|
+
pi.on('turn_end', async (event, ctx) => {
|
|
177
|
+
currentCtx = ctx;
|
|
178
|
+
if (!state.isActive()) return;
|
|
179
|
+
|
|
180
|
+
const shouldAnalyze = state.shouldAnalyzeMidRun(event.turnIndex);
|
|
181
|
+
if (!shouldAnalyze) return;
|
|
182
|
+
|
|
183
|
+
// Clear the justSteered flag since we're checking now
|
|
184
|
+
state.clearJustSteered();
|
|
185
|
+
|
|
186
|
+
let decision;
|
|
187
|
+
try {
|
|
188
|
+
decision = await analyze(ctx, state.getState()!, false /* agent still working */);
|
|
189
|
+
} catch {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Mid-run threshold: only intervene if clearly off track
|
|
194
|
+
if (decision.action === 'steer' && decision.message && decision.confidence >= 0.85) {
|
|
195
|
+
state.addIntervention({
|
|
196
|
+
turnCount: state.getState()!.turnCount,
|
|
197
|
+
message: decision.message,
|
|
198
|
+
reasoning: decision.reasoning,
|
|
199
|
+
timestamp: Date.now(),
|
|
200
|
+
asi: decision.asi,
|
|
201
|
+
});
|
|
202
|
+
updateUI(ctx, widgetState, state.getState(), { type: 'steering', message: decision.message });
|
|
203
|
+
pi.sendUserMessage(decision.message, { deliverAs: 'steer' });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// ---- After each agent run: analyze + steer ----
|
|
208
|
+
// agent_settled fires once pi has fully settled — auto-retries, overflow-
|
|
209
|
+
// compaction recovery, and queued follow-up messages are all done. This is
|
|
210
|
+
// the critical checkpoint where we decide done/steer/continue.
|
|
211
|
+
|
|
212
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
213
|
+
currentCtx = ctx;
|
|
214
|
+
if (!state.isActive()) return;
|
|
215
|
+
|
|
216
|
+
state.incrementTurnCount();
|
|
217
|
+
const s = state.getState()!;
|
|
218
|
+
|
|
219
|
+
// Check for child subagent processes (extension-agnostic via process inspection)
|
|
220
|
+
const subagentStatus = await checkChildPiProcesses();
|
|
221
|
+
if (subagentStatus.hasActiveSubagents) {
|
|
222
|
+
updateUI(ctx, widgetState, s, {
|
|
223
|
+
type: 'waiting',
|
|
224
|
+
message: `Waiting for ${subagentStatus.count} subagent(s)...`,
|
|
225
|
+
turn: s.turnCount,
|
|
226
|
+
reframeTier: state.getReframeTier(),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Poll until subagents complete (or timeout)
|
|
230
|
+
const { completed, finalStatus } = await waitForSubagents(2000, 120000);
|
|
231
|
+
|
|
232
|
+
if (!completed && finalStatus.hasActiveSubagents) {
|
|
233
|
+
// Timeout - subagents still running, but we need to proceed
|
|
234
|
+
// Log this but continue with analysis
|
|
235
|
+
ctx.ui.notify(
|
|
236
|
+
`Loop: ${finalStatus.count} subagent(s) still running after timeout, proceeding with analysis`,
|
|
237
|
+
'warning'
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Subagents done (or timed out), update UI and proceed
|
|
242
|
+
updateUI(ctx, widgetState, s, {
|
|
243
|
+
type: 'analyzing',
|
|
244
|
+
turn: s.turnCount,
|
|
245
|
+
reframeTier: state.getReframeTier(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check for ineffective steering patterns and escalate reframe tier if needed
|
|
250
|
+
const ineffectivePattern = state.detectIneffectivePattern();
|
|
251
|
+
if (ineffectivePattern.detected && state.getReframeTier() < 4) {
|
|
252
|
+
state.escalateReframeTier();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
updateUI(ctx, widgetState, s, {
|
|
256
|
+
type: 'analyzing',
|
|
257
|
+
turn: s.turnCount,
|
|
258
|
+
reframeTier: state.getReframeTier(),
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const decision = await analyze(
|
|
262
|
+
ctx,
|
|
263
|
+
s,
|
|
264
|
+
true /* always idle at agent_end */,
|
|
265
|
+
ineffectivePattern,
|
|
266
|
+
undefined,
|
|
267
|
+
(accumulated) => {
|
|
268
|
+
const thinking = extractThinking(accumulated);
|
|
269
|
+
updateUI(ctx, widgetState, state.getState()!, {
|
|
270
|
+
type: 'analyzing',
|
|
271
|
+
turn: s.turnCount,
|
|
272
|
+
reframeTier: state.getReframeTier(),
|
|
273
|
+
thinking,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
if (decision.action === 'steer' && decision.message) {
|
|
279
|
+
idleSteers++;
|
|
280
|
+
state.addIntervention({
|
|
281
|
+
turnCount: s.turnCount,
|
|
282
|
+
message: decision.message,
|
|
283
|
+
reasoning: decision.reasoning,
|
|
284
|
+
timestamp: Date.now(),
|
|
285
|
+
asi: decision.asi,
|
|
286
|
+
});
|
|
287
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
288
|
+
type: 'steering',
|
|
289
|
+
message: decision.message,
|
|
290
|
+
reframeTier: state.getReframeTier(),
|
|
291
|
+
});
|
|
292
|
+
pi.sendUserMessage(decision.message);
|
|
293
|
+
} else if (decision.action === 'done') {
|
|
294
|
+
idleSteers = 0;
|
|
295
|
+
state.resetReframeTier();
|
|
296
|
+
updateUI(ctx, widgetState, state.getState(), { type: 'done' });
|
|
297
|
+
state.stop();
|
|
298
|
+
disposeSession(); // Clean up reusable session
|
|
299
|
+
updateUI(ctx, widgetState, state.getState());
|
|
300
|
+
} else if (decision.action === 'continue') {
|
|
301
|
+
// FALLBACK: Schema when idle only lists "done" | "steer", but models
|
|
302
|
+
// occasionally return invalid actions. Convert to steer since the
|
|
303
|
+
// model isn't confident enough to say 'done'.
|
|
304
|
+
idleSteers++;
|
|
305
|
+
const steerMessage = decision.message?.trim()
|
|
306
|
+
? decision.message
|
|
307
|
+
: 'Please continue working toward the goal.';
|
|
308
|
+
state.addIntervention({
|
|
309
|
+
turnCount: s.turnCount,
|
|
310
|
+
message: steerMessage,
|
|
311
|
+
reasoning: decision.reasoning || 'Invalid "continue" at agent_end, converted to steer',
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
asi: { ...decision.asi, _schema_fallback: 'continue_at_idle' },
|
|
314
|
+
});
|
|
315
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
316
|
+
type: 'steering',
|
|
317
|
+
message: steerMessage,
|
|
318
|
+
reframeTier: state.getReframeTier(),
|
|
319
|
+
});
|
|
320
|
+
pi.sendUserMessage(steerMessage);
|
|
321
|
+
} else {
|
|
322
|
+
updateUI(ctx, widgetState, state.getState(), {
|
|
323
|
+
type: 'watching',
|
|
324
|
+
reframeTier: state.getReframeTier(),
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ---- /loop command ----
|
|
330
|
+
|
|
331
|
+
pi.registerCommand('loop', {
|
|
332
|
+
description: 'Close the verification loop on task completion (/loop or /loop <outcome>)',
|
|
333
|
+
handler: async (args, ctx) => {
|
|
334
|
+
currentCtx = ctx;
|
|
335
|
+
const trimmed = args?.trim() ?? '';
|
|
336
|
+
|
|
337
|
+
// --- subcommands ---
|
|
338
|
+
|
|
339
|
+
if (trimmed === 'widget') {
|
|
340
|
+
const visible = toggleWidget(widgetState);
|
|
341
|
+
if (state.isActive()) {
|
|
342
|
+
updateUI(ctx, widgetState, state.getState());
|
|
343
|
+
}
|
|
344
|
+
ctx.ui.notify(`Loop widget ${visible ? 'shown' : 'hidden'}.`, 'info');
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (trimmed === 'stop') {
|
|
349
|
+
if (!state.isActive()) {
|
|
350
|
+
ctx.ui.notify('Loop mode is not active.', 'warning');
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
state.stop();
|
|
354
|
+
idleSteers = 0;
|
|
355
|
+
disposeSession();
|
|
356
|
+
updateUI(ctx, widgetState, state.getState());
|
|
357
|
+
ctx.ui.notify('Loop mode stopped.', 'info');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// --- infer goal from conversation (no args) ---
|
|
362
|
+
|
|
363
|
+
if (!trimmed) {
|
|
364
|
+
const s = state.getState();
|
|
365
|
+
const globalModel = loadGlobalModel();
|
|
366
|
+
const sessionModel = ctx.model;
|
|
367
|
+
let provider = s?.provider ?? globalModel?.provider ?? sessionModel?.provider ?? 'unknown';
|
|
368
|
+
let modelId = s?.modelId ?? globalModel?.modelId ?? sessionModel?.id ?? 'unknown';
|
|
369
|
+
|
|
370
|
+
// Check if there's conversation history
|
|
371
|
+
const hasConversation = !s?.active && hasUserMessages(ctx);
|
|
372
|
+
if (!hasConversation) {
|
|
373
|
+
ctx.ui.notify(
|
|
374
|
+
'No conversation history found. Use /loop <goal> to set an explicit goal.',
|
|
375
|
+
'warning'
|
|
376
|
+
);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Only prompt for a model if none has been configured yet
|
|
381
|
+
if (!s) {
|
|
382
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider);
|
|
383
|
+
if (!apiKey) {
|
|
384
|
+
ctx.ui.notify(
|
|
385
|
+
`No API key for "${provider}/${modelId}" — pick a model with an available key.`,
|
|
386
|
+
'warning'
|
|
387
|
+
);
|
|
388
|
+
const picked = await pickModel(ctx, provider, modelId);
|
|
389
|
+
if (!picked) return; // user cancelled
|
|
390
|
+
provider = picked.provider;
|
|
391
|
+
modelId = picked.id;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Infer goal from conversation
|
|
396
|
+
updateUI(ctx, widgetState, state.getState(), { type: 'inferring' });
|
|
397
|
+
const inferred = await inferOutcome(ctx, provider, modelId);
|
|
398
|
+
updateUI(ctx, widgetState, state.getState());
|
|
399
|
+
|
|
400
|
+
if (!inferred) {
|
|
401
|
+
ctx.ui.notify(
|
|
402
|
+
'Could not infer goal from conversation. Use /loop <goal> to set an explicit goal.',
|
|
403
|
+
'warning'
|
|
404
|
+
);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Start supervision with inferred outcome
|
|
409
|
+
state.start(inferred, provider, modelId);
|
|
410
|
+
idleSteers = 0;
|
|
411
|
+
updateUI(ctx, widgetState, state.getState());
|
|
412
|
+
|
|
413
|
+
// Kickstart the agent if idle
|
|
414
|
+
if (ctx.isIdle()) {
|
|
415
|
+
pi.sendUserMessage(`Please start working on this goal: ${inferred}`, {
|
|
416
|
+
deliverAs: 'followUp',
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
ctx.ui.notify(`Loop active: "${truncateForNotify(inferred, 25)}"`, 'info');
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Resolve model settings: session state → global config → active session model
|
|
425
|
+
const existing = state.getState();
|
|
426
|
+
const globalModel = loadGlobalModel();
|
|
427
|
+
const sessionModel = ctx.model;
|
|
428
|
+
let provider =
|
|
429
|
+
existing?.provider ?? globalModel?.provider ?? sessionModel?.provider ?? 'unknown';
|
|
430
|
+
let modelId = existing?.modelId ?? globalModel?.modelId ?? sessionModel?.id ?? 'unknown';
|
|
431
|
+
|
|
432
|
+
// If supervision is already active, append to the existing goal
|
|
433
|
+
if (state.isActive() && existing) {
|
|
434
|
+
const appendedOutcome = `${existing.outcome}. Additionally: ${trimmed}`;
|
|
435
|
+
state.updateOutcome(appendedOutcome);
|
|
436
|
+
updateUI(ctx, widgetState, state.getState());
|
|
437
|
+
|
|
438
|
+
ctx.ui.notify(
|
|
439
|
+
`Loop goal expanded: "${truncateForNotify(trimmed, 30)}" added to active loop.`,
|
|
440
|
+
'info'
|
|
441
|
+
);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Only prompt for a model if none has been configured yet
|
|
446
|
+
if (!existing) {
|
|
447
|
+
const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider);
|
|
448
|
+
if (!apiKey) {
|
|
449
|
+
ctx.ui.notify(
|
|
450
|
+
`No API key for "${provider}/${modelId}" — pick a model with an available key.`,
|
|
451
|
+
'warning'
|
|
452
|
+
);
|
|
453
|
+
const picked = await pickModel(ctx, provider, modelId);
|
|
454
|
+
if (!picked) return; // user cancelled
|
|
455
|
+
provider = picked.provider;
|
|
456
|
+
modelId = picked.id;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
state.start(trimmed, provider, modelId);
|
|
461
|
+
idleSteers = 0;
|
|
462
|
+
updateUI(ctx, widgetState, state.getState());
|
|
463
|
+
|
|
464
|
+
// Kickstart the agent if idle - the user just set a goal, they want work to start
|
|
465
|
+
if (ctx.isIdle()) {
|
|
466
|
+
pi.sendUserMessage(`Please start working on this goal: ${trimmed}`, {
|
|
467
|
+
deliverAs: 'followUp',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
ctx.ui.notify(`Loop active: "${truncateForNotify(trimmed, 25)}"`, 'info');
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// ---- Tool: model can initiate loop but never modify an active session ----
|
|
476
|
+
|
|
477
|
+
pi.registerTool({
|
|
478
|
+
name: 'start_loop',
|
|
479
|
+
label: 'Start Loop Mode',
|
|
480
|
+
description:
|
|
481
|
+
'Activate loop mode to track the conversation toward a specific outcome with verification. ' +
|
|
482
|
+
'Loop mode will observe every turn and validate that the agent closes the verification loop before declaring done. ' +
|
|
483
|
+
'Once loop mode is active it is locked — only the user can change or stop it. ' +
|
|
484
|
+
'Uses the global config model or active chat model (model cannot be specified).',
|
|
485
|
+
parameters: Type.Object({
|
|
486
|
+
outcome: Type.String({
|
|
487
|
+
description:
|
|
488
|
+
'The desired end-state to close the loop on. Be specific and measurable ' +
|
|
489
|
+
"(e.g. 'Implement JWT auth with refresh tokens and verify with tests').",
|
|
490
|
+
}),
|
|
491
|
+
}),
|
|
492
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, ctx) => {
|
|
493
|
+
const text = (msg: string) => ({
|
|
494
|
+
content: [{ type: 'text' as const, text: msg }],
|
|
495
|
+
details: undefined,
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// Guard: loop already active — model cannot modify it
|
|
499
|
+
if (state.isActive()) {
|
|
500
|
+
const s = state.getState()!;
|
|
501
|
+
return text(
|
|
502
|
+
`Loop mode is already active and cannot be changed by the model.\n` +
|
|
503
|
+
`Active outcome: "${s.outcome}"\n` +
|
|
504
|
+
`Only the user can stop or modify loop mode via /loop.`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Resolve model from global config or active session model (AI cannot choose)
|
|
509
|
+
const globalModel = loadGlobalModel();
|
|
510
|
+
const sessionModel = ctx.model;
|
|
511
|
+
const provider = globalModel?.provider ?? sessionModel?.provider ?? 'unknown';
|
|
512
|
+
const modelId = globalModel?.modelId ?? sessionModel?.id ?? 'unknown';
|
|
513
|
+
|
|
514
|
+
state.start(params.outcome, provider, modelId);
|
|
515
|
+
idleSteers = 0;
|
|
516
|
+
currentCtx = ctx;
|
|
517
|
+
updateUI(ctx, widgetState, state.getState());
|
|
518
|
+
|
|
519
|
+
// Kickstart the agent if idle - model-initiated loop should trigger work
|
|
520
|
+
if (ctx.isIdle()) {
|
|
521
|
+
pi.sendUserMessage(`Please start working on this goal: ${params.outcome}`, {
|
|
522
|
+
deliverAs: 'followUp',
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Notify the user so they're aware loop was initiated by the model
|
|
527
|
+
ctx.ui.notify(`Loop started by agent: "${truncateForNotify(params.outcome, 30)}"`, 'info');
|
|
528
|
+
|
|
529
|
+
return text(`Loop mode active. Outcome: "${params.outcome}"`);
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session client - high-level interface for calling the observer model.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { SteeringDecision } from '../types.js';
|
|
7
|
+
import { LoopSession } from './loop-session.js';
|
|
8
|
+
import { parseDecision, safeContinue } from './response-parser.js';
|
|
9
|
+
|
|
10
|
+
// Global session manager (one per loop goal)
|
|
11
|
+
let activeSession: LoopSession | null = null;
|
|
12
|
+
|
|
13
|
+
/** Get or create the global loop session. */
|
|
14
|
+
function getOrCreateSession(): LoopSession {
|
|
15
|
+
if (!activeSession) {
|
|
16
|
+
activeSession = new LoopSession();
|
|
17
|
+
}
|
|
18
|
+
return activeSession;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Dispose the global loop session. */
|
|
22
|
+
export function disposeSession(): void {
|
|
23
|
+
activeSession?.dispose();
|
|
24
|
+
activeSession = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Run a one-shot observer analysis using reusable session.
|
|
29
|
+
* Returns { action: "continue" } on any failure so the chat is never interrupted.
|
|
30
|
+
*/
|
|
31
|
+
export async function callObserverModel(
|
|
32
|
+
ctx: ExtensionContext,
|
|
33
|
+
provider: string,
|
|
34
|
+
modelId: string,
|
|
35
|
+
systemPrompt: string,
|
|
36
|
+
userPrompt: string,
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
onDelta?: (accumulated: string) => void
|
|
39
|
+
): Promise<SteeringDecision> {
|
|
40
|
+
const session = getOrCreateSession();
|
|
41
|
+
const started = await session.ensureStarted(ctx, provider, modelId, systemPrompt);
|
|
42
|
+
if (!started) return safeContinue('Failed to start loop session');
|
|
43
|
+
|
|
44
|
+
const text = await session.prompt(userPrompt, signal, onDelta);
|
|
45
|
+
if (text === null) return safeContinue('Model call failed');
|
|
46
|
+
return parseDecision(text);
|
|
47
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoopSession - reusable session for a single loop goal.
|
|
3
|
+
* Maintains context window across multiple analyses for token efficiency.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createAgentSession,
|
|
8
|
+
DefaultResourceLoader,
|
|
9
|
+
getAgentDir,
|
|
10
|
+
SessionManager,
|
|
11
|
+
} from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
13
|
+
|
|
14
|
+
export class LoopSession {
|
|
15
|
+
private session: Awaited<ReturnType<typeof createAgentSession>>['session'] | null = null;
|
|
16
|
+
private model: any = null;
|
|
17
|
+
private systemPrompt: string = '';
|
|
18
|
+
|
|
19
|
+
async ensureStarted(
|
|
20
|
+
ctx: ExtensionContext,
|
|
21
|
+
provider: string,
|
|
22
|
+
modelId: string,
|
|
23
|
+
systemPrompt: string
|
|
24
|
+
): Promise<boolean> {
|
|
25
|
+
// If model or system prompt changed, need new session
|
|
26
|
+
const newModel = ctx.modelRegistry.find(provider, modelId);
|
|
27
|
+
if (!newModel) return false;
|
|
28
|
+
|
|
29
|
+
if (this.session && this.model === newModel && this.systemPrompt === systemPrompt) {
|
|
30
|
+
// Session reusable
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Dispose old session if exists
|
|
35
|
+
this.dispose();
|
|
36
|
+
|
|
37
|
+
const loader = new DefaultResourceLoader({
|
|
38
|
+
cwd: ctx.cwd,
|
|
39
|
+
agentDir: getAgentDir(),
|
|
40
|
+
noExtensions: true,
|
|
41
|
+
noSkills: true,
|
|
42
|
+
noPromptTemplates: true,
|
|
43
|
+
noThemes: true,
|
|
44
|
+
systemPromptOverride: () => systemPrompt,
|
|
45
|
+
});
|
|
46
|
+
await loader.reload();
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const result = await createAgentSession({
|
|
50
|
+
sessionManager: SessionManager.inMemory(),
|
|
51
|
+
agentDir: getAgentDir(),
|
|
52
|
+
model: newModel,
|
|
53
|
+
tools: [],
|
|
54
|
+
resourceLoader: loader,
|
|
55
|
+
});
|
|
56
|
+
this.session = result.session;
|
|
57
|
+
this.model = newModel;
|
|
58
|
+
this.systemPrompt = systemPrompt;
|
|
59
|
+
return true;
|
|
60
|
+
} catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async prompt(
|
|
66
|
+
userPrompt: string,
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
onDelta?: (accumulated: string) => void
|
|
69
|
+
): Promise<string | null> {
|
|
70
|
+
if (!this.session) return null;
|
|
71
|
+
|
|
72
|
+
const onAbort = () => this.session?.abort();
|
|
73
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
74
|
+
|
|
75
|
+
let responseText = '';
|
|
76
|
+
const unsubscribe = this.session.subscribe((event) => {
|
|
77
|
+
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
|
78
|
+
responseText += event.assistantMessageEvent.delta;
|
|
79
|
+
onDelta?.(responseText);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await this.session.prompt(userPrompt);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
} finally {
|
|
88
|
+
unsubscribe();
|
|
89
|
+
signal?.removeEventListener('abort', onAbort);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return responseText;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
dispose(): void {
|
|
96
|
+
if (this.session) {
|
|
97
|
+
this.session.dispose();
|
|
98
|
+
this.session = null;
|
|
99
|
+
}
|
|
100
|
+
this.model = null;
|
|
101
|
+
}
|
|
102
|
+
}
|