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.
- package/README.md +19 -1
- package/dist/cli.js +8 -0
- package/dist/lib/api-client.d.ts +18 -1
- package/dist/lib/api-client.js +57 -0
- package/dist/lib/llm-extractor.d.ts +14 -38
- package/dist/lib/llm-extractor.js +380 -406
- package/dist/lib/store/convenience.d.ts +40 -0
- package/dist/lib/store/convenience.js +104 -0
- package/dist/lib/store/database.d.ts +22 -0
- package/dist/lib/store/database.js +375 -0
- package/dist/lib/store/drift.d.ts +9 -0
- package/dist/lib/store/drift.js +89 -0
- package/dist/lib/store/index.d.ts +7 -0
- package/dist/lib/store/index.js +13 -0
- package/dist/lib/store/sessions.d.ts +32 -0
- package/dist/lib/store/sessions.js +240 -0
- package/dist/lib/store/steps.d.ts +40 -0
- package/dist/lib/store/steps.js +161 -0
- package/dist/lib/store/tasks.d.ts +33 -0
- package/dist/lib/store/tasks.js +133 -0
- package/dist/lib/store/types.d.ts +167 -0
- package/dist/lib/store/types.js +2 -0
- package/dist/lib/store.d.ts +1 -436
- package/dist/lib/store.js +2 -1478
- package/dist/proxy/cache.d.ts +36 -0
- package/dist/proxy/cache.js +51 -0
- package/dist/proxy/config.d.ts +1 -0
- package/dist/proxy/config.js +2 -0
- package/dist/proxy/extended-cache.d.ts +10 -0
- package/dist/proxy/extended-cache.js +155 -0
- package/dist/proxy/handlers/preprocess.d.ts +20 -0
- package/dist/proxy/handlers/preprocess.js +169 -0
- package/dist/proxy/injection/delta-tracking.d.ts +11 -0
- package/dist/proxy/injection/delta-tracking.js +93 -0
- package/dist/proxy/injection/injectors.d.ts +7 -0
- package/dist/proxy/injection/injectors.js +139 -0
- package/dist/proxy/request-processor.d.ts +18 -4
- package/dist/proxy/request-processor.js +151 -30
- package/dist/proxy/response-processor.js +93 -45
- package/dist/proxy/server.d.ts +0 -1
- package/dist/proxy/server.js +342 -566
- package/dist/proxy/types.d.ts +13 -0
- package/dist/proxy/types.js +2 -0
- package/dist/proxy/utils/extractors.d.ts +18 -0
- package/dist/proxy/utils/extractors.js +109 -0
- package/dist/proxy/utils/logging.d.ts +18 -0
- package/dist/proxy/utils/logging.js +42 -0
- package/package.json +5 -2
package/dist/proxy/server.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
321
|
+
function detectRequestType(messages, projectPath) {
|
|
604
322
|
const currentCount = messages?.length || 0;
|
|
605
|
-
const lastCount = lastMessageCount.get(
|
|
606
|
-
lastMessageCount.set(
|
|
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
|
-
|
|
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
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
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
|
-
//
|
|
1110
|
-
|
|
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:
|
|
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
|
}
|