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