@monotykamary/pi-supervisor 0.5.9
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 +120 -0
- package/LICENSE +21 -0
- package/README.md +341 -0
- package/media/demo.mp4 +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +87 -0
- package/src/compaction/brief.ts +841 -0
- package/src/compaction/build-sections.ts +340 -0
- package/src/compaction/causal-keys.ts +138 -0
- package/src/compaction/content.ts +68 -0
- package/src/compaction/extract/commits.ts +78 -0
- package/src/compaction/extract/goals.ts +79 -0
- package/src/compaction/extract/preferences.ts +52 -0
- package/src/compaction/extract/shared-symbols.ts +376 -0
- package/src/compaction/filter-noise.ts +47 -0
- package/src/compaction/format.ts +89 -0
- package/src/compaction/index.ts +38 -0
- package/src/compaction/normalize.ts +73 -0
- package/src/compaction/sanitize.ts +5 -0
- package/src/compaction/sections.ts +19 -0
- package/src/compaction/skill-collapse.ts +35 -0
- package/src/compaction/tool-args.ts +14 -0
- package/src/compaction/types.ts +26 -0
- package/src/core/analyzer.ts +58 -0
- package/src/core/index.ts +8 -0
- package/src/core/inference.ts +77 -0
- package/src/core/prompt-builder.ts +137 -0
- package/src/core/prompt-loader.ts +125 -0
- package/src/core/reframe.ts +27 -0
- package/src/fabric-provider.ts +115 -0
- package/src/global-config.ts +65 -0
- package/src/index.ts +514 -0
- package/src/session/client.ts +46 -0
- package/src/session/response-parser.ts +37 -0
- package/src/session/supervisor-session.ts +102 -0
- package/src/state/manager.ts +133 -0
- package/src/state/mid-run-signals.ts +103 -0
- package/src/state/patterns.ts +82 -0
- package/src/state/reframe.ts +27 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +42 -0
- package/src/ui/animations.ts +95 -0
- package/src/ui/model-picker.ts +72 -0
- package/src/ui/model-settings-selector.ts +440 -0
- package/src/ui/model-sort.ts +101 -0
- package/src/ui/renderer.ts +314 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +507 -0
- package/tests/engine.test.ts +622 -0
- package/tests/ephemeral-supervision.test.ts +347 -0
- package/tests/fabric-provider.test.ts +55 -0
- package/tests/full-fidelity-snapshot.test.ts +250 -0
- package/tests/global-config.test.ts +74 -0
- package/tests/model-sort.test.ts +157 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +474 -0
- package/tests/status-widget.test.ts +539 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +363 -0
- package/tests/supervise-model-command.test.ts +184 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session client - high-level interface for calling the supervisor model.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { SteeringDecision } from '../types.js';
|
|
7
|
+
import { SupervisorSession } from './supervisor-session.js';
|
|
8
|
+
import { parseDecision, safeContinue } from './response-parser.js';
|
|
9
|
+
|
|
10
|
+
// Global session manager (one per supervision goal)
|
|
11
|
+
let activeSession: SupervisorSession | null = null;
|
|
12
|
+
|
|
13
|
+
function getOrCreateSession(): SupervisorSession {
|
|
14
|
+
if (!activeSession) {
|
|
15
|
+
activeSession = new SupervisorSession();
|
|
16
|
+
}
|
|
17
|
+
return activeSession;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Dispose the global supervisor session. */
|
|
21
|
+
export function disposeSession(): void {
|
|
22
|
+
activeSession?.dispose();
|
|
23
|
+
activeSession = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Run a one-shot supervisor analysis using reusable session.
|
|
28
|
+
* Returns { action: "continue" } on any failure so the chat is never interrupted.
|
|
29
|
+
*/
|
|
30
|
+
export async function callSupervisorModel(
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
provider: string,
|
|
33
|
+
modelId: string,
|
|
34
|
+
systemPrompt: string,
|
|
35
|
+
userPrompt: string,
|
|
36
|
+
signal?: AbortSignal,
|
|
37
|
+
onDelta?: (accumulated: string) => void
|
|
38
|
+
): Promise<SteeringDecision> {
|
|
39
|
+
const session = getOrCreateSession();
|
|
40
|
+
const started = await session.ensureStarted(ctx, provider, modelId, systemPrompt);
|
|
41
|
+
if (!started) return safeContinue('Failed to start supervisor session');
|
|
42
|
+
|
|
43
|
+
const text = await session.prompt(userPrompt, signal, onDelta);
|
|
44
|
+
if (text === null) return safeContinue('Model call failed');
|
|
45
|
+
return parseDecision(text);
|
|
46
|
+
}
|
|
@@ -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,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SupervisorSession - reusable session for a single supervision 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 SupervisorSession {
|
|
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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SupervisorStateManager — manages in-memory supervisor state and session persistence.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { SupervisorState, SupervisorIntervention, 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 = 'supervisor-state';
|
|
15
|
+
|
|
16
|
+
export class SupervisorStateManager {
|
|
17
|
+
private state: SupervisorState | null = null;
|
|
18
|
+
private pi: ExtensionAPI;
|
|
19
|
+
|
|
20
|
+
constructor(pi: ExtensionAPI) {
|
|
21
|
+
this.pi = pi;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
start(outcome: string, provider: string, modelId: string): void {
|
|
25
|
+
this.state = {
|
|
26
|
+
active: true,
|
|
27
|
+
outcome,
|
|
28
|
+
provider,
|
|
29
|
+
modelId,
|
|
30
|
+
interventions: [],
|
|
31
|
+
startedAt: Date.now(),
|
|
32
|
+
reframeTier: 0,
|
|
33
|
+
idleSteers: 0,
|
|
34
|
+
};
|
|
35
|
+
this.persist();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
stop(): void {
|
|
39
|
+
if (!this.state) return;
|
|
40
|
+
this.state.active = false;
|
|
41
|
+
this.state.outcome = '';
|
|
42
|
+
this.persist();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
isActive(): boolean {
|
|
46
|
+
return this.state?.active === true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getState(): SupervisorState | null {
|
|
50
|
+
return this.state;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
addIntervention(intervention: SupervisorIntervention): void {
|
|
54
|
+
if (!this.state) return;
|
|
55
|
+
this.state.interventions.push(intervention);
|
|
56
|
+
this.persist();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
incrementIdleSteers(): void {
|
|
60
|
+
if (!this.state) return;
|
|
61
|
+
this.state.idleSteers = (this.state.idleSteers ?? 0) + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
resetIdleSteers(): void {
|
|
65
|
+
if (!this.state) return;
|
|
66
|
+
this.state.idleSteers = 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getIdleSteers(): number {
|
|
70
|
+
return this.state?.idleSteers ?? 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
setModel(provider: string, modelId: string): void {
|
|
74
|
+
if (!this.state) return;
|
|
75
|
+
this.state.provider = provider;
|
|
76
|
+
this.state.modelId = modelId;
|
|
77
|
+
this.persist();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
updateOutcome(outcome: string): void {
|
|
81
|
+
if (!this.state) return;
|
|
82
|
+
this.state.outcome = outcome;
|
|
83
|
+
this.persist();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- Reframe tier management ----
|
|
87
|
+
|
|
88
|
+
getReframeTier(): ReframeTier {
|
|
89
|
+
return getReframeTier(this.state);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
escalateReframeTier(): void {
|
|
93
|
+
if (!this.state) return;
|
|
94
|
+
if (escalateReframeTierInState(this.state)) {
|
|
95
|
+
this.persist();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
resetReframeTier(): void {
|
|
100
|
+
if (!this.state) return;
|
|
101
|
+
resetReframeTierInState(this.state);
|
|
102
|
+
this.persist();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- Pattern detection ----
|
|
106
|
+
|
|
107
|
+
detectIneffectivePattern(): IneffectivePattern {
|
|
108
|
+
if (!this.state) return { detected: false, similarCount: 0, secondsSinceLastSteer: 0 };
|
|
109
|
+
return detectIneffectivePattern(this.state);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- Persistence ----
|
|
113
|
+
|
|
114
|
+
loadFromSession(ctx: ExtensionContext): void {
|
|
115
|
+
const entries = ctx.sessionManager.getBranch();
|
|
116
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
117
|
+
const entry = entries[i];
|
|
118
|
+
if (entry.type === 'custom' && (entry as any).customType === ENTRY_TYPE) {
|
|
119
|
+
const loaded = (entry as any).data as SupervisorState;
|
|
120
|
+
this.state = {
|
|
121
|
+
...loaded,
|
|
122
|
+
};
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
this.state = null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
persist(): void {
|
|
130
|
+
if (!this.state) return;
|
|
131
|
+
this.pi.appendEntry(ENTRY_TYPE, this.state);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mid-run signal detection — replaces the blind turn counter with
|
|
3
|
+
* reactive signals computed from the conversation tail.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Message } from '@earendil-works/pi-ai';
|
|
7
|
+
import type { NormalizedBlock } from '../compaction/types.js';
|
|
8
|
+
import { normalize } from '../compaction/normalize.js';
|
|
9
|
+
import { filterNoise } from '../compaction/filter-noise.js';
|
|
10
|
+
import { extractPath } from '../compaction/tool-args.js';
|
|
11
|
+
|
|
12
|
+
export interface MidRunSignal {
|
|
13
|
+
type: 'tool_error' | 'file_read_loop';
|
|
14
|
+
detail?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** How many recent messages to scan for signals. */
|
|
18
|
+
const SIGNAL_WINDOW = 30;
|
|
19
|
+
|
|
20
|
+
/** Reads of the same file without an edit to that file triggers a loop signal. */
|
|
21
|
+
const FILE_READ_LOOP_THRESHOLD = 5;
|
|
22
|
+
|
|
23
|
+
const FILE_MUTATION_TOOLS = new Set(['Edit', 'Write', 'edit', 'write', 'MultiEdit']);
|
|
24
|
+
|
|
25
|
+
const FILE_READ_TOOLS = new Set(['Read', 'read', 'read_file', 'View']);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Detect mid-run signals from the recent conversation tail.
|
|
29
|
+
* Returns the first signal found (ordered by severity), or null if none.
|
|
30
|
+
*/
|
|
31
|
+
export function detectMidRunSignals(messages: Message[]): MidRunSignal | null {
|
|
32
|
+
const tail = messages.slice(-SIGNAL_WINDOW);
|
|
33
|
+
if (tail.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
const blocks = filterNoise(normalize(tail));
|
|
36
|
+
if (blocks.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
return checkToolErrors(blocks) ?? checkFileReadLoop(blocks);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** How many consecutive tool errors (separated by their tool_call) trigger a signal. */
|
|
42
|
+
const CONSECUTIVE_ERROR_THRESHOLD = 5;
|
|
43
|
+
|
|
44
|
+
function checkToolErrors(blocks: NormalizedBlock[]): MidRunSignal | null {
|
|
45
|
+
let consecutive = 0;
|
|
46
|
+
for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - 10); i--) {
|
|
47
|
+
const b = blocks[i];
|
|
48
|
+
if (b.kind === 'tool_result' && b.isError) {
|
|
49
|
+
consecutive++;
|
|
50
|
+
if (consecutive >= CONSECUTIVE_ERROR_THRESHOLD) {
|
|
51
|
+
return { type: 'tool_error', detail: `${b.name}: ${b.text.slice(0, 80)}` };
|
|
52
|
+
}
|
|
53
|
+
} else if (b.kind === 'tool_call') {
|
|
54
|
+
// tool_call between error results is expected — skip it
|
|
55
|
+
continue;
|
|
56
|
+
} else if (b.kind === 'tool_result') {
|
|
57
|
+
// A successful result breaks the streak
|
|
58
|
+
break;
|
|
59
|
+
} else {
|
|
60
|
+
// Any other block (user, assistant, bash) breaks the streak
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build a loop-detection key for a read tool call. Includes line range so that
|
|
68
|
+
* reading different portions of the same file (pagination) is not treated as a loop. */
|
|
69
|
+
function readLoopKey(args: Record<string, unknown>): string | null {
|
|
70
|
+
const path = extractPath(args);
|
|
71
|
+
if (!path) return null;
|
|
72
|
+
const offset = args['offset'];
|
|
73
|
+
const limit = args['limit'];
|
|
74
|
+
if (offset != null || limit != null) {
|
|
75
|
+
return `${path}:${offset ?? ''}-${limit ?? ''}`;
|
|
76
|
+
}
|
|
77
|
+
return path;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkFileReadLoop(blocks: NormalizedBlock[]): MidRunSignal | null {
|
|
81
|
+
const readCounts = new Map<string, number>();
|
|
82
|
+
|
|
83
|
+
for (const b of blocks) {
|
|
84
|
+
if (b.kind !== 'tool_call') continue;
|
|
85
|
+
|
|
86
|
+
if (FILE_MUTATION_TOOLS.has(b.name)) {
|
|
87
|
+
const path = extractPath(b.args);
|
|
88
|
+
if (path) readCounts.delete(path);
|
|
89
|
+
} else if (FILE_READ_TOOLS.has(b.name)) {
|
|
90
|
+
const key = readLoopKey(b.args);
|
|
91
|
+
if (key) {
|
|
92
|
+
const count = (readCounts.get(key) ?? 0) + 1;
|
|
93
|
+
readCounts.set(key, count);
|
|
94
|
+
if (count >= FILE_READ_LOOP_THRESHOLD) {
|
|
95
|
+
// Report just the file path for the signal detail
|
|
96
|
+
return { type: 'file_read_loop', detail: extractPath(b.args)! };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pattern detection for ineffective steering interventions.
|
|
3
|
+
* Uses timestamps from intervention records instead of turn counting.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SupervisorIntervention, SupervisorState } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export interface IneffectivePattern {
|
|
9
|
+
detected: boolean;
|
|
10
|
+
similarCount: number;
|
|
11
|
+
secondsSinceLastSteer: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Stagnation threshold: no steer in this many seconds suggests ineffectiveness */
|
|
15
|
+
const STAGNATION_SECS = 60;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect if recent interventions show a pattern of ineffectiveness.
|
|
19
|
+
* Returns similarity info if the last 2+ steering messages are similar,
|
|
20
|
+
* or if stagnation is detected (no new steer in a while despite active supervision).
|
|
21
|
+
*/
|
|
22
|
+
export function detectIneffectivePattern(
|
|
23
|
+
state: Pick<SupervisorState, 'interventions' | 'startedAt'>
|
|
24
|
+
): IneffectivePattern {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const lastSteerTs =
|
|
27
|
+
state.interventions.length > 0
|
|
28
|
+
? state.interventions[state.interventions.length - 1].timestamp
|
|
29
|
+
: state.startedAt;
|
|
30
|
+
const secondsSinceLastSteer = Math.round((now - lastSteerTs) / 1000);
|
|
31
|
+
|
|
32
|
+
// Stagnation: no new steer action in a while
|
|
33
|
+
const stagnating = secondsSinceLastSteer >= STAGNATION_SECS;
|
|
34
|
+
|
|
35
|
+
const recent = state.interventions.slice(-3);
|
|
36
|
+
if (recent.length < 2) {
|
|
37
|
+
return { detected: stagnating, similarCount: recent.length, secondsSinceLastSteer };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const messages = recent.map((iv) => iv.message.toLowerCase());
|
|
41
|
+
let similarCount = 1;
|
|
42
|
+
|
|
43
|
+
for (let i = 1; i < messages.length; i++) {
|
|
44
|
+
if (areMessagesSimilar(messages[i - 1], messages[i])) {
|
|
45
|
+
similarCount++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const detected = similarCount >= 2 || stagnating;
|
|
50
|
+
|
|
51
|
+
return { detected, similarCount, secondsSinceLastSteer };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function areMessagesSimilar(a: string, b: string): boolean {
|
|
55
|
+
const normalize = (s: string) => s.replace(/[^\w\s]/g, '').trim();
|
|
56
|
+
const normA = normalize(a);
|
|
57
|
+
const normB = normalize(b);
|
|
58
|
+
|
|
59
|
+
if (normA === normB) return true;
|
|
60
|
+
|
|
61
|
+
const directiveWords = [
|
|
62
|
+
'focus',
|
|
63
|
+
'implement',
|
|
64
|
+
'add',
|
|
65
|
+
'fix',
|
|
66
|
+
'create',
|
|
67
|
+
'build',
|
|
68
|
+
'need',
|
|
69
|
+
'should',
|
|
70
|
+
'must',
|
|
71
|
+
];
|
|
72
|
+
const aDirectives = directiveWords.filter((w) => normA.includes(w));
|
|
73
|
+
const bDirectives = directiveWords.filter((w) => normB.includes(w));
|
|
74
|
+
|
|
75
|
+
const commonDirectives = aDirectives.filter((w) => bDirectives.includes(w));
|
|
76
|
+
if (commonDirectives.length >= 2) return true;
|
|
77
|
+
|
|
78
|
+
const lenRatio = Math.min(normA.length, normB.length) / Math.max(normA.length, normB.length);
|
|
79
|
+
if (lenRatio > 0.7 && commonDirectives.length >= 1) return true;
|
|
80
|
+
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reframe tier state management.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ReframeTier, SupervisorState } from '../types.js';
|
|
6
|
+
|
|
7
|
+
const MAX_TIER: ReframeTier = 4;
|
|
8
|
+
|
|
9
|
+
/** Get the current reframe tier from state */
|
|
10
|
+
export function getReframeTier(state: SupervisorState | null): ReframeTier {
|
|
11
|
+
return state?.reframeTier ?? 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Escalate reframe tier in state */
|
|
15
|
+
export function escalateReframeTier(state: SupervisorState): boolean {
|
|
16
|
+
const current = state.reframeTier ?? 0;
|
|
17
|
+
if (current < MAX_TIER) {
|
|
18
|
+
state.reframeTier = (current + 1) as ReframeTier;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Reset reframe tier to 0 */
|
|
25
|
+
export function resetReframeTier(state: SupervisorState): void {
|
|
26
|
+
state.reframeTier = 0;
|
|
27
|
+
}
|
|
@@ -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
|
+
export 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,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core types for the pi-supervisor extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** A single intervention record */
|
|
6
|
+
export interface SupervisorIntervention {
|
|
7
|
+
message: string;
|
|
8
|
+
reasoning: string;
|
|
9
|
+
timestamp: number;
|
|
10
|
+
asi?: InterventionASI;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Reframe tier tracks escalation of intervention strategies */
|
|
14
|
+
export type ReframeTier = 0 | 1 | 2 | 3 | 4;
|
|
15
|
+
|
|
16
|
+
/** Actionable Side Information — free-form observations from interventions */
|
|
17
|
+
export interface InterventionASI {
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Full supervisor state — persisted to session */
|
|
22
|
+
export interface SupervisorState {
|
|
23
|
+
active: boolean;
|
|
24
|
+
outcome: string;
|
|
25
|
+
provider: string;
|
|
26
|
+
modelId: string;
|
|
27
|
+
interventions: SupervisorIntervention[];
|
|
28
|
+
startedAt: number;
|
|
29
|
+
reframeTier?: ReframeTier;
|
|
30
|
+
/** Consecutive agent_end steers; reset on done/stop/new supervision */
|
|
31
|
+
idleSteers?: number;
|
|
32
|
+
/** Whether we just steered and should verify on next mid-run event */
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Decision returned by the supervisor LLM */
|
|
36
|
+
export interface SteeringDecision {
|
|
37
|
+
action: 'continue' | 'steer' | 'done';
|
|
38
|
+
message?: string;
|
|
39
|
+
reasoning: string;
|
|
40
|
+
confidence: number;
|
|
41
|
+
asi?: InterventionASI;
|
|
42
|
+
}
|