grov 0.5.2 → 0.5.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 (48) hide show
  1. package/README.md +19 -1
  2. package/dist/cli.js +8 -0
  3. package/dist/lib/api-client.d.ts +18 -1
  4. package/dist/lib/api-client.js +57 -0
  5. package/dist/lib/llm-extractor.d.ts +14 -38
  6. package/dist/lib/llm-extractor.js +380 -406
  7. package/dist/lib/store/convenience.d.ts +40 -0
  8. package/dist/lib/store/convenience.js +104 -0
  9. package/dist/lib/store/database.d.ts +22 -0
  10. package/dist/lib/store/database.js +375 -0
  11. package/dist/lib/store/drift.d.ts +9 -0
  12. package/dist/lib/store/drift.js +89 -0
  13. package/dist/lib/store/index.d.ts +7 -0
  14. package/dist/lib/store/index.js +13 -0
  15. package/dist/lib/store/sessions.d.ts +32 -0
  16. package/dist/lib/store/sessions.js +240 -0
  17. package/dist/lib/store/steps.d.ts +40 -0
  18. package/dist/lib/store/steps.js +161 -0
  19. package/dist/lib/store/tasks.d.ts +33 -0
  20. package/dist/lib/store/tasks.js +133 -0
  21. package/dist/lib/store/types.d.ts +167 -0
  22. package/dist/lib/store/types.js +2 -0
  23. package/dist/lib/store.d.ts +1 -436
  24. package/dist/lib/store.js +2 -1478
  25. package/dist/proxy/cache.d.ts +36 -0
  26. package/dist/proxy/cache.js +51 -0
  27. package/dist/proxy/config.d.ts +1 -0
  28. package/dist/proxy/config.js +2 -0
  29. package/dist/proxy/extended-cache.d.ts +10 -0
  30. package/dist/proxy/extended-cache.js +155 -0
  31. package/dist/proxy/handlers/preprocess.d.ts +20 -0
  32. package/dist/proxy/handlers/preprocess.js +169 -0
  33. package/dist/proxy/injection/delta-tracking.d.ts +11 -0
  34. package/dist/proxy/injection/delta-tracking.js +93 -0
  35. package/dist/proxy/injection/injectors.d.ts +7 -0
  36. package/dist/proxy/injection/injectors.js +139 -0
  37. package/dist/proxy/request-processor.d.ts +18 -4
  38. package/dist/proxy/request-processor.js +151 -30
  39. package/dist/proxy/response-processor.js +93 -45
  40. package/dist/proxy/server.d.ts +0 -1
  41. package/dist/proxy/server.js +342 -566
  42. package/dist/proxy/types.d.ts +13 -0
  43. package/dist/proxy/types.js +2 -0
  44. package/dist/proxy/utils/extractors.d.ts +18 -0
  45. package/dist/proxy/utils/extractors.js +109 -0
  46. package/dist/proxy/utils/logging.d.ts +18 -0
  47. package/dist/proxy/utils/logging.js +42 -0
  48. package/package.json +5 -2
@@ -1,314 +1,26 @@
1
1
  // Grov Proxy Server - Fastify + undici
2
2
  // Intercepts Claude Code <-> Anthropic API traffic for drift detection and context injection
3
3
  import Fastify from 'fastify';
4
- import { config } from './config.js';
4
+ import { config, buildSafeHeaders } from './config.js';
5
5
  import { forwardToAnthropic, isForwardError } from './forwarder.js';
6
+ import { extendedCache, evictOldestCacheEntry, checkExtendedCache, log } from './extended-cache.js';
7
+ import { setDebugMode, getNextRequestId, taskLog, proxyLog, logTokenUsage } from './utils/logging.js';
8
+ import { detectKeyDecision, extractTextContent, extractProjectPath, extractGoalFromMessages, extractConversationHistory } from './utils/extractors.js';
9
+ import { appendToLastUserMessage, injectIntoRawBody } from './injection/injectors.js';
10
+ import { preProcessRequest, setPendingPlanClear } from './handlers/preprocess.js';
6
11
  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, getKeyDecisions, getEditedFiles, } from '../lib/store.js';
8
- import { smartTruncate } from '../lib/utils.js';
12
+ import { createSessionState, getSessionState, updateSessionState, createStep, updateTokenCount, logDriftEvent, getRecentSteps, getValidatedSteps, updateSessionMode, markWaitingForRecovery, incrementEscalation, updateLastChecked, getActiveSessionForUser, deleteSessionState, deleteStepsForSession, updateRecentStepsReasoning, markSessionCompleted, getCompletedSessionForProject, cleanupOldCompletedSessions, cleanupStaleActiveSessions, } from '../lib/store.js';
9
13
  import { checkDrift, scoreToCorrectionLevel, shouldSkipSteps, isDriftCheckAvailable, checkRecoveryAlignment, generateForcedRecovery, } from '../lib/drift-checker-proxy.js';
10
14
  import { buildCorrection, formatCorrectionForInjection } from '../lib/correction-builder-proxy.js';
11
15
  import { generateSessionSummary, isSummaryAvailable, extractIntent, isIntentExtractionAvailable, analyzeTaskContext, isTaskAnalysisAvailable, } from '../lib/llm-extractor.js';
12
- import { buildTeamMemoryContext, extractFilesFromMessages } from './request-processor.js';
13
16
  import { saveToTeamMemory } from './response-processor.js';
14
17
  import { randomUUID } from 'crypto';
15
- import * as fs from 'fs';
16
- import * as path from 'path';
17
18
  // Store last drift result for recovery alignment check
18
19
  const lastDriftResults = new Map();
20
+ // Server logger reference (set in startServer)
21
+ let serverLog = null;
19
22
  // Track last messageCount per session to detect retries vs new turns
20
23
  const lastMessageCount = new Map();
21
- // Cache injection content per session (MUST be identical across requests for cache preservation)
22
- // Stored in memory because session DB state doesn't exist on first request
23
- const cachedInjections = new Map();
24
- const sessionInjectionTracking = new Map();
25
- function getOrCreateTracking(sessionId) {
26
- if (!sessionInjectionTracking.has(sessionId)) {
27
- sessionInjectionTracking.set(sessionId, {
28
- files: new Set(),
29
- decisionIds: new Set(),
30
- reasonings: new Set(),
31
- });
32
- }
33
- return sessionInjectionTracking.get(sessionId);
34
- }
35
- /**
36
- * Build dynamic injection content for user message (DELTA only)
37
- * Includes: edited files, key decisions, drift correction, forced recovery
38
- * Only injects NEW content that hasn't been injected before
39
- */
40
- function buildDynamicInjection(sessionId, sessionState, logger) {
41
- const tracking = getOrCreateTracking(sessionId);
42
- const parts = [];
43
- const debugInfo = {};
44
- // 1. Get edited files (delta - not already injected)
45
- const allEditedFiles = getEditedFiles(sessionId);
46
- const newFiles = allEditedFiles.filter(f => !tracking.files.has(f));
47
- debugInfo.totalEditedFiles = allEditedFiles.length;
48
- debugInfo.newEditedFiles = newFiles.length;
49
- debugInfo.alreadyTrackedFiles = tracking.files.size;
50
- if (newFiles.length > 0) {
51
- // Track and add to injection
52
- newFiles.forEach(f => tracking.files.add(f));
53
- const fileNames = newFiles.slice(0, 5).map(f => f.split('/').pop());
54
- parts.push(`[EDITED: ${fileNames.join(', ')}]`);
55
- debugInfo.editedFilesInjected = fileNames;
56
- }
57
- // 2. Get key decisions with reasoning (delta - not already injected)
58
- const keyDecisions = getKeyDecisions(sessionId, 5);
59
- debugInfo.totalKeyDecisions = keyDecisions.length;
60
- debugInfo.alreadyTrackedDecisions = tracking.decisionIds.size;
61
- const newDecisions = keyDecisions.filter(d => !tracking.decisionIds.has(d.id) &&
62
- d.reasoning &&
63
- !tracking.reasonings.has(d.reasoning));
64
- debugInfo.newKeyDecisions = newDecisions.length;
65
- for (const decision of newDecisions.slice(0, 3)) {
66
- tracking.decisionIds.add(decision.id);
67
- tracking.reasonings.add(decision.reasoning);
68
- const truncated = smartTruncate(decision.reasoning, 120);
69
- parts.push(`[DECISION: ${truncated}]`);
70
- // Log the original and truncated reasoning for debugging
71
- if (logger) {
72
- logger.info({
73
- msg: 'Key decision reasoning extracted',
74
- originalLength: decision.reasoning.length,
75
- truncatedLength: truncated.length,
76
- original: decision.reasoning.substring(0, 200) + (decision.reasoning.length > 200 ? '...' : ''),
77
- truncated,
78
- });
79
- }
80
- }
81
- debugInfo.decisionsInjected = newDecisions.slice(0, 3).length;
82
- // 3. Add drift correction if pending
83
- if (sessionState?.pending_correction) {
84
- parts.push(`[DRIFT: ${sessionState.pending_correction}]`);
85
- debugInfo.hasDriftCorrection = true;
86
- debugInfo.driftCorrectionLength = sessionState.pending_correction.length;
87
- }
88
- // 4. Add forced recovery if pending
89
- if (sessionState?.pending_forced_recovery) {
90
- parts.push(`[RECOVERY: ${sessionState.pending_forced_recovery}]`);
91
- debugInfo.hasForcedRecovery = true;
92
- debugInfo.forcedRecoveryLength = sessionState.pending_forced_recovery.length;
93
- }
94
- // Log debug info
95
- if (logger) {
96
- logger.info({
97
- msg: 'Dynamic injection build details',
98
- ...debugInfo,
99
- partsCount: parts.length,
100
- });
101
- }
102
- if (parts.length === 0) {
103
- return null;
104
- }
105
- const injection = '---\n[GROV CONTEXT]\n' + parts.join('\n');
106
- // Log final injection content
107
- if (logger) {
108
- logger.info({
109
- msg: 'Dynamic injection content',
110
- size: injection.length,
111
- content: injection,
112
- });
113
- }
114
- return injection;
115
- }
116
- /**
117
- * Append dynamic injection to the last user message in raw body string
118
- * This preserves cache for system + previous messages, only the last user msg changes
119
- */
120
- function appendToLastUserMessage(rawBody, injection) {
121
- // Find the last occurrence of "role":"user" followed by content
122
- // We need to find the content field of the last user message and append to it
123
- // Strategy: Find all user messages, get the last one, append to its content
124
- // This is tricky because content can be string or array
125
- // Simpler approach: Find the last user message's closing content
126
- // Look for pattern: "role":"user","content":"..." or "role":"user","content":[...]
127
- // Find last "role":"user"
128
- const userRolePattern = /"role"\s*:\s*"user"/g;
129
- let lastUserMatch = null;
130
- let match;
131
- while ((match = userRolePattern.exec(rawBody)) !== null) {
132
- lastUserMatch = match;
133
- }
134
- if (!lastUserMatch) {
135
- // No user message found, can't inject
136
- return rawBody;
137
- }
138
- // From lastUserMatch position, find the content field
139
- const afterRole = rawBody.slice(lastUserMatch.index);
140
- // Find "content" field after role
141
- const contentMatch = afterRole.match(/"content"\s*:\s*/);
142
- if (!contentMatch || contentMatch.index === undefined) {
143
- return rawBody;
144
- }
145
- const contentStartGlobal = lastUserMatch.index + contentMatch.index + contentMatch[0].length;
146
- const afterContent = rawBody.slice(contentStartGlobal);
147
- // Determine if content is string or array
148
- if (afterContent.startsWith('"')) {
149
- // String content - find closing quote (handling escapes)
150
- let i = 1; // Skip opening quote
151
- while (i < afterContent.length) {
152
- if (afterContent[i] === '\\') {
153
- i += 2; // Skip escaped char
154
- }
155
- else if (afterContent[i] === '"') {
156
- // Found closing quote
157
- const insertPos = contentStartGlobal + i;
158
- // Insert before closing quote, escape the injection for JSON
159
- const escapedInjection = injection
160
- .replace(/\\/g, '\\\\')
161
- .replace(/"/g, '\\"')
162
- .replace(/\n/g, '\\n');
163
- return rawBody.slice(0, insertPos) + '\\n\\n' + escapedInjection + rawBody.slice(insertPos);
164
- }
165
- else {
166
- i++;
167
- }
168
- }
169
- }
170
- else if (afterContent.startsWith('[')) {
171
- // Array content - find last text block and append, or add new text block
172
- // Find the closing ] of the content array
173
- let depth = 1;
174
- let i = 1;
175
- while (i < afterContent.length && depth > 0) {
176
- const char = afterContent[i];
177
- if (char === '[')
178
- depth++;
179
- else if (char === ']')
180
- depth--;
181
- else if (char === '"') {
182
- // Skip string
183
- i++;
184
- while (i < afterContent.length && afterContent[i] !== '"') {
185
- if (afterContent[i] === '\\')
186
- i++;
187
- i++;
188
- }
189
- }
190
- i++;
191
- }
192
- if (depth === 0) {
193
- // Found closing bracket at position i-1
194
- const insertPos = contentStartGlobal + i - 1;
195
- // Add new text block before closing bracket
196
- const escapedInjection = injection
197
- .replace(/\\/g, '\\\\')
198
- .replace(/"/g, '\\"')
199
- .replace(/\n/g, '\\n');
200
- const newBlock = `,{"type":"text","text":"\\n\\n${escapedInjection}"}`;
201
- return rawBody.slice(0, insertPos) + newBlock + rawBody.slice(insertPos);
202
- }
203
- }
204
- // Fallback: couldn't parse, return unchanged
205
- return rawBody;
206
- }
207
- // ============================================
208
- // DEBUG MODE - Controlled via --debug flag
209
- // ============================================
210
- let debugMode = false;
211
- export function setDebugMode(enabled) {
212
- debugMode = enabled;
213
- }
214
- // ============================================
215
- // FILE LOGGER - Request/Response tracking (debug only)
216
- // ============================================
217
- const PROXY_LOG_PATH = path.join(process.cwd(), 'grov-proxy.log');
218
- let requestCounter = 0;
219
- function proxyLog(entry) {
220
- if (!debugMode)
221
- return; // Skip file logging unless --debug flag
222
- const logEntry = {
223
- timestamp: new Date().toISOString(),
224
- ...entry,
225
- };
226
- const line = JSON.stringify(logEntry) + '\n';
227
- fs.appendFileSync(PROXY_LOG_PATH, line);
228
- }
229
- /**
230
- * Log token usage to console (always shown, compact format)
231
- */
232
- function logTokenUsage(requestId, usage, latencyMs) {
233
- const total = usage.cacheCreation + usage.cacheRead;
234
- const hitRatio = total > 0 ? ((usage.cacheRead / total) * 100).toFixed(0) : '0';
235
- console.log(`[${requestId}] ${hitRatio}% cache | in:${usage.inputTokens} out:${usage.outputTokens} | create:${usage.cacheCreation} read:${usage.cacheRead} | ${latencyMs}ms`);
236
- }
237
- /**
238
- * Helper to append text to system prompt (handles string or array format)
239
- */
240
- function appendToSystemPrompt(body, textToAppend) {
241
- if (typeof body.system === 'string') {
242
- body.system = body.system + textToAppend;
243
- }
244
- else if (Array.isArray(body.system)) {
245
- // Append as new text block WITHOUT cache_control
246
- // Anthropic allows max 4 cache blocks - Claude Code already uses 2+
247
- // Grov's injections are small (~2KB) so uncached is fine
248
- body.system.push({
249
- type: 'text',
250
- text: textToAppend,
251
- });
252
- }
253
- else {
254
- // No system prompt yet, create as string
255
- body.system = textToAppend;
256
- }
257
- }
258
- /**
259
- * Get system prompt as string (for reading)
260
- */
261
- function getSystemPromptText(body) {
262
- if (typeof body.system === 'string') {
263
- return body.system;
264
- }
265
- else if (Array.isArray(body.system)) {
266
- return body.system
267
- .filter(block => block.type === 'text')
268
- .map(block => block.text)
269
- .join('\n');
270
- }
271
- return '';
272
- }
273
- /**
274
- * Inject text into raw body string WITHOUT re-serializing
275
- * This preserves the original formatting/whitespace for cache compatibility
276
- *
277
- * Adds a new text block to the end of the system array
278
- */
279
- function injectIntoRawBody(rawBody, injectionText) {
280
- // Find the system array in the raw JSON
281
- // Pattern: "system": [....]
282
- const systemMatch = rawBody.match(/"system"\s*:\s*\[/);
283
- if (!systemMatch || systemMatch.index === undefined) {
284
- return { modified: rawBody, success: false };
285
- }
286
- // Find the matching closing bracket for the system array
287
- const startIndex = systemMatch.index + systemMatch[0].length;
288
- let bracketCount = 1;
289
- let endIndex = startIndex;
290
- for (let i = startIndex; i < rawBody.length && bracketCount > 0; i++) {
291
- const char = rawBody[i];
292
- if (char === '[')
293
- bracketCount++;
294
- else if (char === ']')
295
- bracketCount--;
296
- if (bracketCount === 0) {
297
- endIndex = i;
298
- break;
299
- }
300
- }
301
- if (bracketCount !== 0) {
302
- return { modified: rawBody, success: false };
303
- }
304
- // Escape the injection text for JSON
305
- const escapedText = JSON.stringify(injectionText).slice(1, -1); // Remove outer quotes
306
- // Create the new block (without cache_control - will be cache_creation)
307
- const newBlock = `,{"type":"text","text":"${escapedText}"}`;
308
- // Insert before the closing bracket
309
- const modified = rawBody.slice(0, endIndex) + newBlock + rawBody.slice(endIndex);
310
- return { modified, success: true };
311
- }
312
24
  // Session tracking (in-memory for active sessions)
313
25
  const activeSessions = new Map();
314
26
  /**
@@ -381,7 +93,7 @@ async function handleMessages(request, reply) {
381
93
  promptCount: sessionInfo.promptCount,
382
94
  projectPath: sessionInfo.projectPath,
383
95
  });
384
- const currentRequestId = ++requestCounter;
96
+ const currentRequestId = getNextRequestId();
385
97
  logger.info({
386
98
  msg: 'Incoming request',
387
99
  sessionId: sessionInfo.sessionId.substring(0, 8),
@@ -405,7 +117,7 @@ async function handleMessages(request, reply) {
405
117
  // Process request to get injection text
406
118
  // __grovInjection = team memory (system prompt, cached)
407
119
  // __grovUserMsgInjection = dynamic content (user message, delta only)
408
- const processedBody = await preProcessRequest(request.body, sessionInfo, logger);
120
+ const processedBody = await preProcessRequest(request.body, sessionInfo, logger, detectRequestType);
409
121
  const systemInjection = processedBody.__grovInjection;
410
122
  const userMsgInjection = processedBody.__grovUserMsgInjection;
411
123
  // Get raw body bytes
@@ -470,7 +182,12 @@ async function handleMessages(request, reply) {
470
182
  // FIRE-AND-FORGET: Don't block response to Claude Code
471
183
  // This prevents retry loops caused by Haiku calls adding latency
472
184
  if (result.statusCode === 200 && isAnthropicResponse(result.body)) {
473
- postProcessResponse(result.body, sessionInfo, request.body, logger)
185
+ // Prepare extended cache data (only if enabled)
186
+ const extendedCacheData = config.EXTENDED_CACHE_ENABLED ? {
187
+ headers: buildSafeHeaders(request.headers),
188
+ rawBody: typeof finalBodyToSend === 'string' ? Buffer.from(finalBodyToSend, 'utf-8') : finalBodyToSend,
189
+ } : undefined;
190
+ postProcessResponse(result.body, sessionInfo, request.body, logger, extendedCacheData)
474
191
  .catch(err => console.error('[GROV] postProcess error:', err));
475
192
  }
476
193
  const latency = Date.now() - startTime;
@@ -591,6 +308,7 @@ async function getOrCreateSession(request, logger) {
591
308
  projectPath,
592
309
  };
593
310
  activeSessions.set(tempSessionId, sessionInfo);
311
+ // Note: team memory is now GLOBAL (not per session), no propagation needed
594
312
  logger.info({ msg: 'No existing session, will create after task analysis' });
595
313
  return { ...sessionInfo, isNew: true, currentSession: null, completedSession };
596
314
  }
@@ -600,10 +318,10 @@ async function getOrCreateSession(request, logger) {
600
318
  * - continuation: tool result (messageCount changed, last msg has tool_result)
601
319
  * - retry: same messageCount as before
602
320
  */
603
- function detectRequestType(messages, sessionId) {
321
+ function detectRequestType(messages, projectPath) {
604
322
  const currentCount = messages?.length || 0;
605
- const lastCount = lastMessageCount.get(sessionId);
606
- lastMessageCount.set(sessionId, currentCount);
323
+ const lastCount = lastMessageCount.get(projectPath);
324
+ lastMessageCount.set(projectPath, currentCount);
607
325
  // Same messageCount = retry
608
326
  if (lastCount !== undefined && currentCount === lastCount) {
609
327
  return 'retry';
@@ -623,103 +341,6 @@ function detectRequestType(messages, sessionId) {
623
341
  }
624
342
  return 'first';
625
343
  }
626
- /**
627
- * Pre-process request before forwarding
628
- * - Context injection (first request only)
629
- * - CLEAR operation (first request only)
630
- * - Drift correction (first request only)
631
- *
632
- * SKIP all injections on: retry, continuation
633
- */
634
- async function preProcessRequest(body, sessionInfo, logger) {
635
- const modified = { ...body };
636
- // Detect request type: first, continuation, or retry
637
- const requestType = detectRequestType(modified.messages || [], sessionInfo.sessionId);
638
- // === NEW ARCHITECTURE: Separate static and dynamic injection ===
639
- //
640
- // STATIC (system prompt, cached):
641
- // - Team memory from PAST sessions only
642
- // - CLEAR summary when triggered
643
- // -> Uses __grovInjection + injectIntoRawBody()
644
- //
645
- // DYNAMIC (user message, delta only):
646
- // - Files edited in current session
647
- // - Key decisions with reasoning
648
- // - Drift correction, forced recovery
649
- // -> Uses __grovUserMsgInjection + appendToLastUserMessage()
650
- // Get session state
651
- const sessionState = getSessionState(sessionInfo.sessionId);
652
- // === CLEAR MODE (100% threshold) ===
653
- // If token count exceeds threshold AND we have a pre-computed summary, apply CLEAR
654
- if (sessionState) {
655
- const currentTokenCount = sessionState.token_count || 0;
656
- if (currentTokenCount > config.TOKEN_CLEAR_THRESHOLD &&
657
- sessionState.pending_clear_summary) {
658
- logger.info({
659
- msg: 'CLEAR MODE ACTIVATED - resetting conversation',
660
- tokenCount: currentTokenCount,
661
- threshold: config.TOKEN_CLEAR_THRESHOLD,
662
- summaryLength: sessionState.pending_clear_summary.length,
663
- });
664
- // 1. Empty messages array (fundamental reset)
665
- modified.messages = [];
666
- // 2. Inject summary into system prompt (this will cause cache miss - intentional)
667
- appendToSystemPrompt(modified, sessionState.pending_clear_summary);
668
- // 3. Mark session as cleared
669
- markCleared(sessionInfo.sessionId);
670
- // 4. Clear pending summary and invalidate team memory cache (new baseline)
671
- updateSessionState(sessionInfo.sessionId, { pending_clear_summary: undefined });
672
- cachedInjections.delete(sessionInfo.sessionId);
673
- // 5. Clear tracking (fresh start after CLEAR)
674
- sessionInjectionTracking.delete(sessionInfo.sessionId);
675
- logger.info({ msg: 'CLEAR complete - conversation reset with summary' });
676
- return modified; // Skip other injections - this is a complete reset
677
- }
678
- }
679
- // === STATIC INJECTION: Team memory (PAST sessions only) ===
680
- // Cached per session - identical across all requests for cache preservation
681
- const cachedTeamMemory = cachedInjections.get(sessionInfo.sessionId);
682
- if (cachedTeamMemory) {
683
- // Reuse cached team memory (constant for this session)
684
- modified.__grovInjection = cachedTeamMemory;
685
- modified.__grovInjectionCached = true;
686
- logger.info({ msg: 'Using cached team memory', size: cachedTeamMemory.length });
687
- }
688
- else {
689
- // First request: compute team memory from PAST sessions only
690
- const mentionedFiles = extractFilesFromMessages(modified.messages || []);
691
- // Pass currentSessionId to exclude current session data
692
- const teamContext = buildTeamMemoryContext(sessionInfo.projectPath, mentionedFiles, sessionInfo.sessionId // Exclude current session
693
- );
694
- if (teamContext) {
695
- modified.__grovInjection = teamContext;
696
- modified.__grovInjectionCached = false;
697
- // Cache for future requests (stays constant)
698
- cachedInjections.set(sessionInfo.sessionId, teamContext);
699
- logger.info({ msg: 'Computed and cached team memory', size: teamContext.length });
700
- }
701
- }
702
- // SKIP dynamic injection for retries and continuations
703
- if (requestType !== 'first') {
704
- return modified;
705
- }
706
- // === DYNAMIC INJECTION: User message (delta only) ===
707
- // Includes: edited files, key decisions, drift correction, forced recovery
708
- // This goes into the LAST user message, not system prompt
709
- const dynamicInjection = buildDynamicInjection(sessionInfo.sessionId, sessionState, logger);
710
- if (dynamicInjection) {
711
- modified.__grovUserMsgInjection = dynamicInjection;
712
- logger.info({ msg: 'Dynamic injection ready for user message', size: dynamicInjection.length });
713
- // Clear pending corrections after building injection
714
- if (sessionState?.pending_correction || sessionState?.pending_forced_recovery) {
715
- updateSessionState(sessionInfo.sessionId, {
716
- pending_correction: undefined,
717
- pending_forced_recovery: undefined,
718
- });
719
- }
720
- }
721
- return modified;
722
- }
723
344
  /**
724
345
  * Post-process response after receiving from Anthropic
725
346
  * - Task orchestration (new/continue/subtask/complete)
@@ -730,7 +351,7 @@ async function preProcessRequest(body, sessionInfo, logger) {
730
351
  * - Recovery alignment check (Section 4.4)
731
352
  * - Team memory triggers (Section 4.6)
732
353
  */
733
- async function postProcessResponse(response, sessionInfo, requestBody, logger) {
354
+ async function postProcessResponse(response, sessionInfo, requestBody, logger, extendedCacheData) {
734
355
  // Parse tool_use blocks
735
356
  const actions = parseToolUseBlocks(response);
736
357
  // Extract text content for analysis
@@ -752,6 +373,29 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
752
373
  if (isWarmup) {
753
374
  return;
754
375
  }
376
+ // === EXTENDED CACHE: Capture for keep-alive ===
377
+ // Only capture on end_turn (user idle starts now, not during tool_use loops)
378
+ if (isEndTurn && extendedCacheData) {
379
+ const rawStr = extendedCacheData.rawBody.toString('utf-8');
380
+ const hasSystem = rawStr.includes('"system"');
381
+ const hasTools = rawStr.includes('"tools"');
382
+ const hasCacheCtrl = rawStr.includes('"cache_control"');
383
+ const msgMatch = rawStr.match(/"messages"\s*:\s*\[/);
384
+ const msgPos = msgMatch?.index ?? -1;
385
+ // Use projectPath as key (one entry per conversation, not per task)
386
+ const cacheKey = sessionInfo.projectPath;
387
+ // Evict oldest if at capacity (only for NEW entries, not updates)
388
+ if (!extendedCache.has(cacheKey)) {
389
+ evictOldestCacheEntry();
390
+ }
391
+ extendedCache.set(cacheKey, {
392
+ headers: extendedCacheData.headers,
393
+ rawBody: extendedCacheData.rawBody,
394
+ timestamp: Date.now(),
395
+ keepAliveCount: 0,
396
+ });
397
+ log(`Extended cache: CAPTURE project=${cacheKey.split('/').pop()} size=${rawStr.length} sys=${hasSystem} tools=${hasTools} cache_ctrl=${hasCacheCtrl} msg_pos=${msgPos}`);
398
+ }
755
399
  // If not end_turn (tool_use in progress), skip task orchestration but keep session
756
400
  if (!isEndTurn) {
757
401
  // Use existing session or create minimal one without LLM calls
@@ -774,23 +418,44 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
774
418
  promptCount: 1,
775
419
  projectPath: sessionInfo.projectPath,
776
420
  });
421
+ // Note: team memory is now GLOBAL (not per session), no propagation needed
777
422
  }
778
423
  }
779
424
  else if (isTaskAnalysisAvailable()) {
780
425
  // Use completed session for comparison if no active session
781
426
  const sessionForComparison = sessionInfo.currentSession || sessionInfo.completedSession;
427
+ // Extract conversation history for context-aware task analysis
428
+ const conversationHistory = extractConversationHistory(requestBody.messages || []);
782
429
  try {
783
- const taskAnalysis = await analyzeTaskContext(sessionForComparison, latestUserMessage, recentSteps, textContent);
430
+ const taskAnalysis = await analyzeTaskContext(sessionForComparison, latestUserMessage, recentSteps, textContent, conversationHistory);
784
431
  logger.info({
785
432
  msg: 'Task analysis',
786
433
  action: taskAnalysis.action,
787
- topic_match: taskAnalysis.topic_match,
434
+ task_type: taskAnalysis.task_type,
788
435
  goal: taskAnalysis.current_goal?.substring(0, 50),
789
436
  reasoning: taskAnalysis.reasoning,
790
437
  });
438
+ // TASK LOG: Analysis result
439
+ taskLog('TASK_ANALYSIS', {
440
+ sessionId: sessionInfo.sessionId,
441
+ action: taskAnalysis.action,
442
+ task_type: taskAnalysis.task_type,
443
+ goal: taskAnalysis.current_goal || '',
444
+ reasoning: taskAnalysis.reasoning || '',
445
+ userMessage: latestUserMessage.substring(0, 80),
446
+ hasCurrentSession: !!sessionInfo.currentSession,
447
+ hasCompletedSession: !!sessionInfo.completedSession,
448
+ });
791
449
  // Update recent steps with reasoning (backfill from end_turn response)
792
450
  if (taskAnalysis.step_reasoning && activeSessionId) {
793
451
  const updatedCount = updateRecentStepsReasoning(activeSessionId, taskAnalysis.step_reasoning);
452
+ // TASK LOG: Step reasoning update
453
+ taskLog('STEP_REASONING', {
454
+ sessionId: activeSessionId,
455
+ stepsUpdated: updatedCount,
456
+ reasoningEntries: Object.keys(taskAnalysis.step_reasoning).length,
457
+ stepIds: Object.keys(taskAnalysis.step_reasoning).join(','),
458
+ });
794
459
  }
795
460
  // Handle task orchestration based on analysis
796
461
  switch (taskAnalysis.action) {
@@ -809,6 +474,13 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
809
474
  });
810
475
  activeSession.original_goal = taskAnalysis.current_goal;
811
476
  }
477
+ // TASK LOG: Continue existing session
478
+ taskLog('ORCHESTRATION_CONTINUE', {
479
+ sessionId: activeSessionId,
480
+ source: 'current_session',
481
+ goal: activeSession.original_goal,
482
+ goalUpdated: taskAnalysis.current_goal !== activeSession.original_goal,
483
+ });
812
484
  }
813
485
  else if (sessionInfo.completedSession) {
814
486
  // Reactivate completed session (user wants to continue/add to it)
@@ -824,6 +496,13 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
824
496
  promptCount: 1,
825
497
  projectPath: sessionInfo.projectPath,
826
498
  });
499
+ // Note: team memory is now GLOBAL (not per session), no propagation needed
500
+ // TASK LOG: Reactivate completed session
501
+ taskLog('ORCHESTRATION_CONTINUE', {
502
+ sessionId: activeSessionId,
503
+ source: 'reactivated_completed',
504
+ goal: activeSession.original_goal,
505
+ });
827
506
  }
828
507
  break;
829
508
  case 'new_task': {
@@ -843,9 +522,24 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
843
522
  try {
844
523
  intentData = await extractIntent(latestUserMessage);
845
524
  logger.info({ msg: 'Intent extracted for new task', scopeCount: intentData.expected_scope.length });
525
+ // TASK LOG: Intent extraction for new_task
526
+ taskLog('INTENT_EXTRACTION', {
527
+ sessionId: sessionInfo.sessionId,
528
+ context: 'new_task',
529
+ goal: intentData.goal,
530
+ scopeCount: intentData.expected_scope.length,
531
+ scope: intentData.expected_scope.join(', '),
532
+ constraints: intentData.constraints.join(', '),
533
+ keywords: intentData.keywords.join(', '),
534
+ });
846
535
  }
847
536
  catch (err) {
848
537
  logger.info({ msg: 'Intent extraction failed, using basic goal', error: String(err) });
538
+ taskLog('INTENT_EXTRACTION_FAILED', {
539
+ sessionId: sessionInfo.sessionId,
540
+ context: 'new_task',
541
+ error: String(err),
542
+ });
849
543
  }
850
544
  }
851
545
  const newSessionId = randomUUID();
@@ -865,6 +559,42 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
865
559
  projectPath: sessionInfo.projectPath,
866
560
  });
867
561
  logger.info({ msg: 'Created new task session', sessionId: newSessionId.substring(0, 8) });
562
+ // TASK LOG: New task created
563
+ taskLog('ORCHESTRATION_NEW_TASK', {
564
+ sessionId: newSessionId,
565
+ goal: intentData.goal,
566
+ scopeCount: intentData.expected_scope.length,
567
+ keywordsCount: intentData.keywords.length,
568
+ });
569
+ // Q&A AUTO-SAVE: If this is an information request with a substantive answer
570
+ // AND no tool calls, save immediately since pure Q&A completes in a single turn.
571
+ // If there ARE tool calls (e.g., Read for "Analyze X"), wait for them to complete
572
+ // so steps get captured properly before saving.
573
+ if (taskAnalysis.task_type === 'information' && textContent.length > 100 && actions.length === 0) {
574
+ logger.info({ msg: 'Q&A detected (pure text) - saving immediately', sessionId: newSessionId.substring(0, 8) });
575
+ taskLog('QA_AUTO_SAVE', {
576
+ sessionId: newSessionId,
577
+ goal: intentData.goal,
578
+ responseLength: textContent.length,
579
+ toolCalls: 0,
580
+ });
581
+ // Store the response for reasoning extraction
582
+ updateSessionState(newSessionId, {
583
+ final_response: textContent.substring(0, 10000),
584
+ });
585
+ // Save to team memory and mark complete
586
+ await saveToTeamMemory(newSessionId, 'complete');
587
+ markSessionCompleted(newSessionId);
588
+ }
589
+ else if (taskAnalysis.task_type === 'information' && actions.length > 0) {
590
+ // Q&A with tool calls - don't auto-save, let it continue until task_complete
591
+ logger.info({ msg: 'Q&A with tool calls - waiting for completion', sessionId: newSessionId.substring(0, 8), toolCalls: actions.length });
592
+ taskLog('QA_DEFERRED', {
593
+ sessionId: newSessionId,
594
+ goal: intentData.goal,
595
+ toolCalls: actions.length,
596
+ });
597
+ }
868
598
  break;
869
599
  }
870
600
  case 'subtask': {
@@ -878,8 +608,17 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
878
608
  if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
879
609
  try {
880
610
  intentData = await extractIntent(latestUserMessage);
611
+ taskLog('INTENT_EXTRACTION', {
612
+ sessionId: sessionInfo.sessionId,
613
+ context: 'subtask',
614
+ goal: intentData.goal,
615
+ scope: intentData.expected_scope.join(', '),
616
+ keywords: intentData.keywords.join(', '),
617
+ });
618
+ }
619
+ catch (err) {
620
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'subtask', error: String(err) });
881
621
  }
882
- catch { /* use fallback */ }
883
622
  }
884
623
  const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
885
624
  const subtaskId = randomUUID();
@@ -900,6 +639,12 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
900
639
  projectPath: sessionInfo.projectPath,
901
640
  });
902
641
  logger.info({ msg: 'Created subtask session', sessionId: subtaskId.substring(0, 8), parent: parentId?.substring(0, 8) });
642
+ // TASK LOG: Subtask created
643
+ taskLog('ORCHESTRATION_SUBTASK', {
644
+ sessionId: subtaskId,
645
+ parentId: parentId || 'none',
646
+ goal: intentData.goal,
647
+ });
903
648
  break;
904
649
  }
905
650
  case 'parallel_task': {
@@ -913,8 +658,17 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
913
658
  if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
914
659
  try {
915
660
  intentData = await extractIntent(latestUserMessage);
661
+ taskLog('INTENT_EXTRACTION', {
662
+ sessionId: sessionInfo.sessionId,
663
+ context: 'parallel_task',
664
+ goal: intentData.goal,
665
+ scope: intentData.expected_scope.join(', '),
666
+ keywords: intentData.keywords.join(', '),
667
+ });
668
+ }
669
+ catch (err) {
670
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'parallel_task', error: String(err) });
916
671
  }
917
- catch { /* use fallback */ }
918
672
  }
919
673
  const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
920
674
  const parallelId = randomUUID();
@@ -935,22 +689,89 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
935
689
  projectPath: sessionInfo.projectPath,
936
690
  });
937
691
  logger.info({ msg: 'Created parallel task session', sessionId: parallelId.substring(0, 8), parent: parentId?.substring(0, 8) });
692
+ // TASK LOG: Parallel task created
693
+ taskLog('ORCHESTRATION_PARALLEL', {
694
+ sessionId: parallelId,
695
+ parentId: parentId || 'none',
696
+ goal: intentData.goal,
697
+ });
938
698
  break;
939
699
  }
940
700
  case 'task_complete': {
941
701
  // Save to team memory and mark as completed (don't delete yet - keep for new_task detection)
942
702
  if (sessionInfo.currentSession) {
943
703
  try {
704
+ // Set final_response BEFORE saving so reasoning extraction has the data
705
+ updateSessionState(sessionInfo.currentSession.session_id, {
706
+ final_response: textContent.substring(0, 10000),
707
+ });
944
708
  await saveToTeamMemory(sessionInfo.currentSession.session_id, 'complete');
945
709
  markSessionCompleted(sessionInfo.currentSession.session_id);
946
710
  activeSessions.delete(sessionInfo.currentSession.session_id);
947
711
  lastDriftResults.delete(sessionInfo.currentSession.session_id);
712
+ // TASK LOG: Task completed
713
+ taskLog('ORCHESTRATION_TASK_COMPLETE', {
714
+ sessionId: sessionInfo.currentSession.session_id,
715
+ goal: sessionInfo.currentSession.original_goal,
716
+ });
717
+ // PLANNING COMPLETE: Trigger CLEAR-like reset for implementation phase
718
+ // This ensures next request starts fresh with planning context from team memory
719
+ if (taskAnalysis.task_type === 'planning' && isSummaryAvailable()) {
720
+ try {
721
+ const allSteps = getValidatedSteps(sessionInfo.currentSession.session_id);
722
+ const planSummary = await generateSessionSummary(sessionInfo.currentSession, allSteps, 2000);
723
+ // Store for next request to trigger CLEAR
724
+ setPendingPlanClear({
725
+ projectPath: sessionInfo.projectPath,
726
+ summary: planSummary,
727
+ });
728
+ // Cache invalidation happens in response-processor.ts after syncTask completes
729
+ logger.info({
730
+ msg: 'PLANNING_CLEAR triggered',
731
+ sessionId: sessionInfo.currentSession.session_id.substring(0, 8),
732
+ summaryLen: planSummary.length,
733
+ });
734
+ }
735
+ catch {
736
+ // Silent fail - planning CLEAR is optional enhancement
737
+ }
738
+ }
948
739
  logger.info({ msg: 'Task complete - saved to team memory, marked completed' });
949
740
  }
950
741
  catch (err) {
951
742
  logger.info({ msg: 'Failed to save completed task', error: String(err) });
952
743
  }
953
744
  }
745
+ else if (textContent.length > 100) {
746
+ // NEW: Handle "instant complete" - task that's new AND immediately complete
747
+ // This happens for simple Q&A when Haiku says task_complete without existing session
748
+ // Example: user asks clarification question, answer is provided in single turn
749
+ try {
750
+ const newSessionId = randomUUID();
751
+ const instantSession = createSessionState({
752
+ session_id: newSessionId,
753
+ project_path: sessionInfo.projectPath,
754
+ original_goal: taskAnalysis.current_goal || latestUserMessage.substring(0, 500),
755
+ task_type: 'main',
756
+ });
757
+ // Set final_response for reasoning extraction
758
+ updateSessionState(newSessionId, {
759
+ final_response: textContent.substring(0, 10000),
760
+ });
761
+ await saveToTeamMemory(newSessionId, 'complete');
762
+ markSessionCompleted(newSessionId);
763
+ logger.info({ msg: 'Instant complete - new task saved immediately', sessionId: newSessionId.substring(0, 8) });
764
+ // TASK LOG: Instant complete (new task that finished in one turn)
765
+ taskLog('ORCHESTRATION_TASK_COMPLETE', {
766
+ sessionId: newSessionId,
767
+ goal: taskAnalysis.current_goal || latestUserMessage.substring(0, 80),
768
+ source: 'instant_complete',
769
+ });
770
+ }
771
+ catch (err) {
772
+ logger.info({ msg: 'Failed to save instant complete task', error: String(err) });
773
+ }
774
+ }
954
775
  return; // Done, no more processing needed
955
776
  }
956
777
  case 'subtask_complete': {
@@ -969,6 +790,12 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
969
790
  activeSessionId = parentId;
970
791
  activeSession = parentSession;
971
792
  logger.info({ msg: 'Subtask complete - returning to parent', parent: parentId.substring(0, 8) });
793
+ // TASK LOG: Subtask completed
794
+ taskLog('ORCHESTRATION_SUBTASK_COMPLETE', {
795
+ sessionId: sessionInfo.currentSession.session_id,
796
+ parentId: parentId,
797
+ goal: sessionInfo.currentSession.original_goal,
798
+ });
972
799
  }
973
800
  }
974
801
  }
@@ -993,8 +820,16 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
993
820
  if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
994
821
  try {
995
822
  intentData = await extractIntent(latestUserMessage);
823
+ taskLog('INTENT_EXTRACTION', {
824
+ sessionId: sessionInfo.sessionId,
825
+ context: 'fallback_analysis_failed',
826
+ goal: intentData.goal,
827
+ scope: intentData.expected_scope.join(', '),
828
+ });
829
+ }
830
+ catch (err) {
831
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'fallback_analysis_failed', error: String(err) });
996
832
  }
997
- catch { /* use fallback */ }
998
833
  }
999
834
  const newSessionId = randomUUID();
1000
835
  activeSession = createSessionState({
@@ -1012,6 +847,11 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1012
847
  }
1013
848
  else {
1014
849
  // No task analysis available - fallback with intent extraction
850
+ taskLog('TASK_ANALYSIS_UNAVAILABLE', {
851
+ sessionId: sessionInfo.sessionId,
852
+ hasCurrentSession: !!sessionInfo.currentSession,
853
+ userMessage: latestUserMessage.substring(0, 80),
854
+ });
1015
855
  if (!sessionInfo.currentSession) {
1016
856
  let intentData = {
1017
857
  goal: latestUserMessage.substring(0, 500),
@@ -1023,8 +863,16 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1023
863
  try {
1024
864
  intentData = await extractIntent(latestUserMessage);
1025
865
  logger.info({ msg: 'Intent extracted (fallback)', scopeCount: intentData.expected_scope.length });
866
+ taskLog('INTENT_EXTRACTION', {
867
+ sessionId: sessionInfo.sessionId,
868
+ context: 'no_analysis_available',
869
+ goal: intentData.goal,
870
+ scope: intentData.expected_scope.join(', '),
871
+ });
872
+ }
873
+ catch (err) {
874
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'no_analysis_available', error: String(err) });
1026
875
  }
1027
- catch { /* use fallback */ }
1028
876
  }
1029
877
  const newSessionId = randomUUID();
1030
878
  activeSession = createSessionState({
@@ -1043,19 +891,12 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1043
891
  activeSessionId = sessionInfo.currentSession.session_id;
1044
892
  }
1045
893
  }
1046
- // AUTO-SAVE on every end_turn (for all task types: new_task, continue, subtask, parallel)
1047
- // task_complete and subtask_complete already save and return early, so they won't reach here
1048
- if (isEndTurn && activeSession && activeSessionId) {
1049
- try {
1050
- await saveToTeamMemory(activeSessionId, 'complete');
1051
- markSessionCompleted(activeSessionId);
1052
- activeSessions.delete(activeSessionId);
1053
- logger.info({ msg: 'Auto-saved task on end_turn', sessionId: activeSessionId.substring(0, 8) });
1054
- }
1055
- catch (err) {
1056
- logger.info({ msg: 'Auto-save failed', error: String(err) });
1057
- }
1058
- }
894
+ // NOTE: Auto-save on every end_turn was REMOVED
895
+ // Task saving is now controlled by Haiku's task analysis:
896
+ // - task_complete: Haiku detected task is done (Q&A answered, implementation verified, planning confirmed)
897
+ // - subtask_complete: Haiku detected subtask is done
898
+ // This ensures we only save when work is actually complete, not on every Claude response.
899
+ // See analyzeTaskContext() in llm-extractor.ts for the decision logic.
1059
900
  // Extract token usage
1060
901
  const usage = extractTokenUsage(response);
1061
902
  // Use cache metrics as actual context size (cacheCreation + cacheRead)
@@ -1106,18 +947,8 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1106
947
  });
1107
948
  }
1108
949
  if (actions.length === 0) {
1109
- // Pure Q&A (no tool calls) - auto-save as task
1110
- if (isEndTurn && activeSessionId && activeSession) {
1111
- try {
1112
- await saveToTeamMemory(activeSessionId, 'complete');
1113
- markSessionCompleted(activeSessionId);
1114
- activeSessions.delete(activeSessionId);
1115
- logger.info({ msg: 'Task saved on final answer', sessionId: activeSessionId.substring(0, 8) });
1116
- }
1117
- catch (err) {
1118
- logger.info({ msg: 'Task save failed', error: String(err) });
1119
- }
1120
- }
950
+ // Final response (no tool calls)
951
+ // NOTE: Task saving is controlled by Haiku's task analysis (see switch case 'task_complete' above)
1121
952
  return;
1122
953
  }
1123
954
  logger.info({
@@ -1242,20 +1073,36 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1242
1073
  }
1243
1074
  }
1244
1075
  // Save each action as a step (with reasoning from Claude's text)
1076
+ // When multiple actions come from the same Claude response, they share identical reasoning.
1077
+ // We store reasoning only on the first action and set NULL for subsequent ones to avoid duplication.
1078
+ // At query time, we group steps by reasoning (non-NULL starts a group, NULLs continue it)
1079
+ // and reconstruct the full context: reasoning + all associated files/actions.
1080
+ let previousReasoning = null;
1081
+ logger.info({ msg: 'DEDUP_DEBUG', actionsCount: actions.length, textContentLen: textContent.length });
1245
1082
  for (const action of actions) {
1083
+ const currentReasoning = textContent.substring(0, 1000);
1084
+ const isDuplicate = currentReasoning === previousReasoning;
1085
+ logger.info({
1086
+ msg: 'DEDUP_STEP',
1087
+ actionType: action.actionType,
1088
+ isDuplicate,
1089
+ prevLen: previousReasoning?.length || 0,
1090
+ currLen: currentReasoning.length
1091
+ });
1246
1092
  // Detect key decisions based on action type and reasoning content
1247
- const isKeyDecision = detectKeyDecision(action, textContent);
1093
+ const isKeyDecision = !isDuplicate && detectKeyDecision(action, textContent);
1248
1094
  createStep({
1249
1095
  session_id: activeSessionId,
1250
1096
  action_type: action.actionType,
1251
1097
  files: action.files,
1252
1098
  folders: action.folders,
1253
1099
  command: action.command,
1254
- reasoning: textContent.substring(0, 1000), // Claude's explanation (truncated)
1100
+ reasoning: isDuplicate ? undefined : currentReasoning,
1255
1101
  drift_score: driftScore,
1256
1102
  is_validated: !skipSteps,
1257
1103
  is_key_decision: isKeyDecision,
1258
1104
  });
1105
+ previousReasoning = currentReasoning;
1259
1106
  if (isKeyDecision) {
1260
1107
  logger.info({
1261
1108
  msg: 'Key decision detected',
@@ -1265,138 +1112,6 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1265
1112
  }
1266
1113
  }
1267
1114
  }
1268
- /**
1269
- * Detect if an action represents a key decision worth injecting later
1270
- * Key decisions are:
1271
- * - Edit/write actions (code modifications)
1272
- * - Actions with decision-related keywords in reasoning
1273
- * - Actions with substantial reasoning content
1274
- */
1275
- function detectKeyDecision(action, reasoning) {
1276
- // Code modifications are always key decisions
1277
- if (action.actionType === 'edit' || action.actionType === 'write') {
1278
- return true;
1279
- }
1280
- // Check for decision-related keywords in reasoning
1281
- const decisionKeywords = [
1282
- 'decision', 'decided', 'chose', 'chosen', 'selected', 'picked',
1283
- 'approach', 'strategy', 'solution', 'implementation',
1284
- 'because', 'reason', 'rationale', 'trade-off', 'tradeoff',
1285
- 'instead of', 'rather than', 'prefer', 'opted',
1286
- 'conclusion', 'determined', 'resolved'
1287
- ];
1288
- const reasoningLower = reasoning.toLowerCase();
1289
- const hasDecisionKeyword = decisionKeywords.some(kw => reasoningLower.includes(kw));
1290
- // Substantial reasoning (>200 chars) with decision keyword = key decision
1291
- if (hasDecisionKeyword && reasoning.length > 200) {
1292
- return true;
1293
- }
1294
- return false;
1295
- }
1296
- /**
1297
- * Extract text content from response for analysis
1298
- */
1299
- function extractTextContent(response) {
1300
- return response.content
1301
- .filter((block) => block.type === 'text')
1302
- .map(block => block.text)
1303
- .join('\n');
1304
- }
1305
- /**
1306
- * Detect task completion from response text
1307
- * Returns trigger type or null
1308
- */
1309
- function detectTaskCompletion(text) {
1310
- const lowerText = text.toLowerCase();
1311
- // Strong completion indicators
1312
- const completionPhrases = [
1313
- 'task is complete',
1314
- 'task complete',
1315
- 'implementation is complete',
1316
- 'implementation complete',
1317
- 'successfully implemented',
1318
- 'all changes have been made',
1319
- 'finished implementing',
1320
- 'completed the implementation',
1321
- 'done with the implementation',
1322
- 'completed all the',
1323
- 'all tests pass',
1324
- 'build succeeds',
1325
- ];
1326
- for (const phrase of completionPhrases) {
1327
- if (lowerText.includes(phrase)) {
1328
- return 'complete';
1329
- }
1330
- }
1331
- // Subtask completion indicators
1332
- const subtaskPhrases = [
1333
- 'step complete',
1334
- 'phase complete',
1335
- 'finished this step',
1336
- 'moving on to',
1337
- 'now let\'s',
1338
- 'next step',
1339
- ];
1340
- for (const phrase of subtaskPhrases) {
1341
- if (lowerText.includes(phrase)) {
1342
- return 'subtask';
1343
- }
1344
- }
1345
- return null;
1346
- }
1347
- /**
1348
- * Extract project path from request body
1349
- */
1350
- function extractProjectPath(body) {
1351
- // Try to extract from system prompt or messages
1352
- // Handle both string and array format for system prompt
1353
- let systemPrompt = '';
1354
- if (typeof body.system === 'string') {
1355
- systemPrompt = body.system;
1356
- }
1357
- else if (Array.isArray(body.system)) {
1358
- // New API format: system is array of {type: 'text', text: '...'}
1359
- systemPrompt = body.system
1360
- .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
1361
- .map(block => block.text)
1362
- .join('\n');
1363
- }
1364
- const cwdMatch = systemPrompt.match(/Working directory:\s*([^\n]+)/);
1365
- if (cwdMatch) {
1366
- return cwdMatch[1].trim();
1367
- }
1368
- return null;
1369
- }
1370
- /**
1371
- * Extract goal from FIRST user message with text content
1372
- * Skips tool_result blocks, filters out system-reminder tags
1373
- */
1374
- function extractGoalFromMessages(messages) {
1375
- const userMessages = messages?.filter(m => m.role === 'user') || [];
1376
- for (const userMsg of userMessages) {
1377
- let rawContent = '';
1378
- // Handle string content
1379
- if (typeof userMsg.content === 'string') {
1380
- rawContent = userMsg.content;
1381
- }
1382
- // Handle array content - look for text blocks (skip tool_result)
1383
- if (Array.isArray(userMsg.content)) {
1384
- const textBlocks = userMsg.content
1385
- .filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
1386
- .map(block => block.text);
1387
- rawContent = textBlocks.join('\n');
1388
- }
1389
- // Remove <system-reminder>...</system-reminder> tags
1390
- const cleanContent = rawContent
1391
- .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
1392
- .trim();
1393
- // If we found valid text content, return it
1394
- if (cleanContent && cleanContent.length >= 5) {
1395
- return cleanContent.substring(0, 500);
1396
- }
1397
- }
1398
- return undefined;
1399
- }
1400
1115
  /**
1401
1116
  * Filter response headers for forwarding to client
1402
1117
  */
@@ -1446,13 +1161,74 @@ export async function startServer(options = {}) {
1446
1161
  console.log('[DEBUG] Logging to grov-proxy.log');
1447
1162
  }
1448
1163
  const server = createServer();
1164
+ // Set server logger for background tasks
1165
+ serverLog = server.log;
1449
1166
  // Cleanup old completed sessions (older than 24 hours)
1450
1167
  cleanupOldCompletedSessions();
1168
+ // Cleanup stale active sessions (no activity for 1 hour)
1169
+ // Prevents old sessions from being reused in fresh Claude sessions
1170
+ const staleCount = cleanupStaleActiveSessions();
1171
+ if (staleCount > 0) {
1172
+ log(`Cleaned up ${staleCount} stale active session(s)`);
1173
+ }
1174
+ // Start extended cache timer if enabled
1175
+ let extendedCacheTimer = null;
1176
+ // Track active connections for graceful shutdown
1177
+ const activeConnections = new Set();
1178
+ let isShuttingDown = false;
1179
+ // Graceful shutdown handler (works with or without extended cache)
1180
+ const gracefulShutdown = () => {
1181
+ if (isShuttingDown)
1182
+ return;
1183
+ isShuttingDown = true;
1184
+ log('Shutdown initiated...');
1185
+ // 1. Stop extended cache timer if running
1186
+ if (extendedCacheTimer) {
1187
+ clearInterval(extendedCacheTimer);
1188
+ extendedCacheTimer = null;
1189
+ log('Extended cache: timer stopped');
1190
+ }
1191
+ // 2. Clear sensitive cache data
1192
+ if (extendedCache.size > 0) {
1193
+ log(`Extended cache: clearing ${extendedCache.size} entries`);
1194
+ for (const entry of extendedCache.values()) {
1195
+ for (const key of Object.keys(entry.headers)) {
1196
+ entry.headers[key] = '';
1197
+ }
1198
+ entry.rawBody = Buffer.alloc(0);
1199
+ }
1200
+ extendedCache.clear();
1201
+ }
1202
+ // 3. Stop accepting new connections
1203
+ server.close();
1204
+ // 4. Grace period (500ms) then force close remaining connections
1205
+ setTimeout(() => {
1206
+ if (activeConnections.size > 0) {
1207
+ log(`Force closing ${activeConnections.size} connection(s)`);
1208
+ for (const socket of activeConnections) {
1209
+ socket.destroy();
1210
+ }
1211
+ }
1212
+ log('Goodbye!');
1213
+ process.exit(0);
1214
+ }, 500);
1215
+ };
1216
+ process.on('SIGTERM', gracefulShutdown);
1217
+ process.on('SIGINT', gracefulShutdown);
1218
+ if (config.EXTENDED_CACHE_ENABLED) {
1219
+ extendedCacheTimer = setInterval(checkExtendedCache, 60_000);
1220
+ log('Extended cache: enabled (keep-alive timer started)');
1221
+ }
1451
1222
  try {
1452
1223
  await server.listen({
1453
1224
  host: config.HOST,
1454
1225
  port: config.PORT,
1455
1226
  });
1227
+ // Track connections for graceful shutdown
1228
+ server.server.on('connection', (socket) => {
1229
+ activeConnections.add(socket);
1230
+ socket.on('close', () => activeConnections.delete(socket));
1231
+ });
1456
1232
  console.log(`Grov Proxy: http://${config.HOST}:${config.PORT} -> ${config.ANTHROPIC_BASE_URL}`);
1457
1233
  return server;
1458
1234
  }