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.
- package/README.md +34 -4
- 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 -39
- package/dist/lib/llm-extractor.js +379 -407
- 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 +366 -582
- 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 +7 -2
- package/postinstall.js +19 -0
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
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
//
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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:
|
|
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
|
}
|