grov 0.5.2 → 0.5.4

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 (49) hide show
  1. package/README.md +34 -4
  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 -39
  6. package/dist/lib/llm-extractor.js +379 -407
  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 +366 -582
  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 +7 -2
  49. package/postinstall.js +19 -0
@@ -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
@@ -762,10 +406,21 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
762
406
  else if (!activeSession) {
763
407
  // First request, create session without task analysis
764
408
  const newSessionId = randomUUID();
409
+ // Extract clean goal summary instead of using raw text
410
+ let goalSummary = latestUserMessage.substring(0, 500) || 'Task in progress';
411
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
412
+ try {
413
+ const intentData = await extractIntent(latestUserMessage);
414
+ goalSummary = intentData.goal;
415
+ }
416
+ catch {
417
+ // Keep fallback goalSummary
418
+ }
419
+ }
765
420
  activeSession = createSessionState({
766
421
  session_id: newSessionId,
767
422
  project_path: sessionInfo.projectPath,
768
- original_goal: latestUserMessage.substring(0, 500) || 'Task in progress',
423
+ original_goal: goalSummary,
769
424
  task_type: 'main',
770
425
  });
771
426
  activeSessionId = newSessionId;
@@ -774,23 +429,42 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
774
429
  promptCount: 1,
775
430
  projectPath: sessionInfo.projectPath,
776
431
  });
432
+ // Note: team memory is now GLOBAL (not per session), no propagation needed
777
433
  }
778
434
  }
779
435
  else if (isTaskAnalysisAvailable()) {
780
436
  // Use completed session for comparison if no active session
781
437
  const sessionForComparison = sessionInfo.currentSession || sessionInfo.completedSession;
438
+ // Extract conversation history for context-aware task analysis
439
+ const conversationHistory = extractConversationHistory(requestBody.messages || []);
782
440
  try {
783
- const taskAnalysis = await analyzeTaskContext(sessionForComparison, latestUserMessage, recentSteps, textContent);
441
+ const taskAnalysis = await analyzeTaskContext(sessionForComparison, latestUserMessage, recentSteps, textContent, conversationHistory);
784
442
  logger.info({
785
443
  msg: 'Task analysis',
786
444
  action: taskAnalysis.action,
787
- topic_match: taskAnalysis.topic_match,
788
- goal: taskAnalysis.current_goal?.substring(0, 50),
445
+ task_type: taskAnalysis.task_type,
789
446
  reasoning: taskAnalysis.reasoning,
790
447
  });
448
+ // TASK LOG: Analysis result
449
+ taskLog('TASK_ANALYSIS', {
450
+ sessionId: sessionInfo.sessionId,
451
+ action: taskAnalysis.action,
452
+ task_type: taskAnalysis.task_type,
453
+ reasoning: taskAnalysis.reasoning || '',
454
+ userMessage: latestUserMessage.substring(0, 80),
455
+ hasCurrentSession: !!sessionInfo.currentSession,
456
+ hasCompletedSession: !!sessionInfo.completedSession,
457
+ });
791
458
  // Update recent steps with reasoning (backfill from end_turn response)
792
459
  if (taskAnalysis.step_reasoning && activeSessionId) {
793
460
  const updatedCount = updateRecentStepsReasoning(activeSessionId, taskAnalysis.step_reasoning);
461
+ // TASK LOG: Step reasoning update
462
+ taskLog('STEP_REASONING', {
463
+ sessionId: activeSessionId,
464
+ stepsUpdated: updatedCount,
465
+ reasoningEntries: Object.keys(taskAnalysis.step_reasoning).length,
466
+ stepIds: Object.keys(taskAnalysis.step_reasoning).join(','),
467
+ });
794
468
  }
795
469
  // Handle task orchestration based on analysis
796
470
  switch (taskAnalysis.action) {
@@ -799,16 +473,12 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
799
473
  if (sessionInfo.currentSession) {
800
474
  activeSessionId = sessionInfo.currentSession.session_id;
801
475
  activeSession = sessionInfo.currentSession;
802
- // Update goal if Haiku detected a new instruction from user
803
- // (same task/topic, but new specific instruction)
804
- if (taskAnalysis.current_goal &&
805
- taskAnalysis.current_goal !== activeSession.original_goal &&
806
- latestUserMessage.length > 30) {
807
- updateSessionState(activeSessionId, {
808
- original_goal: taskAnalysis.current_goal,
809
- });
810
- activeSession.original_goal = taskAnalysis.current_goal;
811
- }
476
+ // TASK LOG: Continue existing session
477
+ taskLog('ORCHESTRATION_CONTINUE', {
478
+ sessionId: activeSessionId,
479
+ source: 'current_session',
480
+ goal: activeSession.original_goal,
481
+ });
812
482
  }
813
483
  else if (sessionInfo.completedSession) {
814
484
  // Reactivate completed session (user wants to continue/add to it)
@@ -816,7 +486,6 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
816
486
  activeSession = sessionInfo.completedSession;
817
487
  updateSessionState(activeSessionId, {
818
488
  status: 'active',
819
- original_goal: taskAnalysis.current_goal || activeSession.original_goal,
820
489
  });
821
490
  activeSession.status = 'active';
822
491
  activeSessions.set(activeSessionId, {
@@ -824,6 +493,13 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
824
493
  promptCount: 1,
825
494
  projectPath: sessionInfo.projectPath,
826
495
  });
496
+ // Note: team memory is now GLOBAL (not per session), no propagation needed
497
+ // TASK LOG: Reactivate completed session
498
+ taskLog('ORCHESTRATION_CONTINUE', {
499
+ sessionId: activeSessionId,
500
+ source: 'reactivated_completed',
501
+ goal: activeSession.original_goal,
502
+ });
827
503
  }
828
504
  break;
829
505
  case 'new_task': {
@@ -834,7 +510,7 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
834
510
  }
835
511
  // Extract full intent for new task (goal, scope, constraints, keywords)
836
512
  let intentData = {
837
- goal: taskAnalysis.current_goal,
513
+ goal: latestUserMessage.substring(0, 500),
838
514
  expected_scope: [],
839
515
  constraints: [],
840
516
  keywords: [],
@@ -843,9 +519,24 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
843
519
  try {
844
520
  intentData = await extractIntent(latestUserMessage);
845
521
  logger.info({ msg: 'Intent extracted for new task', scopeCount: intentData.expected_scope.length });
522
+ // TASK LOG: Intent extraction for new_task
523
+ taskLog('INTENT_EXTRACTION', {
524
+ sessionId: sessionInfo.sessionId,
525
+ context: 'new_task',
526
+ goal: intentData.goal,
527
+ scopeCount: intentData.expected_scope.length,
528
+ scope: intentData.expected_scope.join(', '),
529
+ constraints: intentData.constraints.join(', '),
530
+ keywords: intentData.keywords.join(', '),
531
+ });
846
532
  }
847
533
  catch (err) {
848
534
  logger.info({ msg: 'Intent extraction failed, using basic goal', error: String(err) });
535
+ taskLog('INTENT_EXTRACTION_FAILED', {
536
+ sessionId: sessionInfo.sessionId,
537
+ context: 'new_task',
538
+ error: String(err),
539
+ });
849
540
  }
850
541
  }
851
542
  const newSessionId = randomUUID();
@@ -865,12 +556,48 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
865
556
  projectPath: sessionInfo.projectPath,
866
557
  });
867
558
  logger.info({ msg: 'Created new task session', sessionId: newSessionId.substring(0, 8) });
559
+ // TASK LOG: New task created
560
+ taskLog('ORCHESTRATION_NEW_TASK', {
561
+ sessionId: newSessionId,
562
+ goal: intentData.goal,
563
+ scopeCount: intentData.expected_scope.length,
564
+ keywordsCount: intentData.keywords.length,
565
+ });
566
+ // Q&A AUTO-SAVE: If this is an information request with a substantive answer
567
+ // AND no tool calls, save immediately since pure Q&A completes in a single turn.
568
+ // If there ARE tool calls (e.g., Read for "Analyze X"), wait for them to complete
569
+ // so steps get captured properly before saving.
570
+ if (taskAnalysis.task_type === 'information' && textContent.length > 100 && actions.length === 0) {
571
+ logger.info({ msg: 'Q&A detected (pure text) - saving immediately', sessionId: newSessionId.substring(0, 8) });
572
+ taskLog('QA_AUTO_SAVE', {
573
+ sessionId: newSessionId,
574
+ goal: intentData.goal,
575
+ responseLength: textContent.length,
576
+ toolCalls: 0,
577
+ });
578
+ // Store the response for reasoning extraction
579
+ updateSessionState(newSessionId, {
580
+ final_response: textContent.substring(0, 10000),
581
+ });
582
+ // Save to team memory and mark complete
583
+ await saveToTeamMemory(newSessionId, 'complete');
584
+ markSessionCompleted(newSessionId);
585
+ }
586
+ else if (taskAnalysis.task_type === 'information' && actions.length > 0) {
587
+ // Q&A with tool calls - don't auto-save, let it continue until task_complete
588
+ logger.info({ msg: 'Q&A with tool calls - waiting for completion', sessionId: newSessionId.substring(0, 8), toolCalls: actions.length });
589
+ taskLog('QA_DEFERRED', {
590
+ sessionId: newSessionId,
591
+ goal: intentData.goal,
592
+ toolCalls: actions.length,
593
+ });
594
+ }
868
595
  break;
869
596
  }
870
597
  case 'subtask': {
871
598
  // Extract intent for subtask
872
599
  let intentData = {
873
- goal: taskAnalysis.current_goal,
600
+ goal: latestUserMessage.substring(0, 500),
874
601
  expected_scope: [],
875
602
  constraints: [],
876
603
  keywords: [],
@@ -878,8 +605,17 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
878
605
  if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
879
606
  try {
880
607
  intentData = await extractIntent(latestUserMessage);
608
+ taskLog('INTENT_EXTRACTION', {
609
+ sessionId: sessionInfo.sessionId,
610
+ context: 'subtask',
611
+ goal: intentData.goal,
612
+ scope: intentData.expected_scope.join(', '),
613
+ keywords: intentData.keywords.join(', '),
614
+ });
615
+ }
616
+ catch (err) {
617
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'subtask', error: String(err) });
881
618
  }
882
- catch { /* use fallback */ }
883
619
  }
884
620
  const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
885
621
  const subtaskId = randomUUID();
@@ -900,12 +636,18 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
900
636
  projectPath: sessionInfo.projectPath,
901
637
  });
902
638
  logger.info({ msg: 'Created subtask session', sessionId: subtaskId.substring(0, 8), parent: parentId?.substring(0, 8) });
639
+ // TASK LOG: Subtask created
640
+ taskLog('ORCHESTRATION_SUBTASK', {
641
+ sessionId: subtaskId,
642
+ parentId: parentId || 'none',
643
+ goal: intentData.goal,
644
+ });
903
645
  break;
904
646
  }
905
647
  case 'parallel_task': {
906
648
  // Extract intent for parallel task
907
649
  let intentData = {
908
- goal: taskAnalysis.current_goal,
650
+ goal: latestUserMessage.substring(0, 500),
909
651
  expected_scope: [],
910
652
  constraints: [],
911
653
  keywords: [],
@@ -913,8 +655,17 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
913
655
  if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
914
656
  try {
915
657
  intentData = await extractIntent(latestUserMessage);
658
+ taskLog('INTENT_EXTRACTION', {
659
+ sessionId: sessionInfo.sessionId,
660
+ context: 'parallel_task',
661
+ goal: intentData.goal,
662
+ scope: intentData.expected_scope.join(', '),
663
+ keywords: intentData.keywords.join(', '),
664
+ });
665
+ }
666
+ catch (err) {
667
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'parallel_task', error: String(err) });
916
668
  }
917
- catch { /* use fallback */ }
918
669
  }
919
670
  const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
920
671
  const parallelId = randomUUID();
@@ -935,22 +686,100 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
935
686
  projectPath: sessionInfo.projectPath,
936
687
  });
937
688
  logger.info({ msg: 'Created parallel task session', sessionId: parallelId.substring(0, 8), parent: parentId?.substring(0, 8) });
689
+ // TASK LOG: Parallel task created
690
+ taskLog('ORCHESTRATION_PARALLEL', {
691
+ sessionId: parallelId,
692
+ parentId: parentId || 'none',
693
+ goal: intentData.goal,
694
+ });
938
695
  break;
939
696
  }
940
697
  case 'task_complete': {
941
698
  // Save to team memory and mark as completed (don't delete yet - keep for new_task detection)
942
699
  if (sessionInfo.currentSession) {
943
700
  try {
701
+ // Set final_response BEFORE saving so reasoning extraction has the data
702
+ updateSessionState(sessionInfo.currentSession.session_id, {
703
+ final_response: textContent.substring(0, 10000),
704
+ });
944
705
  await saveToTeamMemory(sessionInfo.currentSession.session_id, 'complete');
945
706
  markSessionCompleted(sessionInfo.currentSession.session_id);
946
707
  activeSessions.delete(sessionInfo.currentSession.session_id);
947
708
  lastDriftResults.delete(sessionInfo.currentSession.session_id);
709
+ // TASK LOG: Task completed
710
+ taskLog('ORCHESTRATION_TASK_COMPLETE', {
711
+ sessionId: sessionInfo.currentSession.session_id,
712
+ goal: sessionInfo.currentSession.original_goal,
713
+ });
714
+ // PLANNING COMPLETE: Trigger CLEAR-like reset for implementation phase
715
+ // This ensures next request starts fresh with planning context from team memory
716
+ if (taskAnalysis.task_type === 'planning' && isSummaryAvailable()) {
717
+ try {
718
+ const allSteps = getValidatedSteps(sessionInfo.currentSession.session_id);
719
+ const planSummary = await generateSessionSummary(sessionInfo.currentSession, allSteps, 2000);
720
+ // Store for next request to trigger CLEAR
721
+ setPendingPlanClear({
722
+ projectPath: sessionInfo.projectPath,
723
+ summary: planSummary,
724
+ });
725
+ // Cache invalidation happens in response-processor.ts after syncTask completes
726
+ logger.info({
727
+ msg: 'PLANNING_CLEAR triggered',
728
+ sessionId: sessionInfo.currentSession.session_id.substring(0, 8),
729
+ summaryLen: planSummary.length,
730
+ });
731
+ }
732
+ catch {
733
+ // Silent fail - planning CLEAR is optional enhancement
734
+ }
735
+ }
948
736
  logger.info({ msg: 'Task complete - saved to team memory, marked completed' });
949
737
  }
950
738
  catch (err) {
951
739
  logger.info({ msg: 'Failed to save completed task', error: String(err) });
952
740
  }
953
741
  }
742
+ else if (textContent.length > 100) {
743
+ // NEW: Handle "instant complete" - task that's new AND immediately complete
744
+ // This happens for simple Q&A when Haiku says task_complete without existing session
745
+ // Example: user asks clarification question, answer is provided in single turn
746
+ try {
747
+ const newSessionId = randomUUID();
748
+ // Extract clean goal summary instead of using raw text
749
+ let goalSummary = latestUserMessage.substring(0, 500);
750
+ if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
751
+ try {
752
+ const intentData = await extractIntent(latestUserMessage);
753
+ goalSummary = intentData.goal;
754
+ }
755
+ catch {
756
+ // Keep fallback goalSummary
757
+ }
758
+ }
759
+ const instantSession = createSessionState({
760
+ session_id: newSessionId,
761
+ project_path: sessionInfo.projectPath,
762
+ original_goal: goalSummary,
763
+ task_type: 'main',
764
+ });
765
+ // Set final_response for reasoning extraction
766
+ updateSessionState(newSessionId, {
767
+ final_response: textContent.substring(0, 10000),
768
+ });
769
+ await saveToTeamMemory(newSessionId, 'complete');
770
+ markSessionCompleted(newSessionId);
771
+ logger.info({ msg: 'Instant complete - new task saved immediately', sessionId: newSessionId.substring(0, 8) });
772
+ // TASK LOG: Instant complete (new task that finished in one turn)
773
+ taskLog('ORCHESTRATION_TASK_COMPLETE', {
774
+ sessionId: newSessionId,
775
+ goal: goalSummary,
776
+ source: 'instant_complete',
777
+ });
778
+ }
779
+ catch (err) {
780
+ logger.info({ msg: 'Failed to save instant complete task', error: String(err) });
781
+ }
782
+ }
954
783
  return; // Done, no more processing needed
955
784
  }
956
785
  case 'subtask_complete': {
@@ -969,6 +798,12 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
969
798
  activeSessionId = parentId;
970
799
  activeSession = parentSession;
971
800
  logger.info({ msg: 'Subtask complete - returning to parent', parent: parentId.substring(0, 8) });
801
+ // TASK LOG: Subtask completed
802
+ taskLog('ORCHESTRATION_SUBTASK_COMPLETE', {
803
+ sessionId: sessionInfo.currentSession.session_id,
804
+ parentId: parentId,
805
+ goal: sessionInfo.currentSession.original_goal,
806
+ });
972
807
  }
973
808
  }
974
809
  }
@@ -993,8 +828,16 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
993
828
  if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
994
829
  try {
995
830
  intentData = await extractIntent(latestUserMessage);
831
+ taskLog('INTENT_EXTRACTION', {
832
+ sessionId: sessionInfo.sessionId,
833
+ context: 'fallback_analysis_failed',
834
+ goal: intentData.goal,
835
+ scope: intentData.expected_scope.join(', '),
836
+ });
837
+ }
838
+ catch (err) {
839
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'fallback_analysis_failed', error: String(err) });
996
840
  }
997
- catch { /* use fallback */ }
998
841
  }
999
842
  const newSessionId = randomUUID();
1000
843
  activeSession = createSessionState({
@@ -1012,6 +855,11 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1012
855
  }
1013
856
  else {
1014
857
  // No task analysis available - fallback with intent extraction
858
+ taskLog('TASK_ANALYSIS_UNAVAILABLE', {
859
+ sessionId: sessionInfo.sessionId,
860
+ hasCurrentSession: !!sessionInfo.currentSession,
861
+ userMessage: latestUserMessage.substring(0, 80),
862
+ });
1015
863
  if (!sessionInfo.currentSession) {
1016
864
  let intentData = {
1017
865
  goal: latestUserMessage.substring(0, 500),
@@ -1023,8 +871,16 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1023
871
  try {
1024
872
  intentData = await extractIntent(latestUserMessage);
1025
873
  logger.info({ msg: 'Intent extracted (fallback)', scopeCount: intentData.expected_scope.length });
874
+ taskLog('INTENT_EXTRACTION', {
875
+ sessionId: sessionInfo.sessionId,
876
+ context: 'no_analysis_available',
877
+ goal: intentData.goal,
878
+ scope: intentData.expected_scope.join(', '),
879
+ });
880
+ }
881
+ catch (err) {
882
+ taskLog('INTENT_EXTRACTION_FAILED', { sessionId: sessionInfo.sessionId, context: 'no_analysis_available', error: String(err) });
1026
883
  }
1027
- catch { /* use fallback */ }
1028
884
  }
1029
885
  const newSessionId = randomUUID();
1030
886
  activeSession = createSessionState({
@@ -1043,19 +899,12 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1043
899
  activeSessionId = sessionInfo.currentSession.session_id;
1044
900
  }
1045
901
  }
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
- }
902
+ // NOTE: Auto-save on every end_turn was REMOVED
903
+ // Task saving is now controlled by Haiku's task analysis:
904
+ // - task_complete: Haiku detected task is done (Q&A answered, implementation verified, planning confirmed)
905
+ // - subtask_complete: Haiku detected subtask is done
906
+ // This ensures we only save when work is actually complete, not on every Claude response.
907
+ // See analyzeTaskContext() in llm-extractor.ts for the decision logic.
1059
908
  // Extract token usage
1060
909
  const usage = extractTokenUsage(response);
1061
910
  // Use cache metrics as actual context size (cacheCreation + cacheRead)
@@ -1106,18 +955,8 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1106
955
  });
1107
956
  }
1108
957
  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
- }
958
+ // Final response (no tool calls)
959
+ // NOTE: Task saving is controlled by Haiku's task analysis (see switch case 'task_complete' above)
1121
960
  return;
1122
961
  }
1123
962
  logger.info({
@@ -1242,20 +1081,36 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1242
1081
  }
1243
1082
  }
1244
1083
  // Save each action as a step (with reasoning from Claude's text)
1084
+ // When multiple actions come from the same Claude response, they share identical reasoning.
1085
+ // We store reasoning only on the first action and set NULL for subsequent ones to avoid duplication.
1086
+ // At query time, we group steps by reasoning (non-NULL starts a group, NULLs continue it)
1087
+ // and reconstruct the full context: reasoning + all associated files/actions.
1088
+ let previousReasoning = null;
1089
+ logger.info({ msg: 'DEDUP_DEBUG', actionsCount: actions.length, textContentLen: textContent.length });
1245
1090
  for (const action of actions) {
1091
+ const currentReasoning = textContent.substring(0, 1000);
1092
+ const isDuplicate = currentReasoning === previousReasoning;
1093
+ logger.info({
1094
+ msg: 'DEDUP_STEP',
1095
+ actionType: action.actionType,
1096
+ isDuplicate,
1097
+ prevLen: previousReasoning?.length || 0,
1098
+ currLen: currentReasoning.length
1099
+ });
1246
1100
  // Detect key decisions based on action type and reasoning content
1247
- const isKeyDecision = detectKeyDecision(action, textContent);
1101
+ const isKeyDecision = !isDuplicate && detectKeyDecision(action, textContent);
1248
1102
  createStep({
1249
1103
  session_id: activeSessionId,
1250
1104
  action_type: action.actionType,
1251
1105
  files: action.files,
1252
1106
  folders: action.folders,
1253
1107
  command: action.command,
1254
- reasoning: textContent.substring(0, 1000), // Claude's explanation (truncated)
1108
+ reasoning: isDuplicate ? undefined : currentReasoning,
1255
1109
  drift_score: driftScore,
1256
1110
  is_validated: !skipSteps,
1257
1111
  is_key_decision: isKeyDecision,
1258
1112
  });
1113
+ previousReasoning = currentReasoning;
1259
1114
  if (isKeyDecision) {
1260
1115
  logger.info({
1261
1116
  msg: 'Key decision detected',
@@ -1265,138 +1120,6 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
1265
1120
  }
1266
1121
  }
1267
1122
  }
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
1123
  /**
1401
1124
  * Filter response headers for forwarding to client
1402
1125
  */
@@ -1446,13 +1169,74 @@ export async function startServer(options = {}) {
1446
1169
  console.log('[DEBUG] Logging to grov-proxy.log');
1447
1170
  }
1448
1171
  const server = createServer();
1172
+ // Set server logger for background tasks
1173
+ serverLog = server.log;
1449
1174
  // Cleanup old completed sessions (older than 24 hours)
1450
1175
  cleanupOldCompletedSessions();
1176
+ // Cleanup stale active sessions (no activity for 1 hour)
1177
+ // Prevents old sessions from being reused in fresh Claude sessions
1178
+ const staleCount = cleanupStaleActiveSessions();
1179
+ if (staleCount > 0) {
1180
+ log(`Cleaned up ${staleCount} stale active session(s)`);
1181
+ }
1182
+ // Start extended cache timer if enabled
1183
+ let extendedCacheTimer = null;
1184
+ // Track active connections for graceful shutdown
1185
+ const activeConnections = new Set();
1186
+ let isShuttingDown = false;
1187
+ // Graceful shutdown handler (works with or without extended cache)
1188
+ const gracefulShutdown = () => {
1189
+ if (isShuttingDown)
1190
+ return;
1191
+ isShuttingDown = true;
1192
+ log('Shutdown initiated...');
1193
+ // 1. Stop extended cache timer if running
1194
+ if (extendedCacheTimer) {
1195
+ clearInterval(extendedCacheTimer);
1196
+ extendedCacheTimer = null;
1197
+ log('Extended cache: timer stopped');
1198
+ }
1199
+ // 2. Clear sensitive cache data
1200
+ if (extendedCache.size > 0) {
1201
+ log(`Extended cache: clearing ${extendedCache.size} entries`);
1202
+ for (const entry of extendedCache.values()) {
1203
+ for (const key of Object.keys(entry.headers)) {
1204
+ entry.headers[key] = '';
1205
+ }
1206
+ entry.rawBody = Buffer.alloc(0);
1207
+ }
1208
+ extendedCache.clear();
1209
+ }
1210
+ // 3. Stop accepting new connections
1211
+ server.close();
1212
+ // 4. Grace period (500ms) then force close remaining connections
1213
+ setTimeout(() => {
1214
+ if (activeConnections.size > 0) {
1215
+ log(`Force closing ${activeConnections.size} connection(s)`);
1216
+ for (const socket of activeConnections) {
1217
+ socket.destroy();
1218
+ }
1219
+ }
1220
+ log('Goodbye!');
1221
+ process.exit(0);
1222
+ }, 500);
1223
+ };
1224
+ process.on('SIGTERM', gracefulShutdown);
1225
+ process.on('SIGINT', gracefulShutdown);
1226
+ if (config.EXTENDED_CACHE_ENABLED) {
1227
+ extendedCacheTimer = setInterval(checkExtendedCache, 60_000);
1228
+ log('Extended cache: enabled (keep-alive timer started)');
1229
+ }
1451
1230
  try {
1452
1231
  await server.listen({
1453
1232
  host: config.HOST,
1454
1233
  port: config.PORT,
1455
1234
  });
1235
+ // Track connections for graceful shutdown
1236
+ server.server.on('connection', (socket) => {
1237
+ activeConnections.add(socket);
1238
+ socket.on('close', () => activeConnections.delete(socket));
1239
+ });
1456
1240
  console.log(`Grov Proxy: http://${config.HOST}:${config.PORT} -> ${config.ANTHROPIC_BASE_URL}`);
1457
1241
  return server;
1458
1242
  }