grov 0.1.2 → 0.2.3

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.
Files changed (39) hide show
  1. package/README.md +73 -88
  2. package/dist/cli.js +23 -37
  3. package/dist/commands/capture.js +1 -1
  4. package/dist/commands/disable.d.ts +1 -0
  5. package/dist/commands/disable.js +14 -0
  6. package/dist/commands/drift-test.js +56 -68
  7. package/dist/commands/init.js +29 -17
  8. package/dist/commands/proxy-status.d.ts +1 -0
  9. package/dist/commands/proxy-status.js +32 -0
  10. package/dist/commands/unregister.js +7 -1
  11. package/dist/lib/correction-builder-proxy.d.ts +16 -0
  12. package/dist/lib/correction-builder-proxy.js +125 -0
  13. package/dist/lib/correction-builder.js +1 -1
  14. package/dist/lib/drift-checker-proxy.d.ts +63 -0
  15. package/dist/lib/drift-checker-proxy.js +373 -0
  16. package/dist/lib/drift-checker.js +1 -1
  17. package/dist/lib/hooks.d.ts +11 -0
  18. package/dist/lib/hooks.js +33 -0
  19. package/dist/lib/llm-extractor.d.ts +60 -11
  20. package/dist/lib/llm-extractor.js +431 -98
  21. package/dist/lib/settings.d.ts +19 -0
  22. package/dist/lib/settings.js +63 -0
  23. package/dist/lib/store.d.ts +201 -43
  24. package/dist/lib/store.js +653 -90
  25. package/dist/proxy/action-parser.d.ts +58 -0
  26. package/dist/proxy/action-parser.js +196 -0
  27. package/dist/proxy/config.d.ts +26 -0
  28. package/dist/proxy/config.js +67 -0
  29. package/dist/proxy/forwarder.d.ts +24 -0
  30. package/dist/proxy/forwarder.js +119 -0
  31. package/dist/proxy/index.d.ts +1 -0
  32. package/dist/proxy/index.js +30 -0
  33. package/dist/proxy/request-processor.d.ts +12 -0
  34. package/dist/proxy/request-processor.js +120 -0
  35. package/dist/proxy/response-processor.d.ts +14 -0
  36. package/dist/proxy/response-processor.js +138 -0
  37. package/dist/proxy/server.d.ts +9 -0
  38. package/dist/proxy/server.js +904 -0
  39. package/package.json +8 -3
@@ -0,0 +1,904 @@
1
+ // Grov Proxy Server - Fastify + undici
2
+ // Intercepts Claude Code <-> Anthropic API traffic for drift detection and context injection
3
+ import Fastify from 'fastify';
4
+ import { config } from './config.js';
5
+ import { forwardToAnthropic, isForwardError } from './forwarder.js';
6
+ import { parseToolUseBlocks, extractTokenUsage } from './action-parser.js';
7
+ import { createSessionState, getSessionState, updateSessionState, createStep, updateTokenCount, logDriftEvent, getRecentSteps, getValidatedSteps, updateSessionMode, markWaitingForRecovery, incrementEscalation, updateLastChecked, markCleared, getActiveSessionForUser, deleteSessionState, deleteStepsForSession, updateRecentStepsReasoning, markSessionCompleted, getCompletedSessionForProject, cleanupOldCompletedSessions, } from '../lib/store.js';
8
+ import { checkDrift, scoreToCorrectionLevel, shouldSkipSteps, isDriftCheckAvailable, checkRecoveryAlignment, generateForcedRecovery, } from '../lib/drift-checker-proxy.js';
9
+ import { buildCorrection, formatCorrectionForInjection } from '../lib/correction-builder-proxy.js';
10
+ import { generateSessionSummary, isSummaryAvailable, extractIntent, isIntentExtractionAvailable, analyzeTaskContext, isTaskAnalysisAvailable, } from '../lib/llm-extractor.js';
11
+ import { buildTeamMemoryContext, extractFilesFromMessages } from './request-processor.js';
12
+ import { saveToTeamMemory } from './response-processor.js';
13
+ import { randomUUID } from 'crypto';
14
+ // Store last drift result for recovery alignment check
15
+ const lastDriftResults = new Map();
16
+ /**
17
+ * Helper to append text to system prompt (handles string or array format)
18
+ */
19
+ function appendToSystemPrompt(body, textToAppend) {
20
+ if (typeof body.system === 'string') {
21
+ body.system = body.system + textToAppend;
22
+ }
23
+ else if (Array.isArray(body.system)) {
24
+ // Append as new text block
25
+ body.system.push({ type: 'text', text: textToAppend });
26
+ }
27
+ else {
28
+ // No system prompt yet, create as string
29
+ body.system = textToAppend;
30
+ }
31
+ }
32
+ /**
33
+ * Get system prompt as string (for reading)
34
+ */
35
+ function getSystemPromptText(body) {
36
+ if (typeof body.system === 'string') {
37
+ return body.system;
38
+ }
39
+ else if (Array.isArray(body.system)) {
40
+ return body.system
41
+ .filter(block => block.type === 'text')
42
+ .map(block => block.text)
43
+ .join('\n');
44
+ }
45
+ return '';
46
+ }
47
+ // Session tracking (in-memory for active sessions)
48
+ const activeSessions = new Map();
49
+ /**
50
+ * Create and configure the Fastify server
51
+ */
52
+ export function createServer() {
53
+ const fastify = Fastify({
54
+ logger: false, // Disabled - all debug goes to ~/.grov/debug.log
55
+ bodyLimit: config.BODY_LIMIT,
56
+ });
57
+ // Health check endpoint
58
+ fastify.get('/health', async () => {
59
+ return { status: 'ok', timestamp: new Date().toISOString() };
60
+ });
61
+ // Main messages endpoint
62
+ fastify.post('/v1/messages', {
63
+ config: {
64
+ rawBody: true,
65
+ },
66
+ }, handleMessages);
67
+ // Catch-all for other Anthropic endpoints (pass through)
68
+ fastify.all('/*', async (request, reply) => {
69
+ fastify.log.warn(`Unhandled endpoint: ${request.method} ${request.url}`);
70
+ return reply.status(404).send({ error: 'Not found' });
71
+ });
72
+ return fastify;
73
+ }
74
+ /**
75
+ * Handle /v1/messages requests
76
+ */
77
+ async function handleMessages(request, reply) {
78
+ const logger = request.log;
79
+ const startTime = Date.now();
80
+ const model = request.body.model;
81
+ // Skip Haiku subagents - forward directly without any tracking
82
+ // Haiku requests are Task tool spawns for exploration, they don't make decisions
83
+ // All reasoning and decisions happen in the main model (Opus/Sonnet)
84
+ if (model.includes('haiku')) {
85
+ logger.info({ msg: 'Skipping Haiku subagent', model });
86
+ try {
87
+ const result = await forwardToAnthropic(request.body, request.headers, logger);
88
+ const latency = Date.now() - startTime;
89
+ return reply
90
+ .status(result.statusCode)
91
+ .header('content-type', 'application/json')
92
+ .headers(filterResponseHeaders(result.headers))
93
+ .send(JSON.stringify(result.body));
94
+ }
95
+ catch (error) {
96
+ logger.error({ msg: 'Haiku forward error', error: String(error) });
97
+ return reply
98
+ .status(502)
99
+ .header('content-type', 'application/json')
100
+ .send(JSON.stringify({ error: { type: 'proxy_error', message: 'Bad gateway' } }));
101
+ }
102
+ }
103
+ // === MAIN MODEL TRACKING (Opus/Sonnet) ===
104
+ // Get or create session (async for intent extraction)
105
+ const sessionInfo = await getOrCreateSession(request, logger);
106
+ sessionInfo.promptCount++;
107
+ // Update in-memory map
108
+ activeSessions.set(sessionInfo.sessionId, {
109
+ sessionId: sessionInfo.sessionId,
110
+ promptCount: sessionInfo.promptCount,
111
+ projectPath: sessionInfo.projectPath,
112
+ });
113
+ logger.info({
114
+ msg: 'Incoming request',
115
+ sessionId: sessionInfo.sessionId.substring(0, 8),
116
+ promptCount: sessionInfo.promptCount,
117
+ model: request.body.model,
118
+ messageCount: request.body.messages?.length || 0,
119
+ });
120
+ // === PRE-HANDLER: Modify request if needed ===
121
+ const modifiedBody = await preProcessRequest(request.body, sessionInfo, logger);
122
+ // === FORWARD TO ANTHROPIC ===
123
+ try {
124
+ const result = await forwardToAnthropic(modifiedBody, request.headers, logger);
125
+ // === POST-HANDLER: Process response with task orchestration ===
126
+ if (result.statusCode === 200 && isAnthropicResponse(result.body)) {
127
+ await postProcessResponse(result.body, sessionInfo, request.body, logger);
128
+ }
129
+ // Return response to Claude Code (unmodified)
130
+ const latency = Date.now() - startTime;
131
+ logger.info({
132
+ msg: 'Request complete',
133
+ statusCode: result.statusCode,
134
+ latencyMs: latency,
135
+ });
136
+ return reply
137
+ .status(result.statusCode)
138
+ .header('content-type', 'application/json')
139
+ .headers(filterResponseHeaders(result.headers))
140
+ .send(JSON.stringify(result.body));
141
+ }
142
+ catch (error) {
143
+ if (isForwardError(error)) {
144
+ logger.error({
145
+ msg: 'Forward error',
146
+ type: error.type,
147
+ message: error.message,
148
+ });
149
+ return reply
150
+ .status(error.statusCode || 502)
151
+ .header('content-type', 'application/json')
152
+ .send(JSON.stringify({
153
+ error: {
154
+ type: 'proxy_error',
155
+ message: error.type === 'timeout' ? 'Gateway timeout' : 'Bad gateway',
156
+ },
157
+ }));
158
+ }
159
+ logger.error({
160
+ msg: 'Unexpected error',
161
+ error: String(error),
162
+ });
163
+ return reply
164
+ .status(500)
165
+ .header('content-type', 'application/json')
166
+ .send(JSON.stringify({
167
+ error: {
168
+ type: 'internal_error',
169
+ message: 'Internal proxy error',
170
+ },
171
+ }));
172
+ }
173
+ }
174
+ /**
175
+ * Get or create session info for this request
176
+ */
177
+ async function getOrCreateSession(request, logger) {
178
+ // Determine project path from request
179
+ const projectPath = extractProjectPath(request.body) || process.cwd();
180
+ // Try to find existing active session for this project
181
+ // Task orchestration will happen in postProcessResponse using analyzeTaskContext
182
+ const existingSession = getActiveSessionForUser(projectPath);
183
+ if (existingSession) {
184
+ // Found active session - will be validated by task orchestration later
185
+ let sessionInfo = activeSessions.get(existingSession.session_id);
186
+ if (!sessionInfo) {
187
+ sessionInfo = {
188
+ sessionId: existingSession.session_id,
189
+ promptCount: 0,
190
+ projectPath,
191
+ };
192
+ activeSessions.set(existingSession.session_id, sessionInfo);
193
+ }
194
+ logger.info({
195
+ msg: 'Found existing session',
196
+ sessionId: existingSession.session_id.substring(0, 8),
197
+ goal: existingSession.original_goal?.substring(0, 50),
198
+ });
199
+ return { ...sessionInfo, isNew: false, currentSession: existingSession, completedSession: null };
200
+ }
201
+ // No active session - check for recently completed session (for new_task detection)
202
+ const completedSession = getCompletedSessionForProject(projectPath);
203
+ if (completedSession) {
204
+ logger.info({
205
+ msg: 'Found recently completed session for comparison',
206
+ sessionId: completedSession.session_id.substring(0, 8),
207
+ goal: completedSession.original_goal?.substring(0, 50),
208
+ });
209
+ }
210
+ // No existing session - create placeholder, real session will be created in postProcessResponse
211
+ const tempSessionId = randomUUID();
212
+ const sessionInfo = {
213
+ sessionId: tempSessionId,
214
+ promptCount: 0,
215
+ projectPath,
216
+ };
217
+ activeSessions.set(tempSessionId, sessionInfo);
218
+ logger.info({ msg: 'No existing session, will create after task analysis' });
219
+ return { ...sessionInfo, isNew: true, currentSession: null, completedSession };
220
+ }
221
+ /**
222
+ * Pre-process request before forwarding
223
+ * - Context injection
224
+ * - CLEAR operation
225
+ */
226
+ async function preProcessRequest(body, sessionInfo, logger) {
227
+ const modified = { ...body };
228
+ // FIRST: Always inject team memory context (doesn't require sessionState)
229
+ const mentionedFiles = extractFilesFromMessages(modified.messages || []);
230
+ const teamContext = buildTeamMemoryContext(sessionInfo.projectPath, mentionedFiles);
231
+ if (teamContext) {
232
+ appendToSystemPrompt(modified, '\n\n' + teamContext);
233
+ }
234
+ // THEN: Session-specific operations
235
+ const sessionState = getSessionState(sessionInfo.sessionId);
236
+ if (!sessionState) {
237
+ return modified; // Injection already happened above!
238
+ }
239
+ // Extract latest user message for drift checking
240
+ const latestUserMessage = extractGoalFromMessages(body.messages) || '';
241
+ // CLEAR operation if token threshold exceeded
242
+ if ((sessionState.token_count || 0) > config.TOKEN_CLEAR_THRESHOLD) {
243
+ logger.info({
244
+ msg: 'Token threshold exceeded, initiating CLEAR',
245
+ tokenCount: sessionState.token_count,
246
+ threshold: config.TOKEN_CLEAR_THRESHOLD,
247
+ });
248
+ // Generate summary from session state + steps
249
+ let summary;
250
+ if (isSummaryAvailable()) {
251
+ const steps = getValidatedSteps(sessionInfo.sessionId);
252
+ summary = await generateSessionSummary(sessionState, steps);
253
+ }
254
+ else {
255
+ const files = getValidatedSteps(sessionInfo.sessionId).flatMap(s => s.files);
256
+ summary = `PREVIOUS SESSION CONTEXT:
257
+ Goal: ${sessionState.original_goal || 'Not specified'}
258
+ Files worked on: ${[...new Set(files)].slice(0, 10).join(', ') || 'None'}
259
+ Please continue from where you left off.`;
260
+ }
261
+ // Clear messages and inject summary
262
+ modified.messages = [];
263
+ appendToSystemPrompt(modified, '\n\n' + summary);
264
+ // Update session state
265
+ markCleared(sessionInfo.sessionId);
266
+ logger.info({
267
+ msg: 'CLEAR completed',
268
+ summaryLength: summary.length,
269
+ });
270
+ }
271
+ // Check if session is in drifted or forced mode
272
+ if (sessionState.session_mode === 'drifted' || sessionState.session_mode === 'forced') {
273
+ const recentSteps = getRecentSteps(sessionInfo.sessionId, 5);
274
+ // FORCED MODE: escalation >= 3 -> Haiku generates recovery prompt
275
+ if (sessionState.escalation_count >= 3 || sessionState.session_mode === 'forced') {
276
+ // Update mode to forced if not already
277
+ if (sessionState.session_mode !== 'forced') {
278
+ updateSessionMode(sessionInfo.sessionId, 'forced');
279
+ }
280
+ const lastDrift = lastDriftResults.get(sessionInfo.sessionId);
281
+ const driftResult = lastDrift || await checkDrift({ sessionState, recentSteps, latestUserMessage });
282
+ const forcedRecovery = await generateForcedRecovery(sessionState, recentSteps.map(s => ({ actionType: s.action_type, files: s.files })), driftResult);
283
+ appendToSystemPrompt(modified, forcedRecovery.injectionText);
284
+ logger.info({
285
+ msg: 'FORCED MODE - Injected Haiku recovery prompt',
286
+ escalation: sessionState.escalation_count,
287
+ mandatoryAction: forcedRecovery.mandatoryAction.substring(0, 50),
288
+ });
289
+ }
290
+ else {
291
+ // DRIFTED MODE: normal correction injection
292
+ const driftResult = await checkDrift({ sessionState, recentSteps, latestUserMessage });
293
+ const correctionLevel = scoreToCorrectionLevel(driftResult.score);
294
+ if (correctionLevel) {
295
+ const correction = buildCorrection(driftResult, sessionState, correctionLevel);
296
+ const correctionText = formatCorrectionForInjection(correction);
297
+ appendToSystemPrompt(modified, correctionText);
298
+ logger.info({
299
+ msg: 'Injected correction',
300
+ level: correctionLevel,
301
+ score: driftResult.score,
302
+ });
303
+ }
304
+ }
305
+ }
306
+ // Note: Team memory context injection is now at the TOP of preProcessRequest()
307
+ // so it runs even when sessionState is null (new sessions)
308
+ return modified;
309
+ }
310
+ /**
311
+ * Post-process response after receiving from Anthropic
312
+ * - Task orchestration (new/continue/subtask/complete)
313
+ * - Parse tool_use blocks
314
+ * - Update token count
315
+ * - Save step to DB
316
+ * - Drift check (every N prompts)
317
+ * - Recovery alignment check (Section 4.4)
318
+ * - Team memory triggers (Section 4.6)
319
+ */
320
+ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
321
+ // Parse tool_use blocks
322
+ const actions = parseToolUseBlocks(response);
323
+ // Extract text content for analysis
324
+ const textContent = extractTextContent(response);
325
+ // Extract latest user message from request
326
+ const latestUserMessage = extractGoalFromMessages(requestBody.messages) || '';
327
+ // Get recent steps for context
328
+ const recentSteps = sessionInfo.currentSession
329
+ ? getRecentSteps(sessionInfo.currentSession.session_id, 5)
330
+ : [];
331
+ // === TASK ORCHESTRATION (Part 8) ===
332
+ let activeSessionId = sessionInfo.sessionId;
333
+ let activeSession = sessionInfo.currentSession;
334
+ // Only run task orchestration on end_turn (when Claude finishes responding to user)
335
+ // This reduces Haiku calls from ~11 per prompt to ~1-2
336
+ const isEndTurn = response.stop_reason === 'end_turn';
337
+ // Skip Warmup messages (Claude Code internal initialization)
338
+ const isWarmup = latestUserMessage.toLowerCase().trim() === 'warmup';
339
+ if (isWarmup) {
340
+ return;
341
+ }
342
+ // If not end_turn (tool_use in progress), skip task orchestration but keep session
343
+ if (!isEndTurn) {
344
+ // Use existing session or create minimal one without LLM calls
345
+ if (sessionInfo.currentSession) {
346
+ activeSessionId = sessionInfo.currentSession.session_id;
347
+ activeSession = sessionInfo.currentSession;
348
+ }
349
+ else if (!activeSession) {
350
+ // First request, create session without task analysis
351
+ const newSessionId = randomUUID();
352
+ activeSession = createSessionState({
353
+ session_id: newSessionId,
354
+ project_path: sessionInfo.projectPath,
355
+ original_goal: latestUserMessage.substring(0, 500) || 'Task in progress',
356
+ task_type: 'main',
357
+ });
358
+ activeSessionId = newSessionId;
359
+ activeSessions.set(newSessionId, {
360
+ sessionId: newSessionId,
361
+ promptCount: 1,
362
+ projectPath: sessionInfo.projectPath,
363
+ });
364
+ }
365
+ }
366
+ else if (isTaskAnalysisAvailable()) {
367
+ // Use completed session for comparison if no active session
368
+ const sessionForComparison = sessionInfo.currentSession || sessionInfo.completedSession;
369
+ try {
370
+ const taskAnalysis = await analyzeTaskContext(sessionForComparison, latestUserMessage, recentSteps, textContent);
371
+ logger.info({
372
+ msg: 'Task analysis',
373
+ action: taskAnalysis.action,
374
+ topic_match: taskAnalysis.topic_match,
375
+ goal: taskAnalysis.current_goal?.substring(0, 50),
376
+ reasoning: taskAnalysis.reasoning,
377
+ });
378
+ // Update recent steps with reasoning (backfill from end_turn response)
379
+ if (taskAnalysis.step_reasoning && activeSessionId) {
380
+ const updatedCount = updateRecentStepsReasoning(activeSessionId, taskAnalysis.step_reasoning);
381
+ }
382
+ // Handle task orchestration based on analysis
383
+ switch (taskAnalysis.action) {
384
+ case 'continue':
385
+ // Use existing session or reactivate completed session
386
+ if (sessionInfo.currentSession) {
387
+ activeSessionId = sessionInfo.currentSession.session_id;
388
+ activeSession = sessionInfo.currentSession;
389
+ // Update goal if Haiku detected a new instruction from user
390
+ // (same task/topic, but new specific instruction)
391
+ if (taskAnalysis.current_goal &&
392
+ taskAnalysis.current_goal !== activeSession.original_goal &&
393
+ latestUserMessage.length > 30) {
394
+ updateSessionState(activeSessionId, {
395
+ original_goal: taskAnalysis.current_goal,
396
+ });
397
+ activeSession.original_goal = taskAnalysis.current_goal;
398
+ }
399
+ }
400
+ else if (sessionInfo.completedSession) {
401
+ // Reactivate completed session (user wants to continue/add to it)
402
+ activeSessionId = sessionInfo.completedSession.session_id;
403
+ activeSession = sessionInfo.completedSession;
404
+ updateSessionState(activeSessionId, {
405
+ status: 'active',
406
+ original_goal: taskAnalysis.current_goal || activeSession.original_goal,
407
+ });
408
+ activeSession.status = 'active';
409
+ activeSessions.set(activeSessionId, {
410
+ sessionId: activeSessionId,
411
+ promptCount: 1,
412
+ projectPath: sessionInfo.projectPath,
413
+ });
414
+ }
415
+ break;
416
+ case 'new_task': {
417
+ // Clean up completed session if it exists (it was kept for comparison)
418
+ if (sessionInfo.completedSession) {
419
+ deleteStepsForSession(sessionInfo.completedSession.session_id);
420
+ deleteSessionState(sessionInfo.completedSession.session_id);
421
+ }
422
+ // Extract full intent for new task (goal, scope, constraints, keywords)
423
+ let intentData = {
424
+ goal: taskAnalysis.current_goal,
425
+ expected_scope: [],
426
+ constraints: [],
427
+ keywords: [],
428
+ };
429
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
430
+ try {
431
+ intentData = await extractIntent(latestUserMessage);
432
+ logger.info({ msg: 'Intent extracted for new task', scopeCount: intentData.expected_scope.length });
433
+ }
434
+ catch (err) {
435
+ logger.info({ msg: 'Intent extraction failed, using basic goal', error: String(err) });
436
+ }
437
+ }
438
+ const newSessionId = randomUUID();
439
+ activeSession = createSessionState({
440
+ session_id: newSessionId,
441
+ project_path: sessionInfo.projectPath,
442
+ original_goal: intentData.goal,
443
+ expected_scope: intentData.expected_scope,
444
+ constraints: intentData.constraints,
445
+ keywords: intentData.keywords,
446
+ task_type: 'main',
447
+ });
448
+ activeSessionId = newSessionId;
449
+ activeSessions.set(newSessionId, {
450
+ sessionId: newSessionId,
451
+ promptCount: 1,
452
+ projectPath: sessionInfo.projectPath,
453
+ });
454
+ logger.info({ msg: 'Created new task session', sessionId: newSessionId.substring(0, 8) });
455
+ break;
456
+ }
457
+ case 'subtask': {
458
+ // Extract intent for subtask
459
+ let intentData = {
460
+ goal: taskAnalysis.current_goal,
461
+ expected_scope: [],
462
+ constraints: [],
463
+ keywords: [],
464
+ };
465
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
466
+ try {
467
+ intentData = await extractIntent(latestUserMessage);
468
+ }
469
+ catch { /* use fallback */ }
470
+ }
471
+ const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
472
+ const subtaskId = randomUUID();
473
+ activeSession = createSessionState({
474
+ session_id: subtaskId,
475
+ project_path: sessionInfo.projectPath,
476
+ original_goal: intentData.goal,
477
+ expected_scope: intentData.expected_scope,
478
+ constraints: intentData.constraints,
479
+ keywords: intentData.keywords,
480
+ task_type: 'subtask',
481
+ parent_session_id: parentId,
482
+ });
483
+ activeSessionId = subtaskId;
484
+ activeSessions.set(subtaskId, {
485
+ sessionId: subtaskId,
486
+ promptCount: 1,
487
+ projectPath: sessionInfo.projectPath,
488
+ });
489
+ logger.info({ msg: 'Created subtask session', sessionId: subtaskId.substring(0, 8), parent: parentId?.substring(0, 8) });
490
+ break;
491
+ }
492
+ case 'parallel_task': {
493
+ // Extract intent for parallel task
494
+ let intentData = {
495
+ goal: taskAnalysis.current_goal,
496
+ expected_scope: [],
497
+ constraints: [],
498
+ keywords: [],
499
+ };
500
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
501
+ try {
502
+ intentData = await extractIntent(latestUserMessage);
503
+ }
504
+ catch { /* use fallback */ }
505
+ }
506
+ const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
507
+ const parallelId = randomUUID();
508
+ activeSession = createSessionState({
509
+ session_id: parallelId,
510
+ project_path: sessionInfo.projectPath,
511
+ original_goal: intentData.goal,
512
+ expected_scope: intentData.expected_scope,
513
+ constraints: intentData.constraints,
514
+ keywords: intentData.keywords,
515
+ task_type: 'parallel',
516
+ parent_session_id: parentId,
517
+ });
518
+ activeSessionId = parallelId;
519
+ activeSessions.set(parallelId, {
520
+ sessionId: parallelId,
521
+ promptCount: 1,
522
+ projectPath: sessionInfo.projectPath,
523
+ });
524
+ logger.info({ msg: 'Created parallel task session', sessionId: parallelId.substring(0, 8), parent: parentId?.substring(0, 8) });
525
+ break;
526
+ }
527
+ case 'task_complete': {
528
+ // Save to team memory and mark as completed (don't delete yet - keep for new_task detection)
529
+ if (sessionInfo.currentSession) {
530
+ try {
531
+ await saveToTeamMemory(sessionInfo.currentSession.session_id, 'complete');
532
+ markSessionCompleted(sessionInfo.currentSession.session_id);
533
+ activeSessions.delete(sessionInfo.currentSession.session_id);
534
+ lastDriftResults.delete(sessionInfo.currentSession.session_id);
535
+ logger.info({ msg: 'Task complete - saved to team memory, marked completed' });
536
+ }
537
+ catch (err) {
538
+ logger.info({ msg: 'Failed to save completed task', error: String(err) });
539
+ }
540
+ }
541
+ return; // Done, no more processing needed
542
+ }
543
+ case 'subtask_complete': {
544
+ // Save subtask and mark completed, return to parent
545
+ if (sessionInfo.currentSession) {
546
+ const parentId = sessionInfo.currentSession.parent_session_id;
547
+ try {
548
+ await saveToTeamMemory(sessionInfo.currentSession.session_id, 'complete');
549
+ markSessionCompleted(sessionInfo.currentSession.session_id);
550
+ activeSessions.delete(sessionInfo.currentSession.session_id);
551
+ lastDriftResults.delete(sessionInfo.currentSession.session_id);
552
+ // Switch to parent session
553
+ if (parentId) {
554
+ const parentSession = getSessionState(parentId);
555
+ if (parentSession) {
556
+ activeSessionId = parentId;
557
+ activeSession = parentSession;
558
+ logger.info({ msg: 'Subtask complete - returning to parent', parent: parentId.substring(0, 8) });
559
+ }
560
+ }
561
+ }
562
+ catch (err) {
563
+ logger.info({ msg: 'Failed to save completed subtask', error: String(err) });
564
+ }
565
+ }
566
+ break;
567
+ }
568
+ }
569
+ }
570
+ catch (error) {
571
+ logger.info({ msg: 'Task analysis failed, using existing session', error: String(error) });
572
+ // Fall back to existing session or create new with intent extraction
573
+ if (!sessionInfo.currentSession) {
574
+ let intentData = {
575
+ goal: latestUserMessage.substring(0, 500),
576
+ expected_scope: [],
577
+ constraints: [],
578
+ keywords: [],
579
+ };
580
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
581
+ try {
582
+ intentData = await extractIntent(latestUserMessage);
583
+ }
584
+ catch { /* use fallback */ }
585
+ }
586
+ const newSessionId = randomUUID();
587
+ activeSession = createSessionState({
588
+ session_id: newSessionId,
589
+ project_path: sessionInfo.projectPath,
590
+ original_goal: intentData.goal,
591
+ expected_scope: intentData.expected_scope,
592
+ constraints: intentData.constraints,
593
+ keywords: intentData.keywords,
594
+ task_type: 'main',
595
+ });
596
+ activeSessionId = newSessionId;
597
+ }
598
+ }
599
+ }
600
+ else {
601
+ // No task analysis available - fallback with intent extraction
602
+ if (!sessionInfo.currentSession) {
603
+ let intentData = {
604
+ goal: latestUserMessage.substring(0, 500),
605
+ expected_scope: [],
606
+ constraints: [],
607
+ keywords: [],
608
+ };
609
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
610
+ try {
611
+ intentData = await extractIntent(latestUserMessage);
612
+ logger.info({ msg: 'Intent extracted (fallback)', scopeCount: intentData.expected_scope.length });
613
+ }
614
+ catch { /* use fallback */ }
615
+ }
616
+ const newSessionId = randomUUID();
617
+ activeSession = createSessionState({
618
+ session_id: newSessionId,
619
+ project_path: sessionInfo.projectPath,
620
+ original_goal: intentData.goal,
621
+ expected_scope: intentData.expected_scope,
622
+ constraints: intentData.constraints,
623
+ keywords: intentData.keywords,
624
+ task_type: 'main',
625
+ });
626
+ activeSessionId = newSessionId;
627
+ }
628
+ else {
629
+ activeSession = sessionInfo.currentSession;
630
+ activeSessionId = sessionInfo.currentSession.session_id;
631
+ }
632
+ }
633
+ // Extract token usage
634
+ const usage = extractTokenUsage(response);
635
+ if (activeSession) {
636
+ updateTokenCount(activeSessionId, usage.totalTokens);
637
+ }
638
+ logger.info({
639
+ msg: 'Token usage',
640
+ input: usage.inputTokens,
641
+ output: usage.outputTokens,
642
+ total: usage.totalTokens,
643
+ activeSession: activeSessionId.substring(0, 8),
644
+ });
645
+ if (actions.length === 0) {
646
+ return;
647
+ }
648
+ logger.info({
649
+ msg: 'Actions parsed',
650
+ count: actions.length,
651
+ tools: actions.map(a => a.toolName),
652
+ });
653
+ // Recovery alignment check (Section 4.4)
654
+ if (activeSession && activeSession.waiting_for_recovery) {
655
+ const lastDrift = lastDriftResults.get(activeSessionId);
656
+ const recoveryPlan = lastDrift?.recoverySteps ? { steps: lastDrift.recoverySteps } : undefined;
657
+ for (const action of actions) {
658
+ const alignment = checkRecoveryAlignment({ actionType: action.actionType, files: action.files, command: action.command }, recoveryPlan, activeSession);
659
+ if (alignment.aligned) {
660
+ // Recovered! Reset to normal
661
+ updateSessionMode(activeSessionId, 'normal');
662
+ markWaitingForRecovery(activeSessionId, false);
663
+ updateSessionState(activeSessionId, { escalation_count: 0 });
664
+ lastDriftResults.delete(activeSessionId);
665
+ logger.info({
666
+ msg: 'Recovery alignment SUCCESS - resuming normal mode',
667
+ reason: alignment.reason,
668
+ });
669
+ }
670
+ else {
671
+ incrementEscalation(activeSessionId);
672
+ logger.info({
673
+ msg: 'Recovery alignment FAILED - escalating',
674
+ reason: alignment.reason,
675
+ escalation: activeSession.escalation_count + 1,
676
+ });
677
+ }
678
+ }
679
+ }
680
+ // Run drift check every N prompts
681
+ let driftScore;
682
+ let skipSteps = false;
683
+ const memSessionInfo = activeSessions.get(activeSessionId);
684
+ const promptCount = memSessionInfo?.promptCount || sessionInfo.promptCount;
685
+ if (promptCount % config.DRIFT_CHECK_INTERVAL === 0 && isDriftCheckAvailable()) {
686
+ if (activeSession) {
687
+ const stepsForDrift = getRecentSteps(activeSessionId, 10);
688
+ const driftResult = await checkDrift({ sessionState: activeSession, recentSteps: stepsForDrift, latestUserMessage });
689
+ lastDriftResults.set(activeSessionId, driftResult);
690
+ driftScore = driftResult.score;
691
+ skipSteps = shouldSkipSteps(driftScore);
692
+ logger.info({
693
+ msg: 'Drift check',
694
+ score: driftResult.score,
695
+ type: driftResult.driftType,
696
+ diagnostic: driftResult.diagnostic,
697
+ });
698
+ const correctionLevel = scoreToCorrectionLevel(driftScore);
699
+ if (correctionLevel === 'intervene' || correctionLevel === 'halt') {
700
+ updateSessionMode(activeSessionId, 'drifted');
701
+ markWaitingForRecovery(activeSessionId, true);
702
+ incrementEscalation(activeSessionId);
703
+ }
704
+ else if (driftScore >= 8) {
705
+ updateSessionMode(activeSessionId, 'normal');
706
+ markWaitingForRecovery(activeSessionId, false);
707
+ lastDriftResults.delete(activeSessionId);
708
+ }
709
+ updateLastChecked(activeSessionId, Date.now());
710
+ if (skipSteps) {
711
+ for (const action of actions) {
712
+ logDriftEvent({
713
+ session_id: activeSessionId,
714
+ action_type: action.actionType,
715
+ files: action.files,
716
+ drift_score: driftScore,
717
+ drift_reason: driftResult.diagnostic,
718
+ recovery_plan: driftResult.recoverySteps ? { steps: driftResult.recoverySteps } : undefined,
719
+ });
720
+ }
721
+ logger.info({
722
+ msg: 'Actions logged to drift_log (skipped steps)',
723
+ reason: 'score < 5',
724
+ });
725
+ return;
726
+ }
727
+ }
728
+ }
729
+ // Save each action as a step (with reasoning from Claude's text)
730
+ for (const action of actions) {
731
+ createStep({
732
+ session_id: activeSessionId,
733
+ action_type: action.actionType,
734
+ files: action.files,
735
+ folders: action.folders,
736
+ command: action.command,
737
+ reasoning: textContent.substring(0, 1000), // Claude's explanation (truncated)
738
+ drift_score: driftScore,
739
+ is_validated: !skipSteps,
740
+ });
741
+ }
742
+ }
743
+ /**
744
+ * Extract text content from response for analysis
745
+ */
746
+ function extractTextContent(response) {
747
+ return response.content
748
+ .filter((block) => block.type === 'text')
749
+ .map(block => block.text)
750
+ .join('\n');
751
+ }
752
+ /**
753
+ * Detect task completion from response text
754
+ * Returns trigger type or null
755
+ */
756
+ function detectTaskCompletion(text) {
757
+ const lowerText = text.toLowerCase();
758
+ // Strong completion indicators
759
+ const completionPhrases = [
760
+ 'task is complete',
761
+ 'task complete',
762
+ 'implementation is complete',
763
+ 'implementation complete',
764
+ 'successfully implemented',
765
+ 'all changes have been made',
766
+ 'finished implementing',
767
+ 'completed the implementation',
768
+ 'done with the implementation',
769
+ 'completed all the',
770
+ 'all tests pass',
771
+ 'build succeeds',
772
+ ];
773
+ for (const phrase of completionPhrases) {
774
+ if (lowerText.includes(phrase)) {
775
+ return 'complete';
776
+ }
777
+ }
778
+ // Subtask completion indicators
779
+ const subtaskPhrases = [
780
+ 'step complete',
781
+ 'phase complete',
782
+ 'finished this step',
783
+ 'moving on to',
784
+ 'now let\'s',
785
+ 'next step',
786
+ ];
787
+ for (const phrase of subtaskPhrases) {
788
+ if (lowerText.includes(phrase)) {
789
+ return 'subtask';
790
+ }
791
+ }
792
+ return null;
793
+ }
794
+ /**
795
+ * Extract project path from request body
796
+ */
797
+ function extractProjectPath(body) {
798
+ // Try to extract from system prompt or messages
799
+ // Handle both string and array format for system prompt
800
+ let systemPrompt = '';
801
+ if (typeof body.system === 'string') {
802
+ systemPrompt = body.system;
803
+ }
804
+ else if (Array.isArray(body.system)) {
805
+ // New API format: system is array of {type: 'text', text: '...'}
806
+ systemPrompt = body.system
807
+ .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
808
+ .map(block => block.text)
809
+ .join('\n');
810
+ }
811
+ const cwdMatch = systemPrompt.match(/Working directory:\s*([^\n]+)/);
812
+ if (cwdMatch) {
813
+ return cwdMatch[1].trim();
814
+ }
815
+ return null;
816
+ }
817
+ /**
818
+ * Extract goal from FIRST user message with text content
819
+ * Skips tool_result blocks, filters out system-reminder tags
820
+ */
821
+ function extractGoalFromMessages(messages) {
822
+ const userMessages = messages?.filter(m => m.role === 'user') || [];
823
+ for (const userMsg of userMessages) {
824
+ let rawContent = '';
825
+ // Handle string content
826
+ if (typeof userMsg.content === 'string') {
827
+ rawContent = userMsg.content;
828
+ }
829
+ // Handle array content - look for text blocks (skip tool_result)
830
+ if (Array.isArray(userMsg.content)) {
831
+ const textBlocks = userMsg.content
832
+ .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
833
+ .map(block => block.text);
834
+ rawContent = textBlocks.join('\n');
835
+ }
836
+ // Remove <system-reminder>...</system-reminder> tags
837
+ const cleanContent = rawContent
838
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
839
+ .trim();
840
+ // If we found valid text content, return it
841
+ if (cleanContent && cleanContent.length >= 5) {
842
+ return cleanContent.substring(0, 500);
843
+ }
844
+ }
845
+ return undefined;
846
+ }
847
+ /**
848
+ * Filter response headers for forwarding to client
849
+ */
850
+ function filterResponseHeaders(headers) {
851
+ const filtered = {};
852
+ const allowedHeaders = [
853
+ 'content-type',
854
+ 'x-request-id',
855
+ 'anthropic-ratelimit-requests-limit',
856
+ 'anthropic-ratelimit-requests-remaining',
857
+ 'anthropic-ratelimit-tokens-limit',
858
+ 'anthropic-ratelimit-tokens-remaining',
859
+ ];
860
+ for (const header of allowedHeaders) {
861
+ const value = headers[header];
862
+ if (value) {
863
+ filtered[header] = Array.isArray(value) ? value[0] : value;
864
+ }
865
+ }
866
+ return filtered;
867
+ }
868
+ /**
869
+ * Type guard for AnthropicResponse
870
+ */
871
+ function isAnthropicResponse(body) {
872
+ return (typeof body === 'object' &&
873
+ body !== null &&
874
+ 'type' in body &&
875
+ body.type === 'message' &&
876
+ 'content' in body &&
877
+ 'usage' in body);
878
+ }
879
+ /**
880
+ * Start the proxy server
881
+ */
882
+ export async function startServer() {
883
+ const server = createServer();
884
+ // Cleanup old completed sessions (older than 24 hours)
885
+ const cleanedUp = cleanupOldCompletedSessions();
886
+ if (cleanedUp > 0) {
887
+ }
888
+ try {
889
+ await server.listen({
890
+ host: config.HOST,
891
+ port: config.PORT,
892
+ });
893
+ console.log(`✓ Grov Proxy: http://${config.HOST}:${config.PORT} → ${config.ANTHROPIC_BASE_URL}`);
894
+ return server;
895
+ }
896
+ catch (err) {
897
+ server.log.error(err);
898
+ process.exit(1);
899
+ }
900
+ }
901
+ // CLI entry point
902
+ if (import.meta.url === `file://${process.argv[1]}`) {
903
+ startServer();
904
+ }