@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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response parser for supervisor model decisions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { SteeringDecision, InterventionASI } from '../types.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse a supervisor decision from text response.
|
|
9
|
+
* Handles JSON extraction from markdown code blocks or raw JSON.
|
|
10
|
+
*/
|
|
11
|
+
export function parseDecision(text: string): SteeringDecision {
|
|
12
|
+
const jsonMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/) ?? text.match(/(\{[\s\S]*\})/);
|
|
13
|
+
const jsonStr = jsonMatch?.[1] ?? text.trim();
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(jsonStr) as Partial<SteeringDecision>;
|
|
17
|
+
const action = parsed.action;
|
|
18
|
+
if (action !== 'continue' && action !== 'steer' && action !== 'done') {
|
|
19
|
+
return safeContinue('Invalid action in supervisor response');
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
action,
|
|
23
|
+
message: typeof parsed.message === 'string' ? parsed.message.trim() : undefined,
|
|
24
|
+
reasoning: typeof parsed.reasoning === 'string' ? parsed.reasoning : '',
|
|
25
|
+
confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.5,
|
|
26
|
+
asi:
|
|
27
|
+
parsed.asi && typeof parsed.asi === 'object' ? (parsed.asi as InterventionASI) : undefined,
|
|
28
|
+
};
|
|
29
|
+
} catch {
|
|
30
|
+
return safeContinue('Failed to parse supervisor JSON decision');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Create a safe "continue" decision with a reason. */
|
|
35
|
+
export function safeContinue(reason: string): SteeringDecision {
|
|
36
|
+
return { action: 'continue', reasoning: reason, confidence: 0 };
|
|
37
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LoopStateManager — manages in-memory loop state and session persistence.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { LoopState, LoopIntervention, ConversationMessage, ReframeTier } from '../types.js';
|
|
7
|
+
import { detectIneffectivePattern, type IneffectivePattern } from './patterns.js';
|
|
8
|
+
import {
|
|
9
|
+
getReframeTier,
|
|
10
|
+
escalateReframeTier as escalateReframeTierInState,
|
|
11
|
+
resetReframeTier as resetReframeTierInState,
|
|
12
|
+
} from './reframe.js';
|
|
13
|
+
|
|
14
|
+
const ENTRY_TYPE = 'loop-state';
|
|
15
|
+
|
|
16
|
+
const DEFAULT_PROVIDER: string | null = null;
|
|
17
|
+
const DEFAULT_MODEL_ID: string | null = null;
|
|
18
|
+
|
|
19
|
+
export class LoopStateManager {
|
|
20
|
+
private state: LoopState | null = null;
|
|
21
|
+
private pi: ExtensionAPI;
|
|
22
|
+
|
|
23
|
+
constructor(pi: ExtensionAPI) {
|
|
24
|
+
this.pi = pi;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
start(outcome: string, provider: string, modelId: string): void {
|
|
28
|
+
this.state = {
|
|
29
|
+
active: true,
|
|
30
|
+
outcome,
|
|
31
|
+
provider,
|
|
32
|
+
modelId,
|
|
33
|
+
interventions: [],
|
|
34
|
+
startedAt: Date.now(),
|
|
35
|
+
turnCount: 0,
|
|
36
|
+
snapshotBuffer: [],
|
|
37
|
+
lastAnalyzedTurn: -1,
|
|
38
|
+
justSteered: false,
|
|
39
|
+
reframeTier: 0,
|
|
40
|
+
lastSteerTurn: -1,
|
|
41
|
+
};
|
|
42
|
+
this.persist();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
stop(): void {
|
|
46
|
+
if (!this.state) return;
|
|
47
|
+
this.state.active = false;
|
|
48
|
+
this.state.outcome = ''; // Clear the goal so /supervise starts fresh, not in append mode
|
|
49
|
+
this.persist();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
isActive(): boolean {
|
|
53
|
+
return this.state?.active === true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
getState(): LoopState | null {
|
|
57
|
+
return this.state;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addIntervention(intervention: LoopIntervention): void {
|
|
61
|
+
if (!this.state) return;
|
|
62
|
+
this.state.interventions.push(intervention);
|
|
63
|
+
this.state.justSteered = true;
|
|
64
|
+
this.state.lastSteerTurn = intervention.turnCount;
|
|
65
|
+
this.persist();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
clearJustSteered(): void {
|
|
69
|
+
if (!this.state) return;
|
|
70
|
+
this.state.justSteered = false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
incrementTurnCount(): void {
|
|
74
|
+
if (!this.state) return;
|
|
75
|
+
this.state.turnCount++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setModel(provider: string, modelId: string): void {
|
|
79
|
+
if (!this.state) return;
|
|
80
|
+
this.state.provider = provider;
|
|
81
|
+
this.state.modelId = modelId;
|
|
82
|
+
this.persist();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updateOutcome(outcome: string): void {
|
|
86
|
+
if (!this.state) return;
|
|
87
|
+
this.state.outcome = outcome;
|
|
88
|
+
this.persist();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
updateSnapshotBuffer(messages: ConversationMessage[]): void {
|
|
92
|
+
if (!this.state) return;
|
|
93
|
+
this.state.snapshotBuffer = messages;
|
|
94
|
+
this.state.lastAnalyzedTurn = this.state.turnCount;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
getSnapshotBuffer(): ConversationMessage[] {
|
|
98
|
+
return this.state?.snapshotBuffer ?? [];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
shouldAnalyzeMidRun(turnIndex: number): boolean {
|
|
102
|
+
if (!this.state) return false;
|
|
103
|
+
// Check if we just steered (verify it worked), or safety valve every 8th turn
|
|
104
|
+
if (this.state.justSteered) return true;
|
|
105
|
+
if (turnIndex > 0 && turnIndex % 8 === 0) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---- Reframe tier management ----
|
|
110
|
+
|
|
111
|
+
getReframeTier(): ReframeTier {
|
|
112
|
+
return getReframeTier(this.state);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
escalateReframeTier(): void {
|
|
116
|
+
if (!this.state) return;
|
|
117
|
+
if (escalateReframeTierInState(this.state)) {
|
|
118
|
+
this.persist();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
resetReframeTier(): void {
|
|
123
|
+
if (!this.state) return;
|
|
124
|
+
resetReframeTierInState(this.state);
|
|
125
|
+
this.persist();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- Pattern detection ----
|
|
129
|
+
|
|
130
|
+
detectIneffectivePattern(): IneffectivePattern {
|
|
131
|
+
if (!this.state) return { detected: false, similarCount: 0, turnsSinceLastSteer: 0 };
|
|
132
|
+
return detectIneffectivePattern(this.state);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- Persistence ----
|
|
136
|
+
|
|
137
|
+
/** Restore state from session entries (finds the most recent loop-state entry). */
|
|
138
|
+
loadFromSession(ctx: ExtensionContext): void {
|
|
139
|
+
const entries = ctx.sessionManager.getBranch();
|
|
140
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
141
|
+
const entry = entries[i];
|
|
142
|
+
if (entry.type === 'custom' && (entry as any).customType === ENTRY_TYPE) {
|
|
143
|
+
const loaded = (entry as any).data as LoopState;
|
|
144
|
+
// Restore ephemeral fields
|
|
145
|
+
this.state = {
|
|
146
|
+
...loaded,
|
|
147
|
+
snapshotBuffer: [],
|
|
148
|
+
lastAnalyzedTurn: -1,
|
|
149
|
+
justSteered: false,
|
|
150
|
+
};
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
this.state = null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Persist current state to session (public for compaction handler). */
|
|
158
|
+
persist(): void {
|
|
159
|
+
if (!this.state) return;
|
|
160
|
+
// Don't persist ephemeral fields that are runtime-only
|
|
161
|
+
const { snapshotBuffer, lastAnalyzedTurn, justSteered, ...toPersist } = this.state;
|
|
162
|
+
this.pi.appendEntry(ENTRY_TYPE, toPersist);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern detection for ineffective steering interventions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LoopIntervention, LoopState } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export interface IneffectivePattern {
|
|
8
|
+
detected: boolean;
|
|
9
|
+
similarCount: number;
|
|
10
|
+
turnsSinceLastSteer: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect if recent interventions show a pattern of ineffectiveness.
|
|
15
|
+
* Returns similarity info if the last 2+ steering messages are similar.
|
|
16
|
+
*/
|
|
17
|
+
export function detectIneffectivePattern(
|
|
18
|
+
state: Pick<LoopState, 'interventions' | 'turnCount' | 'lastSteerTurn'>
|
|
19
|
+
): IneffectivePattern {
|
|
20
|
+
const turnsSinceLastSteer = state.turnCount - (state.lastSteerTurn ?? 0);
|
|
21
|
+
|
|
22
|
+
// Check stagnation: no progress after 3+ turns since last steer
|
|
23
|
+
const stagnating =
|
|
24
|
+
state.lastSteerTurn !== undefined && state.lastSteerTurn >= 0 && turnsSinceLastSteer >= 3;
|
|
25
|
+
|
|
26
|
+
const recent = state.interventions.slice(-3);
|
|
27
|
+
if (recent.length < 2) {
|
|
28
|
+
// Still detect stagnation even with fewer than 2 interventions
|
|
29
|
+
return { detected: stagnating, similarCount: recent.length, turnsSinceLastSteer };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Simple similarity: check if messages share common keywords or have similar length
|
|
33
|
+
const messages = recent.map((iv) => iv.message.toLowerCase());
|
|
34
|
+
let similarCount = 1;
|
|
35
|
+
|
|
36
|
+
for (let i = 1; i < messages.length; i++) {
|
|
37
|
+
if (areMessagesSimilar(messages[i - 1], messages[i])) {
|
|
38
|
+
similarCount++;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Detected if 2+ recent messages are similar OR stagnating (no progress after 3+ turns)
|
|
43
|
+
const detected = similarCount >= 2 || stagnating;
|
|
44
|
+
|
|
45
|
+
return { detected, similarCount, turnsSinceLastSteer };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function areMessagesSimilar(a: string, b: string): boolean {
|
|
49
|
+
// Simple similarity heuristics
|
|
50
|
+
const normalize = (s: string) => s.replace(/[^\w\s]/g, '').trim();
|
|
51
|
+
const normA = normalize(a);
|
|
52
|
+
const normB = normalize(b);
|
|
53
|
+
|
|
54
|
+
// Exact match after normalization
|
|
55
|
+
if (normA === normB) return true;
|
|
56
|
+
|
|
57
|
+
// Check for common directive keywords
|
|
58
|
+
const directiveWords = [
|
|
59
|
+
'focus',
|
|
60
|
+
'implement',
|
|
61
|
+
'add',
|
|
62
|
+
'fix',
|
|
63
|
+
'create',
|
|
64
|
+
'build',
|
|
65
|
+
'need',
|
|
66
|
+
'should',
|
|
67
|
+
'must',
|
|
68
|
+
];
|
|
69
|
+
const aDirectives = directiveWords.filter((w) => normA.includes(w));
|
|
70
|
+
const bDirectives = directiveWords.filter((w) => normB.includes(w));
|
|
71
|
+
|
|
72
|
+
// If they share 2+ directive words, likely similar
|
|
73
|
+
const commonDirectives = aDirectives.filter((w) => bDirectives.includes(w));
|
|
74
|
+
if (commonDirectives.length >= 2) return true;
|
|
75
|
+
|
|
76
|
+
// Length similarity (within 30%)
|
|
77
|
+
const lenRatio = Math.min(normA.length, normB.length) / Math.max(normA.length, normB.length);
|
|
78
|
+
if (lenRatio > 0.7 && commonDirectives.length >= 1) return true;
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reframe tier state management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ReframeTier, LoopState } from '../types.js';
|
|
6
|
+
|
|
7
|
+
/** Maximum reframe tier value */
|
|
8
|
+
const MAX_REFRAME_TIER: ReframeTier = 4;
|
|
9
|
+
|
|
10
|
+
/** Get the current reframe tier from state */
|
|
11
|
+
export function getReframeTier(state: LoopState | null): ReframeTier {
|
|
12
|
+
return state?.reframeTier ?? 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Get next tier value */
|
|
16
|
+
function getNextTier(tier: ReframeTier): ReframeTier {
|
|
17
|
+
return Math.min(tier + 1, MAX_REFRAME_TIER) as ReframeTier;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Escalate reframe tier in state */
|
|
21
|
+
export function escalateReframeTier(state: LoopState): boolean {
|
|
22
|
+
const current = state.reframeTier ?? 0;
|
|
23
|
+
if (current < MAX_REFRAME_TIER) {
|
|
24
|
+
state.reframeTier = getNextTier(current);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Reset reframe tier to 0 */
|
|
31
|
+
export function resetReframeTier(state: LoopState): void {
|
|
32
|
+
state.reframeTier = 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagent detection via process tree inspection.
|
|
3
|
+
*
|
|
4
|
+
* This is extension-agnostic: we don't care HOW subagents were created
|
|
5
|
+
* (pi-messenger, manual spawn, or other extensions). We just check if
|
|
6
|
+
* there are child 'pi' processes still running.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { exec } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
|
|
14
|
+
interface SubagentStatus {
|
|
15
|
+
hasActiveSubagents: boolean;
|
|
16
|
+
count: number;
|
|
17
|
+
pids: number[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check for child pi processes spawned by the current process.
|
|
22
|
+
* Works on macOS and Linux via ps. Windows returns false (not implemented).
|
|
23
|
+
*/
|
|
24
|
+
export async function checkChildPiProcesses(): Promise<SubagentStatus> {
|
|
25
|
+
const platform = process.platform;
|
|
26
|
+
|
|
27
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
28
|
+
return checkUnixChildProcesses();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Windows: not implemented, assume no subagents
|
|
32
|
+
return { hasActiveSubagents: false, count: 0, pids: [] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function checkUnixChildProcesses(): Promise<SubagentStatus> {
|
|
36
|
+
try {
|
|
37
|
+
const ppid = process.pid;
|
|
38
|
+
|
|
39
|
+
// Get all pi processes with their parent PID
|
|
40
|
+
// Format: ppid pid command
|
|
41
|
+
const { stdout } = await execAsync(`ps -eo ppid,pid,comm | grep -E "\\bpi\\b" || true`);
|
|
42
|
+
|
|
43
|
+
const pids: number[] = [];
|
|
44
|
+
|
|
45
|
+
for (const line of stdout.trim().split('\n')) {
|
|
46
|
+
const parts = line.trim().split(/\s+/);
|
|
47
|
+
if (parts.length < 3) continue;
|
|
48
|
+
|
|
49
|
+
const childPpid = parseInt(parts[0], 10);
|
|
50
|
+
const childPid = parseInt(parts[1], 10);
|
|
51
|
+
const comm = parts[2];
|
|
52
|
+
|
|
53
|
+
// Check if this pi process is our direct child
|
|
54
|
+
// Also check for grandchildren (subagents spawning subagents)
|
|
55
|
+
if (childPpid === ppid && comm === 'pi') {
|
|
56
|
+
pids.push(childPid);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
hasActiveSubagents: pids.length > 0,
|
|
62
|
+
count: pids.length,
|
|
63
|
+
pids,
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return { hasActiveSubagents: false, count: 0, pids: [] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Poll until no child pi processes remain or timeout.
|
|
72
|
+
* Returns true if all subagents completed, false if timeout.
|
|
73
|
+
*/
|
|
74
|
+
export async function waitForSubagents(
|
|
75
|
+
checkIntervalMs: number = 2000,
|
|
76
|
+
timeoutMs: number = 60000
|
|
77
|
+
): Promise<{ completed: boolean; finalStatus: SubagentStatus }> {
|
|
78
|
+
const start = Date.now();
|
|
79
|
+
|
|
80
|
+
while (Date.now() - start < timeoutMs) {
|
|
81
|
+
const status = await checkChildPiProcesses();
|
|
82
|
+
if (!status.hasActiveSubagents) {
|
|
83
|
+
return { completed: true, finalStatus: status };
|
|
84
|
+
}
|
|
85
|
+
await sleep(checkIntervalMs);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const finalStatus = await checkChildPiProcesses();
|
|
89
|
+
return { completed: !finalStatus.hasActiveSubagents, finalStatus };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function sleep(ms: number): Promise<void> {
|
|
93
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the pi-loop extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type LoopAction = 'continue' | 'steer' | 'done';
|
|
6
|
+
|
|
7
|
+
/** A single intervention record */
|
|
8
|
+
export interface LoopIntervention {
|
|
9
|
+
turnCount: number;
|
|
10
|
+
message: string;
|
|
11
|
+
reasoning: string;
|
|
12
|
+
timestamp: number;
|
|
13
|
+
/** Self-generated actionable side information */
|
|
14
|
+
asi?: InterventionASI;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Reframe tier tracks escalation of intervention strategies */
|
|
18
|
+
export type ReframeTier = 0 | 1 | 2 | 3 | 4;
|
|
19
|
+
|
|
20
|
+
/** Actionable Side Information — free-form observations from interventions */
|
|
21
|
+
export interface InterventionASI {
|
|
22
|
+
/** Any observations worth remembering for future decisions */
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Full loop state — persisted to session */
|
|
27
|
+
export interface LoopState {
|
|
28
|
+
active: boolean;
|
|
29
|
+
outcome: string;
|
|
30
|
+
provider: string; // e.g. "anthropic"
|
|
31
|
+
modelId: string; // e.g. "claude-haiku-4-5-20251001"
|
|
32
|
+
interventions: LoopIntervention[];
|
|
33
|
+
startedAt: number;
|
|
34
|
+
turnCount: number;
|
|
35
|
+
// Incremental snapshot buffer (not persisted, rebuilt on load)
|
|
36
|
+
snapshotBuffer?: ConversationMessage[];
|
|
37
|
+
lastAnalyzedTurn?: number;
|
|
38
|
+
justSteered?: boolean; // flag to check if steer worked
|
|
39
|
+
// Reframe escalation tracking
|
|
40
|
+
reframeTier?: ReframeTier;
|
|
41
|
+
lastSteerTurn?: number; // track when we last steered to detect ineffectiveness
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Decision returned by the observer LLM */
|
|
45
|
+
export interface SteeringDecision {
|
|
46
|
+
action: LoopAction;
|
|
47
|
+
message?: string;
|
|
48
|
+
reasoning: string;
|
|
49
|
+
confidence: number;
|
|
50
|
+
/** Self-generated actionable side information when steering */
|
|
51
|
+
asi?: InterventionASI;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Content block types for rich message capture */
|
|
55
|
+
export type ContentBlock =
|
|
56
|
+
| { type: 'text'; text: string }
|
|
57
|
+
| { type: 'image'; source: string; mimeType?: string }
|
|
58
|
+
| { type: 'tool_call'; id: string; name: string; input: Record<string, unknown> }
|
|
59
|
+
| {
|
|
60
|
+
type: 'tool_result';
|
|
61
|
+
toolCallId: string;
|
|
62
|
+
content: (ContentBlock | string)[];
|
|
63
|
+
isError?: boolean;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** A simplified message for building the supervisor context - now with full tool support */
|
|
67
|
+
export interface ConversationMessage {
|
|
68
|
+
role: 'user' | 'assistant' | 'tool_results';
|
|
69
|
+
content: string;
|
|
70
|
+
/** Rich content blocks for full fidelity capture (images, tool calls, tool results) */
|
|
71
|
+
blocks?: ContentBlock[];
|
|
72
|
+
/** Raw tool results associated with this turn (for assistant messages) */
|
|
73
|
+
toolResults?: ToolResultEntry[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Captured tool result entry */
|
|
77
|
+
export interface ToolResultEntry {
|
|
78
|
+
toolCallId: string;
|
|
79
|
+
toolName: string;
|
|
80
|
+
input: Record<string, unknown>;
|
|
81
|
+
content: ContentBlock[];
|
|
82
|
+
isError: boolean;
|
|
83
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation utilities for the supervisor widget.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { WidgetState, WidgetAction } from './types.js';
|
|
7
|
+
import type { LoopIntervention } from '../types.js';
|
|
8
|
+
import { WIDGET_ID, ANIMATION_STEP_MS } from './types.js';
|
|
9
|
+
|
|
10
|
+
/** Type for the render function callback */
|
|
11
|
+
export type RenderFn = (
|
|
12
|
+
ctx: ExtensionContext,
|
|
13
|
+
snap: { outcome: string; interventions: LoopIntervention[] },
|
|
14
|
+
action: WidgetAction,
|
|
15
|
+
lastThinking: string,
|
|
16
|
+
hideFromBottom: number
|
|
17
|
+
) => void;
|
|
18
|
+
|
|
19
|
+
/** Start the line-by-line clear animation - hides lines from bottom to top */
|
|
20
|
+
export function startLineClearAnimation(
|
|
21
|
+
ctx: ExtensionContext,
|
|
22
|
+
state: WidgetState,
|
|
23
|
+
renderFn: RenderFn
|
|
24
|
+
): void {
|
|
25
|
+
if (!state.lastActiveState || state.lastThinkingLines.length === 0) return;
|
|
26
|
+
|
|
27
|
+
const isSteering = state.lastActionType === 'steering';
|
|
28
|
+
const targetVisibleCount = 0;
|
|
29
|
+
|
|
30
|
+
const animateStep = () => {
|
|
31
|
+
const currentVisible = state.lastThinkingLines.length - state.hiddenFromBottomCount;
|
|
32
|
+
|
|
33
|
+
if (currentVisible <= targetVisibleCount) {
|
|
34
|
+
if (isSteering) {
|
|
35
|
+
state.lastThinkingLines = [];
|
|
36
|
+
state.hiddenFromBottomCount = 0;
|
|
37
|
+
const reframeTier =
|
|
38
|
+
state.storedAction?.type === 'steering' ? (state.storedAction.reframeTier ?? 0) : 0;
|
|
39
|
+
renderFn(
|
|
40
|
+
ctx,
|
|
41
|
+
state.lastActiveState!,
|
|
42
|
+
{ type: 'steering', message: '', reframeTier },
|
|
43
|
+
'',
|
|
44
|
+
0
|
|
45
|
+
);
|
|
46
|
+
return;
|
|
47
|
+
} else {
|
|
48
|
+
state.lastActiveState = null;
|
|
49
|
+
state.lastThinking = '';
|
|
50
|
+
state.lastThinkingLines = [];
|
|
51
|
+
state.hiddenFromBottomCount = 0;
|
|
52
|
+
state.storedAction = null;
|
|
53
|
+
ctx.ui.setWidget(WIDGET_ID, undefined);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
state.hiddenFromBottomCount++;
|
|
59
|
+
const reframeTier =
|
|
60
|
+
state.storedAction?.type === 'steering' ? (state.storedAction.reframeTier ?? 0) : 0;
|
|
61
|
+
const fallbackAction: WidgetAction = isSteering
|
|
62
|
+
? { type: 'steering', message: '', reframeTier }
|
|
63
|
+
: { type: 'done', reframeTier: 0 };
|
|
64
|
+
renderFn(ctx, state.lastActiveState!, fallbackAction, '', state.hiddenFromBottomCount);
|
|
65
|
+
|
|
66
|
+
state.animationTimer = setTimeout(animateStep, ANIMATION_STEP_MS);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
animateStep();
|
|
70
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-picker — wraps pi's internal ModelSelectorComponent for use in
|
|
3
|
+
* the /loop model command. Shows the same model selector the user
|
|
4
|
+
* sees when pressing Ctrl+P in pi, with search and API-key availability.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ModelRuntime,
|
|
9
|
+
ModelSelectorComponent,
|
|
10
|
+
SettingsManager,
|
|
11
|
+
getAgentDir,
|
|
12
|
+
} from '@earendil-works/pi-coding-agent';
|
|
13
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
14
|
+
import type { Model } from '@earendil-works/pi-ai';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open the interactive model picker.
|
|
18
|
+
* Returns the selected Model, or null if the user cancelled.
|
|
19
|
+
*/
|
|
20
|
+
export async function pickModel(
|
|
21
|
+
ctx: ExtensionContext,
|
|
22
|
+
currentProvider?: string,
|
|
23
|
+
currentModelId?: string
|
|
24
|
+
): Promise<Model<any> | null> {
|
|
25
|
+
if (ctx.mode !== 'tui') {
|
|
26
|
+
if (!ctx.hasUI) return null;
|
|
27
|
+
const all = ctx.modelRegistry.getAll();
|
|
28
|
+
if (all.length === 0) {
|
|
29
|
+
ctx.ui.notify('No models available.', 'info');
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const items = all.map((m) => `${m.name ?? m.id} (${m.provider}/${m.id})`);
|
|
33
|
+
const pick = await ctx.ui.select('Pick a model', items);
|
|
34
|
+
if (pick === undefined) return null;
|
|
35
|
+
const idx = items.indexOf(pick);
|
|
36
|
+
if (idx < 0) return null;
|
|
37
|
+
return all[idx] ?? null;
|
|
38
|
+
}
|
|
39
|
+
// Resolve the currently-selected supervisor model (to pre-highlight it)
|
|
40
|
+
const currentModel =
|
|
41
|
+
currentProvider && currentModelId
|
|
42
|
+
? ctx.modelRegistry.find(currentProvider, currentModelId)
|
|
43
|
+
: undefined;
|
|
44
|
+
|
|
45
|
+
// Minimal in-memory settings — we only need the selector, not persistence
|
|
46
|
+
const settingsManager = SettingsManager.inMemory();
|
|
47
|
+
|
|
48
|
+
// pi 0.80.8: ModelSelectorComponent takes the canonical ModelRuntime
|
|
49
|
+
// (previously the sync ModelRegistry facade). ExtensionContext only exposes
|
|
50
|
+
// modelRegistry, so build a runtime from the agent dir for the picker.
|
|
51
|
+
const modelRuntime = await ModelRuntime.create({
|
|
52
|
+
authPath: `${getAgentDir()}/auth.json`,
|
|
53
|
+
modelsPath: `${getAgentDir()}/models.json`,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return ctx.ui.custom<Model<any> | null>((tui, _theme, _kb, done) => {
|
|
57
|
+
const component = new ModelSelectorComponent(
|
|
58
|
+
tui,
|
|
59
|
+
currentModel,
|
|
60
|
+
settingsManager,
|
|
61
|
+
modelRuntime,
|
|
62
|
+
[], // no scoped-model cycling — we want the full model list
|
|
63
|
+
(model) => done(model),
|
|
64
|
+
() => done(null)
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
// Give focus so the search input is active immediately
|
|
68
|
+
component.focused = true;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
render: (width) => component.render(width),
|
|
72
|
+
invalidate: () => component.invalidate(),
|
|
73
|
+
handleInput: (data) => {
|
|
74
|
+
component.handleInput(data);
|
|
75
|
+
tui.requestRender();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
});
|
|
79
|
+
}
|