grov 0.1.1 → 0.2.2
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/README.md +66 -87
- package/dist/cli.js +23 -37
- package/dist/commands/capture.js +1 -1
- package/dist/commands/disable.d.ts +1 -0
- package/dist/commands/disable.js +14 -0
- package/dist/commands/drift-test.js +56 -68
- package/dist/commands/init.js +29 -17
- package/dist/commands/proxy-status.d.ts +1 -0
- package/dist/commands/proxy-status.js +32 -0
- package/dist/commands/unregister.js +7 -1
- package/dist/lib/correction-builder-proxy.d.ts +16 -0
- package/dist/lib/correction-builder-proxy.js +125 -0
- package/dist/lib/correction-builder.js +1 -1
- package/dist/lib/drift-checker-proxy.d.ts +63 -0
- package/dist/lib/drift-checker-proxy.js +373 -0
- package/dist/lib/drift-checker.js +1 -1
- package/dist/lib/hooks.d.ts +11 -0
- package/dist/lib/hooks.js +33 -0
- package/dist/lib/llm-extractor.d.ts +60 -11
- package/dist/lib/llm-extractor.js +419 -98
- package/dist/lib/settings.d.ts +19 -0
- package/dist/lib/settings.js +63 -0
- package/dist/lib/store.d.ts +201 -43
- package/dist/lib/store.js +653 -90
- package/dist/proxy/action-parser.d.ts +58 -0
- package/dist/proxy/action-parser.js +196 -0
- package/dist/proxy/config.d.ts +26 -0
- package/dist/proxy/config.js +67 -0
- package/dist/proxy/forwarder.d.ts +24 -0
- package/dist/proxy/forwarder.js +119 -0
- package/dist/proxy/index.d.ts +1 -0
- package/dist/proxy/index.js +30 -0
- package/dist/proxy/request-processor.d.ts +12 -0
- package/dist/proxy/request-processor.js +94 -0
- package/dist/proxy/response-processor.d.ts +14 -0
- package/dist/proxy/response-processor.js +128 -0
- package/dist/proxy/server.d.ts +9 -0
- package/dist/proxy/server.js +911 -0
- package/package.json +10 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// grov unregister - Remove hooks from Claude Code settings
|
|
2
|
-
import { unregisterGrovHooks, getSettingsPath } from '../lib/hooks.js';
|
|
2
|
+
import { unregisterGrovHooks, getSettingsPath, setProxyEnv } from '../lib/hooks.js';
|
|
3
3
|
export async function unregister() {
|
|
4
4
|
console.log('Removing grov hooks from Claude Code...\n');
|
|
5
5
|
try {
|
|
@@ -11,6 +11,12 @@ export async function unregister() {
|
|
|
11
11
|
else {
|
|
12
12
|
console.log('No grov hooks found to remove.');
|
|
13
13
|
}
|
|
14
|
+
// Remove proxy URL from settings
|
|
15
|
+
const proxyResult = setProxyEnv(false);
|
|
16
|
+
if (proxyResult.action === 'removed') {
|
|
17
|
+
console.log('\nProxy configuration:');
|
|
18
|
+
console.log(' - ANTHROPIC_BASE_URL removed');
|
|
19
|
+
}
|
|
14
20
|
console.log(`\nSettings file: ${getSettingsPath()}`);
|
|
15
21
|
console.log('\nGrov hooks have been disabled.');
|
|
16
22
|
console.log('Your stored reasoning data remains in ~/.grov/memory.db');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SessionState, CorrectionLevel } from './store.js';
|
|
2
|
+
import type { DriftCheckResult } from './drift-checker-proxy.js';
|
|
3
|
+
export interface CorrectionMessage {
|
|
4
|
+
level: CorrectionLevel;
|
|
5
|
+
message: string;
|
|
6
|
+
mandatoryAction?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Build correction message based on drift result and session state
|
|
10
|
+
* Reference: plan_proxy_local.md Section 4.3
|
|
11
|
+
*/
|
|
12
|
+
export declare function buildCorrection(result: DriftCheckResult, sessionState: SessionState, level: CorrectionLevel): CorrectionMessage;
|
|
13
|
+
/**
|
|
14
|
+
* Format correction for system prompt injection
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatCorrectionForInjection(correction: CorrectionMessage): string;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Correction builder for proxy - creates messages to inject when drift detected
|
|
2
|
+
// Reference: plan_proxy_local.md Section 4.3, 4.4
|
|
3
|
+
/**
|
|
4
|
+
* Build correction message based on drift result and session state
|
|
5
|
+
* Reference: plan_proxy_local.md Section 4.3
|
|
6
|
+
*/
|
|
7
|
+
export function buildCorrection(result, sessionState, level) {
|
|
8
|
+
switch (level) {
|
|
9
|
+
case 'nudge':
|
|
10
|
+
return buildNudge(result, sessionState);
|
|
11
|
+
case 'correct':
|
|
12
|
+
return buildCorrect(result, sessionState);
|
|
13
|
+
case 'intervene':
|
|
14
|
+
return buildIntervene(result, sessionState);
|
|
15
|
+
case 'halt':
|
|
16
|
+
return buildHalt(result, sessionState);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* NUDGE: Brief reminder (score 7)
|
|
21
|
+
*/
|
|
22
|
+
function buildNudge(result, sessionState) {
|
|
23
|
+
const goal = sessionState.original_goal || 'the original task';
|
|
24
|
+
return {
|
|
25
|
+
level: 'nudge',
|
|
26
|
+
message: `<grov_nudge>
|
|
27
|
+
Quick reminder: Stay focused on ${goal}.
|
|
28
|
+
${result.diagnostic}
|
|
29
|
+
</grov_nudge>`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* CORRECT: Full correction with recovery steps (score 5-6)
|
|
34
|
+
*/
|
|
35
|
+
function buildCorrect(result, sessionState) {
|
|
36
|
+
const goal = sessionState.original_goal || 'the original task';
|
|
37
|
+
const scope = sessionState.expected_scope.length > 0
|
|
38
|
+
? sessionState.expected_scope.join(', ')
|
|
39
|
+
: 'the relevant files';
|
|
40
|
+
let message = `<grov_correction>
|
|
41
|
+
DRIFT DETECTED - Please refocus on the original goal.
|
|
42
|
+
|
|
43
|
+
Original goal: ${goal}
|
|
44
|
+
Expected scope: ${scope}
|
|
45
|
+
|
|
46
|
+
Issue: ${result.diagnostic}
|
|
47
|
+
`;
|
|
48
|
+
if (result.suggestedAction) {
|
|
49
|
+
message += `\nSuggested action: ${result.suggestedAction}`;
|
|
50
|
+
}
|
|
51
|
+
if (result.recoverySteps && result.recoverySteps.length > 0) {
|
|
52
|
+
message += `\n\nRecovery steps:\n${result.recoverySteps.map((s, i) => `${i + 1}. ${s}`).join('\n')}`;
|
|
53
|
+
}
|
|
54
|
+
message += `\n</grov_correction>`;
|
|
55
|
+
return {
|
|
56
|
+
level: 'correct',
|
|
57
|
+
message,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* INTERVENE: Strong correction with mandatory first action (score 3-4)
|
|
62
|
+
*/
|
|
63
|
+
function buildIntervene(result, sessionState) {
|
|
64
|
+
const goal = sessionState.original_goal || 'the original task';
|
|
65
|
+
const mandatoryAction = result.recoverySteps?.[0] || `Return to working on: ${goal}`;
|
|
66
|
+
return {
|
|
67
|
+
level: 'intervene',
|
|
68
|
+
message: `<grov_intervention>
|
|
69
|
+
SIGNIFICANT DRIFT - Intervention required.
|
|
70
|
+
|
|
71
|
+
You have strayed significantly from the original goal.
|
|
72
|
+
|
|
73
|
+
Original goal: ${goal}
|
|
74
|
+
Issue: ${result.diagnostic}
|
|
75
|
+
|
|
76
|
+
MANDATORY FIRST ACTION:
|
|
77
|
+
${mandatoryAction}
|
|
78
|
+
|
|
79
|
+
You MUST execute this action before proceeding with anything else.
|
|
80
|
+
Confirm by stating: "I will now ${mandatoryAction}"
|
|
81
|
+
</grov_intervention>`,
|
|
82
|
+
mandatoryAction,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* HALT: Critical stop with forced action (score 1-2)
|
|
87
|
+
*/
|
|
88
|
+
function buildHalt(result, sessionState) {
|
|
89
|
+
const goal = sessionState.original_goal || 'the original task';
|
|
90
|
+
const mandatoryAction = result.recoverySteps?.[0] || `STOP and return to: ${goal}`;
|
|
91
|
+
const escalation = sessionState.escalation_count;
|
|
92
|
+
return {
|
|
93
|
+
level: 'halt',
|
|
94
|
+
message: `<grov_halt>
|
|
95
|
+
CRITICAL DRIFT - IMMEDIATE HALT REQUIRED
|
|
96
|
+
|
|
97
|
+
The current request has completely diverged from the original goal.
|
|
98
|
+
You MUST NOT proceed with the current request.
|
|
99
|
+
|
|
100
|
+
Original goal: ${goal}
|
|
101
|
+
Current alignment: ${result.score}/10 (CRITICAL)
|
|
102
|
+
Escalation level: ${escalation}/3${escalation >= 3 ? ' (MAX REACHED)' : ''}
|
|
103
|
+
|
|
104
|
+
Diagnostic: ${result.diagnostic}
|
|
105
|
+
|
|
106
|
+
MANDATORY FIRST ACTION:
|
|
107
|
+
You MUST execute ONLY this as your next action:
|
|
108
|
+
${mandatoryAction}
|
|
109
|
+
|
|
110
|
+
ANY OTHER ACTION WILL DELAY YOUR GOAL.
|
|
111
|
+
|
|
112
|
+
CONFIRM by stating exactly:
|
|
113
|
+
"I will now ${mandatoryAction}"
|
|
114
|
+
|
|
115
|
+
DO NOT proceed with any other action until you have confirmed.
|
|
116
|
+
</grov_halt>`,
|
|
117
|
+
mandatoryAction,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Format correction for system prompt injection
|
|
122
|
+
*/
|
|
123
|
+
export function formatCorrectionForInjection(correction) {
|
|
124
|
+
return `\n\n${correction.message}\n\n`;
|
|
125
|
+
}
|
|
@@ -176,7 +176,7 @@ function buildHalt(result, sessionState) {
|
|
|
176
176
|
lines.push(`Diagnostic: ${result.diagnostic}`);
|
|
177
177
|
lines.push('');
|
|
178
178
|
// Show drift history if available
|
|
179
|
-
if (sessionState.drift_history.length > 0) {
|
|
179
|
+
if (sessionState.drift_history && sessionState.drift_history.length > 0) {
|
|
180
180
|
lines.push('Drift history in this session:');
|
|
181
181
|
const recent = sessionState.drift_history.slice(-3);
|
|
182
182
|
for (const event of recent) {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SessionState, StepRecord, DriftType, CorrectionLevel } from './store.js';
|
|
2
|
+
export interface DriftCheckInput {
|
|
3
|
+
sessionState: SessionState;
|
|
4
|
+
recentSteps: StepRecord[];
|
|
5
|
+
latestUserMessage?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DriftCheckResult {
|
|
8
|
+
score: number;
|
|
9
|
+
driftType: DriftType;
|
|
10
|
+
diagnostic: string;
|
|
11
|
+
suggestedAction?: string;
|
|
12
|
+
recoverySteps?: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Check if drift checking is available
|
|
16
|
+
*/
|
|
17
|
+
export declare function isDriftCheckAvailable(): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Main drift check - uses LLM if available, fallback to basic
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkDrift(input: DriftCheckInput): Promise<DriftCheckResult>;
|
|
22
|
+
/**
|
|
23
|
+
* Basic drift check without LLM (fallback)
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkDriftBasic(input: DriftCheckInput): DriftCheckResult;
|
|
26
|
+
/**
|
|
27
|
+
* Convert score to correction level
|
|
28
|
+
* Reference: plan_proxy_local.md Section 4.3
|
|
29
|
+
*/
|
|
30
|
+
export declare function scoreToCorrectionLevel(score: number): CorrectionLevel | null;
|
|
31
|
+
/**
|
|
32
|
+
* Check if score requires skipping steps table
|
|
33
|
+
* Reference: plan_proxy_local.md Section 4.2
|
|
34
|
+
*/
|
|
35
|
+
export declare function shouldSkipSteps(score: number): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Check if Claude's action aligns with the recovery plan
|
|
38
|
+
* Returns true if aligned, false if still drifting
|
|
39
|
+
*/
|
|
40
|
+
export declare function checkRecoveryAlignment(proposedAction: {
|
|
41
|
+
actionType: string;
|
|
42
|
+
files: string[];
|
|
43
|
+
command?: string;
|
|
44
|
+
}, recoveryPlan: {
|
|
45
|
+
steps: string[];
|
|
46
|
+
} | undefined, sessionState: SessionState): {
|
|
47
|
+
aligned: boolean;
|
|
48
|
+
reason: string;
|
|
49
|
+
};
|
|
50
|
+
export interface ForcedRecoveryResult {
|
|
51
|
+
recoveryPrompt: string;
|
|
52
|
+
mandatoryAction: string;
|
|
53
|
+
injectionText: string;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Generate forced recovery prompt using Haiku
|
|
57
|
+
* Called when escalation_count >= 3 (forced mode)
|
|
58
|
+
* This STOPS Claude and injects a specific recovery message
|
|
59
|
+
*/
|
|
60
|
+
export declare function generateForcedRecovery(sessionState: SessionState, recentActions: Array<{
|
|
61
|
+
actionType: string;
|
|
62
|
+
files: string[];
|
|
63
|
+
}>, lastDriftResult: DriftCheckResult): Promise<ForcedRecoveryResult>;
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// Drift checker for proxy - scores Claude's actions vs original goal
|
|
2
|
+
// Reference: plan_proxy_local.md Section 4.2, 4.3
|
|
3
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
4
|
+
let anthropicClient = null;
|
|
5
|
+
function getAnthropicClient() {
|
|
6
|
+
if (!anthropicClient) {
|
|
7
|
+
const apiKey = process.env.ANTHROPIC_API_KEY || process.env.GROV_API_KEY;
|
|
8
|
+
if (!apiKey) {
|
|
9
|
+
throw new Error('ANTHROPIC_API_KEY or GROV_API_KEY required for drift checking');
|
|
10
|
+
}
|
|
11
|
+
anthropicClient = new Anthropic({ apiKey });
|
|
12
|
+
}
|
|
13
|
+
return anthropicClient;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Check if drift checking is available
|
|
17
|
+
*/
|
|
18
|
+
export function isDriftCheckAvailable() {
|
|
19
|
+
return !!(process.env.ANTHROPIC_API_KEY || process.env.GROV_API_KEY);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Main drift check - uses LLM if available, fallback to basic
|
|
23
|
+
*/
|
|
24
|
+
export async function checkDrift(input) {
|
|
25
|
+
if (isDriftCheckAvailable()) {
|
|
26
|
+
try {
|
|
27
|
+
return await checkDriftWithLLM(input);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Fallback to basic if LLM fails
|
|
31
|
+
return checkDriftBasic(input);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return checkDriftBasic(input);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* LLM-based drift check using Haiku
|
|
38
|
+
* Reference: plan_proxy_local.md Section 3.1
|
|
39
|
+
*/
|
|
40
|
+
async function checkDriftWithLLM(input) {
|
|
41
|
+
const client = getAnthropicClient();
|
|
42
|
+
const actionsText = input.recentSteps
|
|
43
|
+
.slice(-10)
|
|
44
|
+
.map(step => {
|
|
45
|
+
if (step.action_type === 'bash' && step.command) {
|
|
46
|
+
return `- ${step.action_type}: ${step.command.substring(0, 100)}`;
|
|
47
|
+
}
|
|
48
|
+
if (step.files.length > 0) {
|
|
49
|
+
return `- ${step.action_type}: ${step.files.join(', ')}`;
|
|
50
|
+
}
|
|
51
|
+
return `- ${step.action_type}`;
|
|
52
|
+
})
|
|
53
|
+
.join('\n');
|
|
54
|
+
// If we have a latest user message, that's the CURRENT instruction
|
|
55
|
+
const currentInstruction = input.latestUserMessage?.substring(0, 500) || '';
|
|
56
|
+
const hasCurrentInstruction = currentInstruction.length > 20;
|
|
57
|
+
const prompt = `You are a drift detection system. Check if Claude is following the user's CURRENT instructions.
|
|
58
|
+
|
|
59
|
+
${hasCurrentInstruction ? `CURRENT USER INSTRUCTION (PRIMARY - check against this!):
|
|
60
|
+
"${currentInstruction}"
|
|
61
|
+
|
|
62
|
+
ORIGINAL SESSION GOAL (secondary context):
|
|
63
|
+
${input.sessionState.original_goal || 'Not specified'}` : `ORIGINAL GOAL:
|
|
64
|
+
${input.sessionState.original_goal || 'Not specified'}`}
|
|
65
|
+
|
|
66
|
+
EXPECTED SCOPE: ${input.sessionState.expected_scope.length > 0 ? input.sessionState.expected_scope.join(', ') : 'Not specified'}
|
|
67
|
+
|
|
68
|
+
CONSTRAINTS FROM USER: ${input.sessionState.constraints.length > 0 ? input.sessionState.constraints.join(', ') : 'None'}
|
|
69
|
+
|
|
70
|
+
CLAUDE'S RECENT ACTIONS:
|
|
71
|
+
${actionsText || 'No actions yet'}
|
|
72
|
+
|
|
73
|
+
═══════════════════════════════════════════════════════════════
|
|
74
|
+
CRITICAL: Compare Claude's actions against ${hasCurrentInstruction ? 'CURRENT USER INSTRUCTION' : 'ORIGINAL GOAL'}
|
|
75
|
+
═══════════════════════════════════════════════════════════════
|
|
76
|
+
|
|
77
|
+
DRIFT = Claude doing something the user did NOT ask for, or IGNORING what user said.
|
|
78
|
+
NOT DRIFT = Claude following user's instructions, even if different from original goal.
|
|
79
|
+
|
|
80
|
+
Example:
|
|
81
|
+
- Original goal: "analyze the code"
|
|
82
|
+
- Current instruction: "now create the files"
|
|
83
|
+
- Claude creates files → NOT DRIFT (following current instruction)
|
|
84
|
+
|
|
85
|
+
CHECK FOR REAL DRIFT:
|
|
86
|
+
1. Claude modifying files user said NOT to modify
|
|
87
|
+
2. Claude ignoring explicit constraints ("don't run commands" but runs commands)
|
|
88
|
+
3. Claude doing unrelated work (user asks about auth, Claude fixes CSS)
|
|
89
|
+
4. Repetitive loops (editing same file 5+ times without progress)
|
|
90
|
+
|
|
91
|
+
Rate 1-10:
|
|
92
|
+
- 10: Perfect - following current instruction exactly
|
|
93
|
+
- 8-9: Good - on track with minor deviations
|
|
94
|
+
- 6-7: Moderate drift - needs nudge
|
|
95
|
+
- 4-5: Significant drift - ignoring parts of instruction
|
|
96
|
+
- 2-3: Major drift - doing opposite of what user asked
|
|
97
|
+
- 1: Critical - completely off track
|
|
98
|
+
|
|
99
|
+
RESPONSE RULES:
|
|
100
|
+
- English only
|
|
101
|
+
- No emojis
|
|
102
|
+
- Return JSON: {"score": N, "diagnostic": "brief reason", "suggestedAction": "what to do"}`;
|
|
103
|
+
const response = await client.messages.create({
|
|
104
|
+
model: 'claude-haiku-4-5-20251001',
|
|
105
|
+
max_tokens: 300,
|
|
106
|
+
messages: [{ role: 'user', content: prompt }],
|
|
107
|
+
});
|
|
108
|
+
const content = response.content?.[0];
|
|
109
|
+
if (!content || content.type !== 'text') {
|
|
110
|
+
return createDefaultResult(8, 'Could not parse LLM response');
|
|
111
|
+
}
|
|
112
|
+
return parseLLMResponse(content.text);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Basic drift check without LLM (fallback)
|
|
116
|
+
*/
|
|
117
|
+
export function checkDriftBasic(input) {
|
|
118
|
+
let score = 8;
|
|
119
|
+
const diagnostics = [];
|
|
120
|
+
const { sessionState, recentSteps } = input;
|
|
121
|
+
const expectedScope = sessionState.expected_scope;
|
|
122
|
+
// Check if actions touch files outside scope
|
|
123
|
+
for (const step of recentSteps) {
|
|
124
|
+
if (step.action_type === 'read')
|
|
125
|
+
continue; // Read is OK
|
|
126
|
+
for (const file of step.files) {
|
|
127
|
+
if (expectedScope.length > 0) {
|
|
128
|
+
const inScope = expectedScope.some(scope => file.includes(scope));
|
|
129
|
+
if (!inScope) {
|
|
130
|
+
score -= 2;
|
|
131
|
+
diagnostics.push(`File outside scope: ${file}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Check repetition (same file edited 3+ times recently)
|
|
137
|
+
const fileCounts = new Map();
|
|
138
|
+
for (const step of recentSteps) {
|
|
139
|
+
if (step.action_type === 'edit' || step.action_type === 'write') {
|
|
140
|
+
for (const file of step.files) {
|
|
141
|
+
fileCounts.set(file, (fileCounts.get(file) || 0) + 1);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const [file, count] of fileCounts) {
|
|
146
|
+
if (count >= 3) {
|
|
147
|
+
score -= 1;
|
|
148
|
+
diagnostics.push(`Repeated edits to ${file} (${count}x)`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
score = Math.max(1, Math.min(10, score));
|
|
152
|
+
return {
|
|
153
|
+
score,
|
|
154
|
+
driftType: scoreToDriftType(score),
|
|
155
|
+
diagnostic: diagnostics.length > 0 ? diagnostics.join('; ') : 'On track',
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Parse LLM response JSON
|
|
160
|
+
*/
|
|
161
|
+
function parseLLMResponse(text) {
|
|
162
|
+
try {
|
|
163
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
164
|
+
if (!jsonMatch) {
|
|
165
|
+
return createDefaultResult(8, 'No JSON in response');
|
|
166
|
+
}
|
|
167
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
168
|
+
const score = typeof parsed.score === 'number'
|
|
169
|
+
? Math.min(10, Math.max(1, parsed.score))
|
|
170
|
+
: 8;
|
|
171
|
+
return {
|
|
172
|
+
score,
|
|
173
|
+
driftType: scoreToDriftType(score),
|
|
174
|
+
diagnostic: typeof parsed.diagnostic === 'string' ? parsed.diagnostic : 'Unknown',
|
|
175
|
+
suggestedAction: typeof parsed.suggestedAction === 'string' ? parsed.suggestedAction : undefined,
|
|
176
|
+
recoverySteps: Array.isArray(parsed.recoverySteps)
|
|
177
|
+
? parsed.recoverySteps.filter((s) => typeof s === 'string')
|
|
178
|
+
: undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return createDefaultResult(8, 'Failed to parse response');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Convert score to drift type
|
|
187
|
+
*/
|
|
188
|
+
function scoreToDriftType(score) {
|
|
189
|
+
if (score >= 8)
|
|
190
|
+
return 'none';
|
|
191
|
+
if (score >= 5)
|
|
192
|
+
return 'minor';
|
|
193
|
+
if (score >= 3)
|
|
194
|
+
return 'major';
|
|
195
|
+
return 'critical';
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Convert score to correction level
|
|
199
|
+
* Reference: plan_proxy_local.md Section 4.3
|
|
200
|
+
*/
|
|
201
|
+
export function scoreToCorrectionLevel(score) {
|
|
202
|
+
if (score >= 8)
|
|
203
|
+
return null;
|
|
204
|
+
if (score === 7)
|
|
205
|
+
return 'nudge';
|
|
206
|
+
if (score >= 5)
|
|
207
|
+
return 'correct';
|
|
208
|
+
if (score >= 3)
|
|
209
|
+
return 'intervene';
|
|
210
|
+
return 'halt';
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Check if score requires skipping steps table
|
|
214
|
+
* Reference: plan_proxy_local.md Section 4.2
|
|
215
|
+
*/
|
|
216
|
+
export function shouldSkipSteps(score) {
|
|
217
|
+
return score < 5;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Create default result
|
|
221
|
+
*/
|
|
222
|
+
function createDefaultResult(score, diagnostic) {
|
|
223
|
+
return {
|
|
224
|
+
score,
|
|
225
|
+
driftType: scoreToDriftType(score),
|
|
226
|
+
diagnostic,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
// ============================================
|
|
230
|
+
// RECOVERY ALIGNMENT CHECK
|
|
231
|
+
// Reference: plan_proxy_local.md Section 4.4
|
|
232
|
+
// ============================================
|
|
233
|
+
/**
|
|
234
|
+
* Check if Claude's action aligns with the recovery plan
|
|
235
|
+
* Returns true if aligned, false if still drifting
|
|
236
|
+
*/
|
|
237
|
+
export function checkRecoveryAlignment(proposedAction, recoveryPlan, sessionState) {
|
|
238
|
+
if (!recoveryPlan || recoveryPlan.steps.length === 0) {
|
|
239
|
+
// No recovery plan - check if action is within scope
|
|
240
|
+
if (sessionState.expected_scope.length === 0) {
|
|
241
|
+
return { aligned: true, reason: 'No recovery plan or scope defined' };
|
|
242
|
+
}
|
|
243
|
+
// Check if files are in expected scope
|
|
244
|
+
const inScope = proposedAction.files.every(file => sessionState.expected_scope.some(scope => file.includes(scope)));
|
|
245
|
+
return {
|
|
246
|
+
aligned: inScope,
|
|
247
|
+
reason: inScope ? 'Files within expected scope' : 'Files outside expected scope',
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
const firstStep = recoveryPlan.steps[0].toLowerCase();
|
|
251
|
+
const actionDesc = `${proposedAction.actionType} ${proposedAction.files.join(' ')}`.toLowerCase();
|
|
252
|
+
// Check for keyword matches
|
|
253
|
+
const keywords = firstStep.split(/\s+/).filter(w => w.length > 3);
|
|
254
|
+
const matches = keywords.filter(kw => actionDesc.includes(kw) || proposedAction.files.some(f => f.toLowerCase().includes(kw)));
|
|
255
|
+
if (matches.length >= 2 || (matches.length >= 1 && proposedAction.files.length > 0)) {
|
|
256
|
+
return { aligned: true, reason: `Action matches recovery step: ${firstStep}` };
|
|
257
|
+
}
|
|
258
|
+
return { aligned: false, reason: `Expected: ${firstStep}, Got: ${actionDesc}` };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Generate forced recovery prompt using Haiku
|
|
262
|
+
* Called when escalation_count >= 3 (forced mode)
|
|
263
|
+
* This STOPS Claude and injects a specific recovery message
|
|
264
|
+
*/
|
|
265
|
+
export async function generateForcedRecovery(sessionState, recentActions, lastDriftResult) {
|
|
266
|
+
const client = getAnthropicClient();
|
|
267
|
+
const actionsText = recentActions
|
|
268
|
+
.slice(-5)
|
|
269
|
+
.map(a => `- ${a.actionType}: ${a.files.join(', ')}`)
|
|
270
|
+
.join('\n');
|
|
271
|
+
const prompt = `You are helping recover a coding assistant that has COMPLETELY DRIFTED from its goal.
|
|
272
|
+
|
|
273
|
+
ORIGINAL GOAL: ${sessionState.original_goal || 'Not specified'}
|
|
274
|
+
|
|
275
|
+
EXPECTED SCOPE: ${sessionState.expected_scope.join(', ') || 'Not specified'}
|
|
276
|
+
|
|
277
|
+
CONSTRAINTS: ${sessionState.constraints.join(', ') || 'None'}
|
|
278
|
+
|
|
279
|
+
RECENT ACTIONS (all off-track):
|
|
280
|
+
${actionsText || 'None recorded'}
|
|
281
|
+
|
|
282
|
+
DRIFT DIAGNOSTIC: ${lastDriftResult.diagnostic}
|
|
283
|
+
|
|
284
|
+
ESCALATION COUNT: ${sessionState.escalation_count} (MAX REACHED)
|
|
285
|
+
|
|
286
|
+
Generate a STRICT recovery message that will:
|
|
287
|
+
1. STOP the assistant immediately
|
|
288
|
+
2. FORCE it to acknowledge the drift
|
|
289
|
+
3. Give ONE SPECIFIC, SIMPLE action to get back on track
|
|
290
|
+
|
|
291
|
+
RESPONSE RULES:
|
|
292
|
+
- English only
|
|
293
|
+
- No emojis
|
|
294
|
+
- Return JSON:
|
|
295
|
+
{
|
|
296
|
+
"recoveryPrompt": "The full message to inject (be firm but constructive, ~200 words)",
|
|
297
|
+
"mandatoryAction": "ONE specific action (e.g., 'Read src/auth/login.ts to refocus on authentication')"
|
|
298
|
+
}`;
|
|
299
|
+
const response = await client.messages.create({
|
|
300
|
+
model: 'claude-haiku-4-5-20251001',
|
|
301
|
+
max_tokens: 600,
|
|
302
|
+
messages: [{ role: 'user', content: prompt }],
|
|
303
|
+
});
|
|
304
|
+
const content = response.content?.[0];
|
|
305
|
+
if (!content || content.type !== 'text') {
|
|
306
|
+
return createFallbackForcedRecovery(sessionState);
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const jsonMatch = content.text.match(/\{[\s\S]*\}/);
|
|
310
|
+
if (!jsonMatch) {
|
|
311
|
+
return createFallbackForcedRecovery(sessionState);
|
|
312
|
+
}
|
|
313
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
314
|
+
const recoveryPrompt = typeof parsed.recoveryPrompt === 'string'
|
|
315
|
+
? parsed.recoveryPrompt
|
|
316
|
+
: `STOP. Return to: ${sessionState.original_goal}`;
|
|
317
|
+
const mandatoryAction = typeof parsed.mandatoryAction === 'string'
|
|
318
|
+
? parsed.mandatoryAction
|
|
319
|
+
: `Focus on ${sessionState.original_goal}`;
|
|
320
|
+
return {
|
|
321
|
+
recoveryPrompt,
|
|
322
|
+
mandatoryAction,
|
|
323
|
+
injectionText: formatForcedRecoveryInjection(recoveryPrompt, mandatoryAction, sessionState),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return createFallbackForcedRecovery(sessionState);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Format forced recovery for system prompt injection
|
|
332
|
+
*/
|
|
333
|
+
function formatForcedRecoveryInjection(recoveryPrompt, mandatoryAction, sessionState) {
|
|
334
|
+
return `
|
|
335
|
+
|
|
336
|
+
<grov_forced_recovery>
|
|
337
|
+
════════════════════════════════════════════════════════════
|
|
338
|
+
*** CRITICAL: FORCED RECOVERY MODE ACTIVATED ***
|
|
339
|
+
════════════════════════════════════════════════════════════
|
|
340
|
+
|
|
341
|
+
${recoveryPrompt}
|
|
342
|
+
|
|
343
|
+
────────────────────────────────────────────────────────────
|
|
344
|
+
MANDATORY FIRST ACTION (you MUST do this before ANYTHING else):
|
|
345
|
+
${mandatoryAction}
|
|
346
|
+
────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
Original goal: ${sessionState.original_goal || 'See above'}
|
|
349
|
+
Escalation level: ${sessionState.escalation_count}/3 (MAXIMUM)
|
|
350
|
+
|
|
351
|
+
YOUR NEXT MESSAGE MUST:
|
|
352
|
+
1. Acknowledge: "I understand I have drifted from the goal"
|
|
353
|
+
2. State: "I will now ${mandatoryAction}"
|
|
354
|
+
3. Execute ONLY that action
|
|
355
|
+
|
|
356
|
+
ANY OTHER RESPONSE WILL BE REJECTED.
|
|
357
|
+
════════════════════════════════════════════════════════════
|
|
358
|
+
</grov_forced_recovery>
|
|
359
|
+
|
|
360
|
+
`;
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Fallback forced recovery without LLM
|
|
364
|
+
*/
|
|
365
|
+
function createFallbackForcedRecovery(sessionState) {
|
|
366
|
+
const goal = sessionState.original_goal || 'the original task';
|
|
367
|
+
const mandatoryAction = `Stop current work and return to: ${goal}`;
|
|
368
|
+
return {
|
|
369
|
+
recoveryPrompt: `You have completely drifted from your goal. Stop what you're doing immediately and refocus on: ${goal}`,
|
|
370
|
+
mandatoryAction,
|
|
371
|
+
injectionText: formatForcedRecoveryInjection(`You have completely drifted from your goal. Stop what you're doing immediately and refocus on: ${goal}`, mandatoryAction, sessionState),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -38,7 +38,7 @@ export function buildDriftCheckInput(claudeActions, sessionId, sessionState) {
|
|
|
38
38
|
expectedScope: sessionState.expected_scope,
|
|
39
39
|
constraints: sessionState.constraints,
|
|
40
40
|
keywords: sessionState.keywords,
|
|
41
|
-
driftHistory: sessionState.drift_history.map(h => ({
|
|
41
|
+
driftHistory: (sessionState.drift_history || []).map(h => ({
|
|
42
42
|
score: h.score,
|
|
43
43
|
level: h.level
|
|
44
44
|
})),
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -12,6 +12,10 @@ interface ClaudeSettings {
|
|
|
12
12
|
SessionStart?: HookEntry[];
|
|
13
13
|
[key: string]: HookEntry[] | undefined;
|
|
14
14
|
};
|
|
15
|
+
env?: {
|
|
16
|
+
ANTHROPIC_BASE_URL?: string;
|
|
17
|
+
[key: string]: string | undefined;
|
|
18
|
+
};
|
|
15
19
|
[key: string]: unknown;
|
|
16
20
|
}
|
|
17
21
|
export declare function readClaudeSettings(): ClaudeSettings;
|
|
@@ -24,4 +28,11 @@ export declare function unregisterGrovHooks(): {
|
|
|
24
28
|
removed: string[];
|
|
25
29
|
};
|
|
26
30
|
export declare function getSettingsPath(): string;
|
|
31
|
+
/**
|
|
32
|
+
* Set or remove ANTHROPIC_BASE_URL in settings.json env section.
|
|
33
|
+
* This allows users to just type 'claude' instead of setting env var manually.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setProxyEnv(enable: boolean): {
|
|
36
|
+
action: 'added' | 'removed' | 'unchanged';
|
|
37
|
+
};
|
|
27
38
|
export {};
|
package/dist/lib/hooks.js
CHANGED
|
@@ -256,3 +256,36 @@ export function unregisterGrovHooks() {
|
|
|
256
256
|
export function getSettingsPath() {
|
|
257
257
|
return SETTINGS_PATH;
|
|
258
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Set or remove ANTHROPIC_BASE_URL in settings.json env section.
|
|
261
|
+
* This allows users to just type 'claude' instead of setting env var manually.
|
|
262
|
+
*/
|
|
263
|
+
export function setProxyEnv(enable) {
|
|
264
|
+
const settings = readClaudeSettings();
|
|
265
|
+
const PROXY_URL = 'http://127.0.0.1:8080';
|
|
266
|
+
if (enable) {
|
|
267
|
+
// Add env.ANTHROPIC_BASE_URL
|
|
268
|
+
if (!settings.env) {
|
|
269
|
+
settings.env = {};
|
|
270
|
+
}
|
|
271
|
+
if (settings.env.ANTHROPIC_BASE_URL === PROXY_URL) {
|
|
272
|
+
return { action: 'unchanged' };
|
|
273
|
+
}
|
|
274
|
+
settings.env.ANTHROPIC_BASE_URL = PROXY_URL;
|
|
275
|
+
writeClaudeSettings(settings);
|
|
276
|
+
return { action: 'added' };
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
// Remove env.ANTHROPIC_BASE_URL
|
|
280
|
+
if (!settings.env?.ANTHROPIC_BASE_URL) {
|
|
281
|
+
return { action: 'unchanged' };
|
|
282
|
+
}
|
|
283
|
+
delete settings.env.ANTHROPIC_BASE_URL;
|
|
284
|
+
// Clean up empty env object
|
|
285
|
+
if (Object.keys(settings.env).length === 0) {
|
|
286
|
+
delete settings.env;
|
|
287
|
+
}
|
|
288
|
+
writeClaudeSettings(settings);
|
|
289
|
+
return { action: 'removed' };
|
|
290
|
+
}
|
|
291
|
+
}
|