grov 0.2.2 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -6
- package/dist/cli.js +32 -2
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +115 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +13 -0
- package/dist/commands/sync.d.ts +8 -0
- package/dist/commands/sync.js +127 -0
- package/dist/lib/api-client.d.ts +40 -0
- package/dist/lib/api-client.js +117 -0
- package/dist/lib/cloud-sync.d.ts +33 -0
- package/dist/lib/cloud-sync.js +176 -0
- package/dist/lib/credentials.d.ts +53 -0
- package/dist/lib/credentials.js +201 -0
- package/dist/lib/llm-extractor.d.ts +1 -1
- package/dist/lib/llm-extractor.js +53 -33
- package/dist/lib/store.d.ts +32 -2
- package/dist/lib/store.js +133 -11
- package/dist/lib/utils.d.ts +5 -0
- package/dist/lib/utils.js +45 -0
- package/dist/proxy/action-parser.d.ts +10 -2
- package/dist/proxy/action-parser.js +4 -2
- package/dist/proxy/forwarder.d.ts +7 -1
- package/dist/proxy/forwarder.js +157 -7
- package/dist/proxy/request-processor.d.ts +5 -4
- package/dist/proxy/request-processor.js +39 -11
- package/dist/proxy/response-processor.js +38 -7
- package/dist/proxy/server.d.ts +5 -1
- package/dist/proxy/server.js +693 -137
- package/package.json +18 -3
package/dist/proxy/server.js
CHANGED
|
@@ -4,15 +4,236 @@ import Fastify from 'fastify';
|
|
|
4
4
|
import { config } from './config.js';
|
|
5
5
|
import { forwardToAnthropic, isForwardError } from './forwarder.js';
|
|
6
6
|
import { parseToolUseBlocks, extractTokenUsage } from './action-parser.js';
|
|
7
|
-
import { createSessionState, getSessionState, updateSessionState, createStep, updateTokenCount, logDriftEvent, getRecentSteps, getValidatedSteps, updateSessionMode, markWaitingForRecovery, incrementEscalation, updateLastChecked, markCleared, getActiveSessionForUser, deleteSessionState, deleteStepsForSession, updateRecentStepsReasoning, markSessionCompleted, getCompletedSessionForProject, cleanupOldCompletedSessions, } from '../lib/store.js';
|
|
7
|
+
import { createSessionState, getSessionState, updateSessionState, createStep, updateTokenCount, logDriftEvent, getRecentSteps, getValidatedSteps, updateSessionMode, markWaitingForRecovery, incrementEscalation, updateLastChecked, markCleared, getActiveSessionForUser, deleteSessionState, deleteStepsForSession, updateRecentStepsReasoning, markSessionCompleted, getCompletedSessionForProject, cleanupOldCompletedSessions, getKeyDecisions, getEditedFiles, } from '../lib/store.js';
|
|
8
|
+
import { smartTruncate } from '../lib/utils.js';
|
|
8
9
|
import { checkDrift, scoreToCorrectionLevel, shouldSkipSteps, isDriftCheckAvailable, checkRecoveryAlignment, generateForcedRecovery, } from '../lib/drift-checker-proxy.js';
|
|
9
10
|
import { buildCorrection, formatCorrectionForInjection } from '../lib/correction-builder-proxy.js';
|
|
10
11
|
import { generateSessionSummary, isSummaryAvailable, extractIntent, isIntentExtractionAvailable, analyzeTaskContext, isTaskAnalysisAvailable, } from '../lib/llm-extractor.js';
|
|
11
12
|
import { buildTeamMemoryContext, extractFilesFromMessages } from './request-processor.js';
|
|
12
13
|
import { saveToTeamMemory } from './response-processor.js';
|
|
13
14
|
import { randomUUID } from 'crypto';
|
|
15
|
+
import * as fs from 'fs';
|
|
16
|
+
import * as path from 'path';
|
|
14
17
|
// Store last drift result for recovery alignment check
|
|
15
18
|
const lastDriftResults = new Map();
|
|
19
|
+
// Track last messageCount per session to detect retries vs new turns
|
|
20
|
+
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
|
+
}
|
|
16
237
|
/**
|
|
17
238
|
* Helper to append text to system prompt (handles string or array format)
|
|
18
239
|
*/
|
|
@@ -21,8 +242,13 @@ function appendToSystemPrompt(body, textToAppend) {
|
|
|
21
242
|
body.system = body.system + textToAppend;
|
|
22
243
|
}
|
|
23
244
|
else if (Array.isArray(body.system)) {
|
|
24
|
-
// Append as new text block
|
|
25
|
-
|
|
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
|
+
});
|
|
26
252
|
}
|
|
27
253
|
else {
|
|
28
254
|
// No system prompt yet, create as string
|
|
@@ -44,6 +270,45 @@ function getSystemPromptText(body) {
|
|
|
44
270
|
}
|
|
45
271
|
return '';
|
|
46
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
|
+
}
|
|
47
312
|
// Session tracking (in-memory for active sessions)
|
|
48
313
|
const activeSessions = new Map();
|
|
49
314
|
/**
|
|
@@ -51,21 +316,27 @@ const activeSessions = new Map();
|
|
|
51
316
|
*/
|
|
52
317
|
export function createServer() {
|
|
53
318
|
const fastify = Fastify({
|
|
54
|
-
logger:
|
|
55
|
-
level: 'error', // Only errors in console, details in file
|
|
56
|
-
},
|
|
319
|
+
logger: false, // Disabled - all debug goes to ~/.grov/debug.log
|
|
57
320
|
bodyLimit: config.BODY_LIMIT,
|
|
58
321
|
});
|
|
322
|
+
// Custom JSON parser that preserves raw bytes for cache preservation
|
|
323
|
+
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
|
|
324
|
+
// Store raw bytes on request for later use
|
|
325
|
+
req.rawBody = body;
|
|
326
|
+
try {
|
|
327
|
+
const json = JSON.parse(body.toString('utf-8'));
|
|
328
|
+
done(null, json);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
done(err, undefined);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
59
334
|
// Health check endpoint
|
|
60
335
|
fastify.get('/health', async () => {
|
|
61
336
|
return { status: 'ok', timestamp: new Date().toISOString() };
|
|
62
337
|
});
|
|
63
338
|
// Main messages endpoint
|
|
64
|
-
fastify.post('/v1/messages',
|
|
65
|
-
config: {
|
|
66
|
-
rawBody: true,
|
|
67
|
-
},
|
|
68
|
-
}, handleMessages);
|
|
339
|
+
fastify.post('/v1/messages', handleMessages);
|
|
69
340
|
// Catch-all for other Anthropic endpoints (pass through)
|
|
70
341
|
fastify.all('/*', async (request, reply) => {
|
|
71
342
|
fastify.log.warn(`Unhandled endpoint: ${request.method} ${request.url}`);
|
|
@@ -80,14 +351,12 @@ async function handleMessages(request, reply) {
|
|
|
80
351
|
const logger = request.log;
|
|
81
352
|
const startTime = Date.now();
|
|
82
353
|
const model = request.body.model;
|
|
83
|
-
// Skip Haiku subagents - forward directly without any tracking
|
|
84
|
-
// Haiku requests are Task tool spawns for exploration, they don't make decisions
|
|
85
|
-
// All reasoning and decisions happen in the main model (Opus/Sonnet)
|
|
86
354
|
if (model.includes('haiku')) {
|
|
87
355
|
logger.info({ msg: 'Skipping Haiku subagent', model });
|
|
88
356
|
try {
|
|
89
|
-
|
|
90
|
-
const
|
|
357
|
+
// Force non-streaming for Haiku too
|
|
358
|
+
const haikusBody = { ...request.body, stream: false };
|
|
359
|
+
const result = await forwardToAnthropic(haikusBody, request.headers, logger);
|
|
91
360
|
return reply
|
|
92
361
|
.status(result.statusCode)
|
|
93
362
|
.header('content-type', 'application/json')
|
|
@@ -112,6 +381,7 @@ async function handleMessages(request, reply) {
|
|
|
112
381
|
promptCount: sessionInfo.promptCount,
|
|
113
382
|
projectPath: sessionInfo.projectPath,
|
|
114
383
|
});
|
|
384
|
+
const currentRequestId = ++requestCounter;
|
|
115
385
|
logger.info({
|
|
116
386
|
msg: 'Incoming request',
|
|
117
387
|
sessionId: sessionInfo.sessionId.substring(0, 8),
|
|
@@ -119,27 +389,131 @@ async function handleMessages(request, reply) {
|
|
|
119
389
|
model: request.body.model,
|
|
120
390
|
messageCount: request.body.messages?.length || 0,
|
|
121
391
|
});
|
|
122
|
-
//
|
|
123
|
-
const
|
|
124
|
-
|
|
392
|
+
// Log REQUEST to file
|
|
393
|
+
const rawBodySize = request.rawBody?.length || 0;
|
|
394
|
+
proxyLog({
|
|
395
|
+
requestId: currentRequestId,
|
|
396
|
+
type: 'REQUEST',
|
|
397
|
+
sessionId: sessionInfo.sessionId.substring(0, 8),
|
|
398
|
+
data: {
|
|
399
|
+
model: request.body.model,
|
|
400
|
+
messageCount: request.body.messages?.length || 0,
|
|
401
|
+
promptCount: sessionInfo.promptCount,
|
|
402
|
+
rawBodySize,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
// Process request to get injection text
|
|
406
|
+
// __grovInjection = team memory (system prompt, cached)
|
|
407
|
+
// __grovUserMsgInjection = dynamic content (user message, delta only)
|
|
408
|
+
const processedBody = await preProcessRequest(request.body, sessionInfo, logger);
|
|
409
|
+
const systemInjection = processedBody.__grovInjection;
|
|
410
|
+
const userMsgInjection = processedBody.__grovUserMsgInjection;
|
|
411
|
+
// Get raw body bytes
|
|
412
|
+
const rawBody = request.rawBody;
|
|
413
|
+
let rawBodyStr = rawBody?.toString('utf-8') || '';
|
|
414
|
+
// Track injection sizes for logging
|
|
415
|
+
let systemInjectionSize = 0;
|
|
416
|
+
let userMsgInjectionSize = 0;
|
|
417
|
+
let systemSuccess = false;
|
|
418
|
+
let userMsgSuccess = false;
|
|
419
|
+
// 1. Inject team memory into SYSTEM prompt (cached, constant)
|
|
420
|
+
if (systemInjection && rawBodyStr) {
|
|
421
|
+
const result = injectIntoRawBody(rawBodyStr, '\n\n' + systemInjection);
|
|
422
|
+
rawBodyStr = result.modified;
|
|
423
|
+
systemInjectionSize = systemInjection.length;
|
|
424
|
+
systemSuccess = result.success;
|
|
425
|
+
}
|
|
426
|
+
// 2. Inject dynamic content into LAST USER MESSAGE (delta only)
|
|
427
|
+
if (userMsgInjection && rawBodyStr) {
|
|
428
|
+
rawBodyStr = appendToLastUserMessage(rawBodyStr, userMsgInjection);
|
|
429
|
+
userMsgInjectionSize = userMsgInjection.length;
|
|
430
|
+
userMsgSuccess = true; // appendToLastUserMessage doesn't return success flag
|
|
431
|
+
}
|
|
432
|
+
// Determine final body to send
|
|
433
|
+
let finalBodyToSend;
|
|
434
|
+
if (systemInjection || userMsgInjection) {
|
|
435
|
+
finalBodyToSend = rawBodyStr;
|
|
436
|
+
// Log INJECTION to file with full details
|
|
437
|
+
const wasCached = processedBody.__grovInjectionCached;
|
|
438
|
+
proxyLog({
|
|
439
|
+
requestId: currentRequestId,
|
|
440
|
+
type: 'INJECTION',
|
|
441
|
+
sessionId: sessionInfo.sessionId.substring(0, 8),
|
|
442
|
+
data: {
|
|
443
|
+
systemInjectionSize,
|
|
444
|
+
userMsgInjectionSize,
|
|
445
|
+
totalInjectionSize: systemInjectionSize + userMsgInjectionSize,
|
|
446
|
+
originalSize: rawBody?.length || 0,
|
|
447
|
+
finalSize: rawBodyStr.length,
|
|
448
|
+
systemSuccess,
|
|
449
|
+
userMsgSuccess,
|
|
450
|
+
teamMemoryCached: wasCached,
|
|
451
|
+
// Include actual content for debugging (truncated for log readability)
|
|
452
|
+
systemInjectionPreview: systemInjection ? systemInjection.substring(0, 200) + (systemInjection.length > 200 ? '...' : '') : null,
|
|
453
|
+
userMsgInjectionContent: userMsgInjection || null, // Full content since it's small
|
|
454
|
+
},
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
else if (rawBody) {
|
|
458
|
+
// No injection, use original raw bytes
|
|
459
|
+
finalBodyToSend = rawBody;
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
// Fallback to re-serialization (shouldn't happen normally)
|
|
463
|
+
finalBodyToSend = JSON.stringify(processedBody);
|
|
464
|
+
}
|
|
465
|
+
const forwardStart = Date.now();
|
|
125
466
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
467
|
+
// Forward: raw bytes (with injection inserted) or original raw bytes
|
|
468
|
+
const result = await forwardToAnthropic(processedBody, request.headers, logger, typeof finalBodyToSend === 'string' ? Buffer.from(finalBodyToSend, 'utf-8') : finalBodyToSend);
|
|
469
|
+
const forwardLatency = Date.now() - forwardStart;
|
|
470
|
+
// FIRE-AND-FORGET: Don't block response to Claude Code
|
|
471
|
+
// This prevents retry loops caused by Haiku calls adding latency
|
|
128
472
|
if (result.statusCode === 200 && isAnthropicResponse(result.body)) {
|
|
129
|
-
|
|
473
|
+
postProcessResponse(result.body, sessionInfo, request.body, logger)
|
|
474
|
+
.catch(err => console.error('[GROV] postProcess error:', err));
|
|
130
475
|
}
|
|
131
|
-
// Return response to Claude Code (unmodified)
|
|
132
476
|
const latency = Date.now() - startTime;
|
|
477
|
+
const filteredHeaders = filterResponseHeaders(result.headers);
|
|
478
|
+
// Log token usage (always to console, file only in debug mode)
|
|
479
|
+
if (isAnthropicResponse(result.body)) {
|
|
480
|
+
const usage = extractTokenUsage(result.body);
|
|
481
|
+
// Console: compact token summary (always shown)
|
|
482
|
+
logTokenUsage(currentRequestId, usage, latency);
|
|
483
|
+
// File: detailed response log (debug mode only)
|
|
484
|
+
proxyLog({
|
|
485
|
+
requestId: currentRequestId,
|
|
486
|
+
type: 'RESPONSE',
|
|
487
|
+
sessionId: sessionInfo.sessionId.substring(0, 8),
|
|
488
|
+
data: {
|
|
489
|
+
statusCode: result.statusCode,
|
|
490
|
+
latencyMs: latency,
|
|
491
|
+
forwardLatencyMs: forwardLatency,
|
|
492
|
+
inputTokens: usage.inputTokens,
|
|
493
|
+
outputTokens: usage.outputTokens,
|
|
494
|
+
cacheCreation: usage.cacheCreation,
|
|
495
|
+
cacheRead: usage.cacheRead,
|
|
496
|
+
cacheHitRatio: usage.cacheRead > 0 ? (usage.cacheRead / (usage.cacheRead + usage.cacheCreation)).toFixed(2) : '0.00',
|
|
497
|
+
wasSSE: result.wasSSE,
|
|
498
|
+
},
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
// If response was SSE, forward raw SSE to Claude Code (it expects streaming)
|
|
502
|
+
// Otherwise, send JSON
|
|
503
|
+
const isSSEResponse = result.wasSSE;
|
|
504
|
+
const responseContentType = isSSEResponse ? 'text/event-stream; charset=utf-8' : 'application/json';
|
|
505
|
+
const responseBody = isSSEResponse ? result.rawBody : JSON.stringify(result.body);
|
|
133
506
|
logger.info({
|
|
134
507
|
msg: 'Request complete',
|
|
135
508
|
statusCode: result.statusCode,
|
|
136
509
|
latencyMs: latency,
|
|
510
|
+
wasSSE: isSSEResponse,
|
|
137
511
|
});
|
|
138
512
|
return reply
|
|
139
513
|
.status(result.statusCode)
|
|
140
|
-
.header('content-type',
|
|
141
|
-
.headers(
|
|
142
|
-
.send(
|
|
514
|
+
.header('content-type', responseContentType)
|
|
515
|
+
.headers(filteredHeaders)
|
|
516
|
+
.send(responseBody);
|
|
143
517
|
}
|
|
144
518
|
catch (error) {
|
|
145
519
|
if (isForwardError(error)) {
|
|
@@ -220,96 +594,130 @@ async function getOrCreateSession(request, logger) {
|
|
|
220
594
|
logger.info({ msg: 'No existing session, will create after task analysis' });
|
|
221
595
|
return { ...sessionInfo, isNew: true, currentSession: null, completedSession };
|
|
222
596
|
}
|
|
597
|
+
/**
|
|
598
|
+
* Detect request type: 'first', 'continuation', or 'retry'
|
|
599
|
+
* - first: new user message (messageCount changed, last msg is user without tool_result)
|
|
600
|
+
* - continuation: tool result (messageCount changed, last msg has tool_result)
|
|
601
|
+
* - retry: same messageCount as before
|
|
602
|
+
*/
|
|
603
|
+
function detectRequestType(messages, sessionId) {
|
|
604
|
+
const currentCount = messages?.length || 0;
|
|
605
|
+
const lastCount = lastMessageCount.get(sessionId);
|
|
606
|
+
lastMessageCount.set(sessionId, currentCount);
|
|
607
|
+
// Same messageCount = retry
|
|
608
|
+
if (lastCount !== undefined && currentCount === lastCount) {
|
|
609
|
+
return 'retry';
|
|
610
|
+
}
|
|
611
|
+
// No messages or no last message = first
|
|
612
|
+
if (!messages || messages.length === 0)
|
|
613
|
+
return 'first';
|
|
614
|
+
const lastMessage = messages[messages.length - 1];
|
|
615
|
+
// Check if last message is tool_result (continuation)
|
|
616
|
+
if (lastMessage.role === 'user') {
|
|
617
|
+
const content = lastMessage.content;
|
|
618
|
+
if (Array.isArray(content)) {
|
|
619
|
+
const hasToolResult = content.some((block) => typeof block === 'object' && block !== null && block.type === 'tool_result');
|
|
620
|
+
if (hasToolResult)
|
|
621
|
+
return 'continuation';
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return 'first';
|
|
625
|
+
}
|
|
223
626
|
/**
|
|
224
627
|
* Pre-process request before forwarding
|
|
225
|
-
* - Context injection
|
|
226
|
-
* - CLEAR operation
|
|
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
|
|
227
633
|
*/
|
|
228
634
|
async function preProcessRequest(body, sessionInfo, logger) {
|
|
229
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
|
|
230
651
|
const sessionState = getSessionState(sessionInfo.sessionId);
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
if ((sessionState.token_count || 0) > config.TOKEN_CLEAR_THRESHOLD) {
|
|
238
|
-
logger.info({
|
|
239
|
-
msg: 'Token threshold exceeded, initiating CLEAR',
|
|
240
|
-
tokenCount: sessionState.token_count,
|
|
241
|
-
threshold: config.TOKEN_CLEAR_THRESHOLD,
|
|
242
|
-
});
|
|
243
|
-
// Generate summary from session state + steps
|
|
244
|
-
let summary;
|
|
245
|
-
if (isSummaryAvailable()) {
|
|
246
|
-
const steps = getValidatedSteps(sessionInfo.sessionId);
|
|
247
|
-
summary = await generateSessionSummary(sessionState, steps);
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
250
|
-
const files = getValidatedSteps(sessionInfo.sessionId).flatMap(s => s.files);
|
|
251
|
-
summary = `PREVIOUS SESSION CONTEXT:
|
|
252
|
-
Goal: ${sessionState.original_goal || 'Not specified'}
|
|
253
|
-
Files worked on: ${[...new Set(files)].slice(0, 10).join(', ') || 'None'}
|
|
254
|
-
Please continue from where you left off.`;
|
|
255
|
-
}
|
|
256
|
-
// Clear messages and inject summary
|
|
257
|
-
modified.messages = [];
|
|
258
|
-
appendToSystemPrompt(modified, '\n\n' + summary);
|
|
259
|
-
// Update session state
|
|
260
|
-
markCleared(sessionInfo.sessionId);
|
|
261
|
-
logger.info({
|
|
262
|
-
msg: 'CLEAR completed',
|
|
263
|
-
summaryLength: summary.length,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
// Check if session is in drifted or forced mode
|
|
267
|
-
if (sessionState.session_mode === 'drifted' || sessionState.session_mode === 'forced') {
|
|
268
|
-
const recentSteps = getRecentSteps(sessionInfo.sessionId, 5);
|
|
269
|
-
// FORCED MODE: escalation >= 3 -> Haiku generates recovery prompt
|
|
270
|
-
if (sessionState.escalation_count >= 3 || sessionState.session_mode === 'forced') {
|
|
271
|
-
// Update mode to forced if not already
|
|
272
|
-
if (sessionState.session_mode !== 'forced') {
|
|
273
|
-
updateSessionMode(sessionInfo.sessionId, 'forced');
|
|
274
|
-
}
|
|
275
|
-
const lastDrift = lastDriftResults.get(sessionInfo.sessionId);
|
|
276
|
-
const driftResult = lastDrift || await checkDrift({ sessionState, recentSteps, latestUserMessage });
|
|
277
|
-
const forcedRecovery = await generateForcedRecovery(sessionState, recentSteps.map(s => ({ actionType: s.action_type, files: s.files })), driftResult);
|
|
278
|
-
appendToSystemPrompt(modified, forcedRecovery.injectionText);
|
|
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) {
|
|
279
658
|
logger.info({
|
|
280
|
-
msg: '
|
|
281
|
-
|
|
282
|
-
|
|
659
|
+
msg: 'CLEAR MODE ACTIVATED - resetting conversation',
|
|
660
|
+
tokenCount: currentTokenCount,
|
|
661
|
+
threshold: config.TOKEN_CLEAR_THRESHOLD,
|
|
662
|
+
summaryLength: sessionState.pending_clear_summary.length,
|
|
283
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
|
|
284
677
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 });
|
|
299
700
|
}
|
|
300
701
|
}
|
|
301
|
-
//
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
+
}
|
|
310
720
|
}
|
|
311
|
-
// Log final system prompt size
|
|
312
|
-
const finalSystemSize = getSystemPromptText(modified).length;
|
|
313
721
|
return modified;
|
|
314
722
|
}
|
|
315
723
|
/**
|
|
@@ -635,19 +1043,81 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
|
|
|
635
1043
|
activeSessionId = sessionInfo.currentSession.session_id;
|
|
636
1044
|
}
|
|
637
1045
|
}
|
|
1046
|
+
// AUTO-SAVE on every end_turn (for all task types: new_task, continue, subtask, parallel)
|
|
1047
|
+
// task_complete and subtask_complete already save and return early, so they won't reach here
|
|
1048
|
+
if (isEndTurn && activeSession && activeSessionId) {
|
|
1049
|
+
try {
|
|
1050
|
+
await saveToTeamMemory(activeSessionId, 'complete');
|
|
1051
|
+
markSessionCompleted(activeSessionId);
|
|
1052
|
+
activeSessions.delete(activeSessionId);
|
|
1053
|
+
logger.info({ msg: 'Auto-saved task on end_turn', sessionId: activeSessionId.substring(0, 8) });
|
|
1054
|
+
}
|
|
1055
|
+
catch (err) {
|
|
1056
|
+
logger.info({ msg: 'Auto-save failed', error: String(err) });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
638
1059
|
// Extract token usage
|
|
639
1060
|
const usage = extractTokenUsage(response);
|
|
1061
|
+
// Use cache metrics as actual context size (cacheCreation + cacheRead)
|
|
1062
|
+
// This is what Anthropic bills for and what determines CLEAR threshold
|
|
1063
|
+
const actualContextSize = usage.cacheCreation + usage.cacheRead;
|
|
640
1064
|
if (activeSession) {
|
|
641
|
-
|
|
1065
|
+
// Set to actual context size (not cumulative - context size IS the total)
|
|
1066
|
+
updateTokenCount(activeSessionId, actualContextSize);
|
|
642
1067
|
}
|
|
643
1068
|
logger.info({
|
|
644
1069
|
msg: 'Token usage',
|
|
645
1070
|
input: usage.inputTokens,
|
|
646
1071
|
output: usage.outputTokens,
|
|
647
1072
|
total: usage.totalTokens,
|
|
1073
|
+
cacheCreation: usage.cacheCreation,
|
|
1074
|
+
cacheRead: usage.cacheRead,
|
|
1075
|
+
actualContextSize,
|
|
648
1076
|
activeSession: activeSessionId.substring(0, 8),
|
|
649
1077
|
});
|
|
1078
|
+
// === CLEAR MODE PRE-COMPUTE (85% threshold) ===
|
|
1079
|
+
// Pre-compute summary before hitting 100% threshold to avoid blocking Haiku call
|
|
1080
|
+
const preComputeThreshold = Math.floor(config.TOKEN_CLEAR_THRESHOLD * 0.85);
|
|
1081
|
+
// Use actualContextSize (cacheCreation + cacheRead) as the real context size
|
|
1082
|
+
if (activeSession &&
|
|
1083
|
+
actualContextSize > preComputeThreshold &&
|
|
1084
|
+
!activeSession.pending_clear_summary &&
|
|
1085
|
+
isSummaryAvailable()) {
|
|
1086
|
+
// Get all validated steps for comprehensive summary
|
|
1087
|
+
const allSteps = getValidatedSteps(activeSessionId);
|
|
1088
|
+
// Generate summary asynchronously (fire-and-forget)
|
|
1089
|
+
generateSessionSummary(activeSession, allSteps, 15000).then(summary => {
|
|
1090
|
+
updateSessionState(activeSessionId, { pending_clear_summary: summary });
|
|
1091
|
+
logger.info({
|
|
1092
|
+
msg: 'CLEAR summary pre-computed',
|
|
1093
|
+
actualContextSize,
|
|
1094
|
+
threshold: preComputeThreshold,
|
|
1095
|
+
summaryLength: summary.length,
|
|
1096
|
+
});
|
|
1097
|
+
}).catch(err => {
|
|
1098
|
+
logger.info({ msg: 'CLEAR summary generation failed', error: String(err) });
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
// Capture final_response for ALL end_turn responses (not just Q&A)
|
|
1102
|
+
// This preserves Claude's analysis even when tools were used
|
|
1103
|
+
if (isEndTurn && textContent.length > 100 && activeSessionId) {
|
|
1104
|
+
updateSessionState(activeSessionId, {
|
|
1105
|
+
final_response: textContent.substring(0, 10000),
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
650
1108
|
if (actions.length === 0) {
|
|
1109
|
+
// Pure Q&A (no tool calls) - auto-save as task
|
|
1110
|
+
if (isEndTurn && activeSessionId && activeSession) {
|
|
1111
|
+
try {
|
|
1112
|
+
await saveToTeamMemory(activeSessionId, 'complete');
|
|
1113
|
+
markSessionCompleted(activeSessionId);
|
|
1114
|
+
activeSessions.delete(activeSessionId);
|
|
1115
|
+
logger.info({ msg: 'Task saved on final answer', sessionId: activeSessionId.substring(0, 8) });
|
|
1116
|
+
}
|
|
1117
|
+
catch (err) {
|
|
1118
|
+
logger.info({ msg: 'Task save failed', error: String(err) });
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
651
1121
|
return;
|
|
652
1122
|
}
|
|
653
1123
|
logger.info({
|
|
@@ -705,11 +1175,51 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
|
|
|
705
1175
|
updateSessionMode(activeSessionId, 'drifted');
|
|
706
1176
|
markWaitingForRecovery(activeSessionId, true);
|
|
707
1177
|
incrementEscalation(activeSessionId);
|
|
1178
|
+
// Pre-compute correction for next request (fire-and-forget pattern)
|
|
1179
|
+
// This avoids blocking Haiku calls in preProcessRequest
|
|
1180
|
+
const correction = buildCorrection(driftResult, activeSession, correctionLevel);
|
|
1181
|
+
const correctionText = formatCorrectionForInjection(correction);
|
|
1182
|
+
updateSessionState(activeSessionId, { pending_correction: correctionText });
|
|
1183
|
+
logger.info({
|
|
1184
|
+
msg: 'Pre-computed correction saved',
|
|
1185
|
+
level: correctionLevel,
|
|
1186
|
+
correctionLength: correctionText.length,
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
else if (correctionLevel) {
|
|
1190
|
+
// Nudge or correct level - still save correction but don't change mode
|
|
1191
|
+
const correction = buildCorrection(driftResult, activeSession, correctionLevel);
|
|
1192
|
+
const correctionText = formatCorrectionForInjection(correction);
|
|
1193
|
+
updateSessionState(activeSessionId, { pending_correction: correctionText });
|
|
1194
|
+
logger.info({
|
|
1195
|
+
msg: 'Pre-computed mild correction saved',
|
|
1196
|
+
level: correctionLevel,
|
|
1197
|
+
});
|
|
708
1198
|
}
|
|
709
1199
|
else if (driftScore >= 8) {
|
|
710
1200
|
updateSessionMode(activeSessionId, 'normal');
|
|
711
1201
|
markWaitingForRecovery(activeSessionId, false);
|
|
712
1202
|
lastDriftResults.delete(activeSessionId);
|
|
1203
|
+
// Clear any pending correction since drift is resolved
|
|
1204
|
+
updateSessionState(activeSessionId, { pending_correction: undefined });
|
|
1205
|
+
}
|
|
1206
|
+
// FORCED MODE: escalation >= 3 triggers Haiku-generated recovery
|
|
1207
|
+
const currentEscalation = activeSession.escalation_count || 0;
|
|
1208
|
+
if (currentEscalation >= 3 && driftScore < 8) {
|
|
1209
|
+
updateSessionMode(activeSessionId, 'forced');
|
|
1210
|
+
// Generate forced recovery asynchronously (fire-and-forget within fire-and-forget)
|
|
1211
|
+
generateForcedRecovery(activeSession, recentSteps.map(s => ({ actionType: s.action_type, files: s.files })), driftResult).then(forcedRecovery => {
|
|
1212
|
+
updateSessionState(activeSessionId, {
|
|
1213
|
+
pending_forced_recovery: forcedRecovery.injectionText,
|
|
1214
|
+
});
|
|
1215
|
+
logger.info({
|
|
1216
|
+
msg: 'Pre-computed forced recovery saved',
|
|
1217
|
+
escalation: currentEscalation,
|
|
1218
|
+
mandatoryAction: forcedRecovery.mandatoryAction?.substring(0, 50),
|
|
1219
|
+
});
|
|
1220
|
+
}).catch(err => {
|
|
1221
|
+
logger.info({ msg: 'Forced recovery generation failed', error: String(err) });
|
|
1222
|
+
});
|
|
713
1223
|
}
|
|
714
1224
|
updateLastChecked(activeSessionId, Date.now());
|
|
715
1225
|
if (skipSteps) {
|
|
@@ -733,6 +1243,8 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
|
|
|
733
1243
|
}
|
|
734
1244
|
// Save each action as a step (with reasoning from Claude's text)
|
|
735
1245
|
for (const action of actions) {
|
|
1246
|
+
// Detect key decisions based on action type and reasoning content
|
|
1247
|
+
const isKeyDecision = detectKeyDecision(action, textContent);
|
|
736
1248
|
createStep({
|
|
737
1249
|
session_id: activeSessionId,
|
|
738
1250
|
action_type: action.actionType,
|
|
@@ -742,8 +1254,44 @@ async function postProcessResponse(response, sessionInfo, requestBody, logger) {
|
|
|
742
1254
|
reasoning: textContent.substring(0, 1000), // Claude's explanation (truncated)
|
|
743
1255
|
drift_score: driftScore,
|
|
744
1256
|
is_validated: !skipSteps,
|
|
1257
|
+
is_key_decision: isKeyDecision,
|
|
745
1258
|
});
|
|
1259
|
+
if (isKeyDecision) {
|
|
1260
|
+
logger.info({
|
|
1261
|
+
msg: 'Key decision detected',
|
|
1262
|
+
actionType: action.actionType,
|
|
1263
|
+
files: action.files.slice(0, 3),
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
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;
|
|
746
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;
|
|
747
1295
|
}
|
|
748
1296
|
/**
|
|
749
1297
|
* Extract text content from response for analysis
|
|
@@ -820,36 +1368,34 @@ function extractProjectPath(body) {
|
|
|
820
1368
|
return null;
|
|
821
1369
|
}
|
|
822
1370
|
/**
|
|
823
|
-
* Extract goal from
|
|
824
|
-
*
|
|
1371
|
+
* Extract goal from FIRST user message with text content
|
|
1372
|
+
* Skips tool_result blocks, filters out system-reminder tags
|
|
825
1373
|
*/
|
|
826
1374
|
function extractGoalFromMessages(messages) {
|
|
827
|
-
// Find the LAST user message (most recent prompt)
|
|
828
1375
|
const userMessages = messages?.filter(m => m.role === 'user') || [];
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
.
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
return cleanContent.substring(0, 500);
|
|
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;
|
|
853
1399
|
}
|
|
854
1400
|
/**
|
|
855
1401
|
* Filter response headers for forwarding to client
|
|
@@ -859,10 +1405,16 @@ function filterResponseHeaders(headers) {
|
|
|
859
1405
|
const allowedHeaders = [
|
|
860
1406
|
'content-type',
|
|
861
1407
|
'x-request-id',
|
|
1408
|
+
'request-id',
|
|
1409
|
+
'x-should-retry',
|
|
1410
|
+
'retry-after',
|
|
1411
|
+
'retry-after-ms',
|
|
862
1412
|
'anthropic-ratelimit-requests-limit',
|
|
863
1413
|
'anthropic-ratelimit-requests-remaining',
|
|
1414
|
+
'anthropic-ratelimit-requests-reset',
|
|
864
1415
|
'anthropic-ratelimit-tokens-limit',
|
|
865
1416
|
'anthropic-ratelimit-tokens-remaining',
|
|
1417
|
+
'anthropic-ratelimit-tokens-reset',
|
|
866
1418
|
];
|
|
867
1419
|
for (const header of allowedHeaders) {
|
|
868
1420
|
const value = headers[header];
|
|
@@ -885,19 +1437,23 @@ function isAnthropicResponse(body) {
|
|
|
885
1437
|
}
|
|
886
1438
|
/**
|
|
887
1439
|
* Start the proxy server
|
|
1440
|
+
* @param options.debug - Enable debug logging to grov-proxy.log
|
|
888
1441
|
*/
|
|
889
|
-
export async function startServer() {
|
|
1442
|
+
export async function startServer(options = {}) {
|
|
1443
|
+
// Set debug mode based on flag
|
|
1444
|
+
if (options.debug) {
|
|
1445
|
+
setDebugMode(true);
|
|
1446
|
+
console.log('[DEBUG] Logging to grov-proxy.log');
|
|
1447
|
+
}
|
|
890
1448
|
const server = createServer();
|
|
891
1449
|
// Cleanup old completed sessions (older than 24 hours)
|
|
892
|
-
|
|
893
|
-
if (cleanedUp > 0) {
|
|
894
|
-
}
|
|
1450
|
+
cleanupOldCompletedSessions();
|
|
895
1451
|
try {
|
|
896
1452
|
await server.listen({
|
|
897
1453
|
host: config.HOST,
|
|
898
1454
|
port: config.PORT,
|
|
899
1455
|
});
|
|
900
|
-
console.log(
|
|
1456
|
+
console.log(`Grov Proxy: http://${config.HOST}:${config.PORT} -> ${config.ANTHROPIC_BASE_URL}`);
|
|
901
1457
|
return server;
|
|
902
1458
|
}
|
|
903
1459
|
catch (err) {
|