grov 0.1.2 → 0.2.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 +66 -87
- package/dist/cli.js +23 -37
- package/dist/commands/capture.js +1 -1
- package/dist/commands/disable.d.ts +1 -0
- package/dist/commands/disable.js +14 -0
- package/dist/commands/drift-test.js +56 -68
- package/dist/commands/init.js +29 -17
- package/dist/commands/proxy-status.d.ts +1 -0
- package/dist/commands/proxy-status.js +32 -0
- package/dist/commands/unregister.js +7 -1
- package/dist/lib/correction-builder-proxy.d.ts +16 -0
- package/dist/lib/correction-builder-proxy.js +125 -0
- package/dist/lib/correction-builder.js +1 -1
- package/dist/lib/drift-checker-proxy.d.ts +63 -0
- package/dist/lib/drift-checker-proxy.js +373 -0
- package/dist/lib/drift-checker.js +1 -1
- package/dist/lib/hooks.d.ts +11 -0
- package/dist/lib/hooks.js +33 -0
- package/dist/lib/llm-extractor.d.ts +60 -11
- package/dist/lib/llm-extractor.js +419 -98
- package/dist/lib/settings.d.ts +19 -0
- package/dist/lib/settings.js +63 -0
- package/dist/lib/store.d.ts +201 -43
- package/dist/lib/store.js +653 -90
- package/dist/proxy/action-parser.d.ts +58 -0
- package/dist/proxy/action-parser.js +196 -0
- package/dist/proxy/config.d.ts +26 -0
- package/dist/proxy/config.js +67 -0
- package/dist/proxy/forwarder.d.ts +24 -0
- package/dist/proxy/forwarder.js +119 -0
- package/dist/proxy/index.d.ts +1 -0
- package/dist/proxy/index.js +30 -0
- package/dist/proxy/request-processor.d.ts +12 -0
- package/dist/proxy/request-processor.js +94 -0
- package/dist/proxy/response-processor.d.ts +14 -0
- package/dist/proxy/response-processor.js +128 -0
- package/dist/proxy/server.d.ts +9 -0
- package/dist/proxy/server.js +911 -0
- package/package.json +8 -3
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
// Grov Proxy Server - Fastify + undici
|
|
2
|
+
// Intercepts Claude Code <-> Anthropic API traffic for drift detection and context injection
|
|
3
|
+
import Fastify from 'fastify';
|
|
4
|
+
import { config } from './config.js';
|
|
5
|
+
import { forwardToAnthropic, isForwardError } from './forwarder.js';
|
|
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';
|
|
8
|
+
import { checkDrift, scoreToCorrectionLevel, shouldSkipSteps, isDriftCheckAvailable, checkRecoveryAlignment, generateForcedRecovery, } from '../lib/drift-checker-proxy.js';
|
|
9
|
+
import { buildCorrection, formatCorrectionForInjection } from '../lib/correction-builder-proxy.js';
|
|
10
|
+
import { generateSessionSummary, isSummaryAvailable, extractIntent, isIntentExtractionAvailable, analyzeTaskContext, isTaskAnalysisAvailable, } from '../lib/llm-extractor.js';
|
|
11
|
+
import { buildTeamMemoryContext, extractFilesFromMessages } from './request-processor.js';
|
|
12
|
+
import { saveToTeamMemory } from './response-processor.js';
|
|
13
|
+
import { randomUUID } from 'crypto';
|
|
14
|
+
// Store last drift result for recovery alignment check
|
|
15
|
+
const lastDriftResults = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* Helper to append text to system prompt (handles string or array format)
|
|
18
|
+
*/
|
|
19
|
+
function appendToSystemPrompt(body, textToAppend) {
|
|
20
|
+
if (typeof body.system === 'string') {
|
|
21
|
+
body.system = body.system + textToAppend;
|
|
22
|
+
}
|
|
23
|
+
else if (Array.isArray(body.system)) {
|
|
24
|
+
// Append as new text block
|
|
25
|
+
body.system.push({ type: 'text', text: textToAppend });
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
// No system prompt yet, create as string
|
|
29
|
+
body.system = textToAppend;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get system prompt as string (for reading)
|
|
34
|
+
*/
|
|
35
|
+
function getSystemPromptText(body) {
|
|
36
|
+
if (typeof body.system === 'string') {
|
|
37
|
+
return body.system;
|
|
38
|
+
}
|
|
39
|
+
else if (Array.isArray(body.system)) {
|
|
40
|
+
return body.system
|
|
41
|
+
.filter(block => block.type === 'text')
|
|
42
|
+
.map(block => block.text)
|
|
43
|
+
.join('\n');
|
|
44
|
+
}
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
// Session tracking (in-memory for active sessions)
|
|
48
|
+
const activeSessions = new Map();
|
|
49
|
+
/**
|
|
50
|
+
* Create and configure the Fastify server
|
|
51
|
+
*/
|
|
52
|
+
export function createServer() {
|
|
53
|
+
const fastify = Fastify({
|
|
54
|
+
logger: {
|
|
55
|
+
level: 'error', // Only errors in console, details in file
|
|
56
|
+
},
|
|
57
|
+
bodyLimit: config.BODY_LIMIT,
|
|
58
|
+
});
|
|
59
|
+
// Health check endpoint
|
|
60
|
+
fastify.get('/health', async () => {
|
|
61
|
+
return { status: 'ok', timestamp: new Date().toISOString() };
|
|
62
|
+
});
|
|
63
|
+
// Main messages endpoint
|
|
64
|
+
fastify.post('/v1/messages', {
|
|
65
|
+
config: {
|
|
66
|
+
rawBody: true,
|
|
67
|
+
},
|
|
68
|
+
}, handleMessages);
|
|
69
|
+
// Catch-all for other Anthropic endpoints (pass through)
|
|
70
|
+
fastify.all('/*', async (request, reply) => {
|
|
71
|
+
fastify.log.warn(`Unhandled endpoint: ${request.method} ${request.url}`);
|
|
72
|
+
return reply.status(404).send({ error: 'Not found' });
|
|
73
|
+
});
|
|
74
|
+
return fastify;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Handle /v1/messages requests
|
|
78
|
+
*/
|
|
79
|
+
async function handleMessages(request, reply) {
|
|
80
|
+
const logger = request.log;
|
|
81
|
+
const startTime = Date.now();
|
|
82
|
+
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
|
+
if (model.includes('haiku')) {
|
|
87
|
+
logger.info({ msg: 'Skipping Haiku subagent', model });
|
|
88
|
+
try {
|
|
89
|
+
const result = await forwardToAnthropic(request.body, request.headers, logger);
|
|
90
|
+
const latency = Date.now() - startTime;
|
|
91
|
+
return reply
|
|
92
|
+
.status(result.statusCode)
|
|
93
|
+
.header('content-type', 'application/json')
|
|
94
|
+
.headers(filterResponseHeaders(result.headers))
|
|
95
|
+
.send(JSON.stringify(result.body));
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
logger.error({ msg: 'Haiku forward error', error: String(error) });
|
|
99
|
+
return reply
|
|
100
|
+
.status(502)
|
|
101
|
+
.header('content-type', 'application/json')
|
|
102
|
+
.send(JSON.stringify({ error: { type: 'proxy_error', message: 'Bad gateway' } }));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// === MAIN MODEL TRACKING (Opus/Sonnet) ===
|
|
106
|
+
// Get or create session (async for intent extraction)
|
|
107
|
+
const sessionInfo = await getOrCreateSession(request, logger);
|
|
108
|
+
sessionInfo.promptCount++;
|
|
109
|
+
// Update in-memory map
|
|
110
|
+
activeSessions.set(sessionInfo.sessionId, {
|
|
111
|
+
sessionId: sessionInfo.sessionId,
|
|
112
|
+
promptCount: sessionInfo.promptCount,
|
|
113
|
+
projectPath: sessionInfo.projectPath,
|
|
114
|
+
});
|
|
115
|
+
logger.info({
|
|
116
|
+
msg: 'Incoming request',
|
|
117
|
+
sessionId: sessionInfo.sessionId.substring(0, 8),
|
|
118
|
+
promptCount: sessionInfo.promptCount,
|
|
119
|
+
model: request.body.model,
|
|
120
|
+
messageCount: request.body.messages?.length || 0,
|
|
121
|
+
});
|
|
122
|
+
// === PRE-HANDLER: Modify request if needed ===
|
|
123
|
+
const modifiedBody = await preProcessRequest(request.body, sessionInfo, logger);
|
|
124
|
+
// === FORWARD TO ANTHROPIC ===
|
|
125
|
+
try {
|
|
126
|
+
const result = await forwardToAnthropic(modifiedBody, request.headers, logger);
|
|
127
|
+
// === POST-HANDLER: Process response with task orchestration ===
|
|
128
|
+
if (result.statusCode === 200 && isAnthropicResponse(result.body)) {
|
|
129
|
+
await postProcessResponse(result.body, sessionInfo, request.body, logger);
|
|
130
|
+
}
|
|
131
|
+
// Return response to Claude Code (unmodified)
|
|
132
|
+
const latency = Date.now() - startTime;
|
|
133
|
+
logger.info({
|
|
134
|
+
msg: 'Request complete',
|
|
135
|
+
statusCode: result.statusCode,
|
|
136
|
+
latencyMs: latency,
|
|
137
|
+
});
|
|
138
|
+
return reply
|
|
139
|
+
.status(result.statusCode)
|
|
140
|
+
.header('content-type', 'application/json')
|
|
141
|
+
.headers(filterResponseHeaders(result.headers))
|
|
142
|
+
.send(JSON.stringify(result.body));
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
if (isForwardError(error)) {
|
|
146
|
+
logger.error({
|
|
147
|
+
msg: 'Forward error',
|
|
148
|
+
type: error.type,
|
|
149
|
+
message: error.message,
|
|
150
|
+
});
|
|
151
|
+
return reply
|
|
152
|
+
.status(error.statusCode || 502)
|
|
153
|
+
.header('content-type', 'application/json')
|
|
154
|
+
.send(JSON.stringify({
|
|
155
|
+
error: {
|
|
156
|
+
type: 'proxy_error',
|
|
157
|
+
message: error.type === 'timeout' ? 'Gateway timeout' : 'Bad gateway',
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
logger.error({
|
|
162
|
+
msg: 'Unexpected error',
|
|
163
|
+
error: String(error),
|
|
164
|
+
});
|
|
165
|
+
return reply
|
|
166
|
+
.status(500)
|
|
167
|
+
.header('content-type', 'application/json')
|
|
168
|
+
.send(JSON.stringify({
|
|
169
|
+
error: {
|
|
170
|
+
type: 'internal_error',
|
|
171
|
+
message: 'Internal proxy error',
|
|
172
|
+
},
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Get or create session info for this request
|
|
178
|
+
*/
|
|
179
|
+
async function getOrCreateSession(request, logger) {
|
|
180
|
+
// Determine project path from request
|
|
181
|
+
const projectPath = extractProjectPath(request.body) || process.cwd();
|
|
182
|
+
// Try to find existing active session for this project
|
|
183
|
+
// Task orchestration will happen in postProcessResponse using analyzeTaskContext
|
|
184
|
+
const existingSession = getActiveSessionForUser(projectPath);
|
|
185
|
+
if (existingSession) {
|
|
186
|
+
// Found active session - will be validated by task orchestration later
|
|
187
|
+
let sessionInfo = activeSessions.get(existingSession.session_id);
|
|
188
|
+
if (!sessionInfo) {
|
|
189
|
+
sessionInfo = {
|
|
190
|
+
sessionId: existingSession.session_id,
|
|
191
|
+
promptCount: 0,
|
|
192
|
+
projectPath,
|
|
193
|
+
};
|
|
194
|
+
activeSessions.set(existingSession.session_id, sessionInfo);
|
|
195
|
+
}
|
|
196
|
+
logger.info({
|
|
197
|
+
msg: 'Found existing session',
|
|
198
|
+
sessionId: existingSession.session_id.substring(0, 8),
|
|
199
|
+
goal: existingSession.original_goal?.substring(0, 50),
|
|
200
|
+
});
|
|
201
|
+
return { ...sessionInfo, isNew: false, currentSession: existingSession, completedSession: null };
|
|
202
|
+
}
|
|
203
|
+
// No active session - check for recently completed session (for new_task detection)
|
|
204
|
+
const completedSession = getCompletedSessionForProject(projectPath);
|
|
205
|
+
if (completedSession) {
|
|
206
|
+
logger.info({
|
|
207
|
+
msg: 'Found recently completed session for comparison',
|
|
208
|
+
sessionId: completedSession.session_id.substring(0, 8),
|
|
209
|
+
goal: completedSession.original_goal?.substring(0, 50),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
// No existing session - create placeholder, real session will be created in postProcessResponse
|
|
213
|
+
const tempSessionId = randomUUID();
|
|
214
|
+
const sessionInfo = {
|
|
215
|
+
sessionId: tempSessionId,
|
|
216
|
+
promptCount: 0,
|
|
217
|
+
projectPath,
|
|
218
|
+
};
|
|
219
|
+
activeSessions.set(tempSessionId, sessionInfo);
|
|
220
|
+
logger.info({ msg: 'No existing session, will create after task analysis' });
|
|
221
|
+
return { ...sessionInfo, isNew: true, currentSession: null, completedSession };
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Pre-process request before forwarding
|
|
225
|
+
* - Context injection
|
|
226
|
+
* - CLEAR operation
|
|
227
|
+
*/
|
|
228
|
+
async function preProcessRequest(body, sessionInfo, logger) {
|
|
229
|
+
const modified = { ...body };
|
|
230
|
+
const sessionState = getSessionState(sessionInfo.sessionId);
|
|
231
|
+
if (!sessionState) {
|
|
232
|
+
return modified;
|
|
233
|
+
}
|
|
234
|
+
// Extract latest user message for drift checking
|
|
235
|
+
const latestUserMessage = extractGoalFromMessages(body.messages) || '';
|
|
236
|
+
// CLEAR operation if token threshold exceeded
|
|
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);
|
|
279
|
+
logger.info({
|
|
280
|
+
msg: 'FORCED MODE - Injected Haiku recovery prompt',
|
|
281
|
+
escalation: sessionState.escalation_count,
|
|
282
|
+
mandatoryAction: forcedRecovery.mandatoryAction.substring(0, 50),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// DRIFTED MODE: normal correction injection
|
|
287
|
+
const driftResult = await checkDrift({ sessionState, recentSteps, latestUserMessage });
|
|
288
|
+
const correctionLevel = scoreToCorrectionLevel(driftResult.score);
|
|
289
|
+
if (correctionLevel) {
|
|
290
|
+
const correction = buildCorrection(driftResult, sessionState, correctionLevel);
|
|
291
|
+
const correctionText = formatCorrectionForInjection(correction);
|
|
292
|
+
appendToSystemPrompt(modified, correctionText);
|
|
293
|
+
logger.info({
|
|
294
|
+
msg: 'Injected correction',
|
|
295
|
+
level: correctionLevel,
|
|
296
|
+
score: driftResult.score,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// Inject context from team memory
|
|
302
|
+
const mentionedFiles = extractFilesFromMessages(modified.messages || []);
|
|
303
|
+
const teamContext = buildTeamMemoryContext(sessionInfo.projectPath, mentionedFiles);
|
|
304
|
+
if (teamContext) {
|
|
305
|
+
appendToSystemPrompt(modified, '\n\n' + teamContext);
|
|
306
|
+
logger.info({
|
|
307
|
+
msg: 'Injected team memory context',
|
|
308
|
+
filesMatched: mentionedFiles.length,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// Log final system prompt size
|
|
312
|
+
const finalSystemSize = getSystemPromptText(modified).length;
|
|
313
|
+
return modified;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Post-process response after receiving from Anthropic
|
|
317
|
+
* - Task orchestration (new/continue/subtask/complete)
|
|
318
|
+
* - Parse tool_use blocks
|
|
319
|
+
* - Update token count
|
|
320
|
+
* - Save step to DB
|
|
321
|
+
* - Drift check (every N prompts)
|
|
322
|
+
* - Recovery alignment check (Section 4.4)
|
|
323
|
+
* - Team memory triggers (Section 4.6)
|
|
324
|
+
*/
|
|
325
|
+
async function postProcessResponse(response, sessionInfo, requestBody, logger) {
|
|
326
|
+
// Parse tool_use blocks
|
|
327
|
+
const actions = parseToolUseBlocks(response);
|
|
328
|
+
// Extract text content for analysis
|
|
329
|
+
const textContent = extractTextContent(response);
|
|
330
|
+
// Extract latest user message from request
|
|
331
|
+
const latestUserMessage = extractGoalFromMessages(requestBody.messages) || '';
|
|
332
|
+
// Get recent steps for context
|
|
333
|
+
const recentSteps = sessionInfo.currentSession
|
|
334
|
+
? getRecentSteps(sessionInfo.currentSession.session_id, 5)
|
|
335
|
+
: [];
|
|
336
|
+
// === TASK ORCHESTRATION (Part 8) ===
|
|
337
|
+
let activeSessionId = sessionInfo.sessionId;
|
|
338
|
+
let activeSession = sessionInfo.currentSession;
|
|
339
|
+
// Only run task orchestration on end_turn (when Claude finishes responding to user)
|
|
340
|
+
// This reduces Haiku calls from ~11 per prompt to ~1-2
|
|
341
|
+
const isEndTurn = response.stop_reason === 'end_turn';
|
|
342
|
+
// Skip Warmup messages (Claude Code internal initialization)
|
|
343
|
+
const isWarmup = latestUserMessage.toLowerCase().trim() === 'warmup';
|
|
344
|
+
if (isWarmup) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// If not end_turn (tool_use in progress), skip task orchestration but keep session
|
|
348
|
+
if (!isEndTurn) {
|
|
349
|
+
// Use existing session or create minimal one without LLM calls
|
|
350
|
+
if (sessionInfo.currentSession) {
|
|
351
|
+
activeSessionId = sessionInfo.currentSession.session_id;
|
|
352
|
+
activeSession = sessionInfo.currentSession;
|
|
353
|
+
}
|
|
354
|
+
else if (!activeSession) {
|
|
355
|
+
// First request, create session without task analysis
|
|
356
|
+
const newSessionId = randomUUID();
|
|
357
|
+
activeSession = createSessionState({
|
|
358
|
+
session_id: newSessionId,
|
|
359
|
+
project_path: sessionInfo.projectPath,
|
|
360
|
+
original_goal: latestUserMessage.substring(0, 500) || 'Task in progress',
|
|
361
|
+
task_type: 'main',
|
|
362
|
+
});
|
|
363
|
+
activeSessionId = newSessionId;
|
|
364
|
+
activeSessions.set(newSessionId, {
|
|
365
|
+
sessionId: newSessionId,
|
|
366
|
+
promptCount: 1,
|
|
367
|
+
projectPath: sessionInfo.projectPath,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
else if (isTaskAnalysisAvailable()) {
|
|
372
|
+
// Use completed session for comparison if no active session
|
|
373
|
+
const sessionForComparison = sessionInfo.currentSession || sessionInfo.completedSession;
|
|
374
|
+
try {
|
|
375
|
+
const taskAnalysis = await analyzeTaskContext(sessionForComparison, latestUserMessage, recentSteps, textContent);
|
|
376
|
+
logger.info({
|
|
377
|
+
msg: 'Task analysis',
|
|
378
|
+
action: taskAnalysis.action,
|
|
379
|
+
topic_match: taskAnalysis.topic_match,
|
|
380
|
+
goal: taskAnalysis.current_goal?.substring(0, 50),
|
|
381
|
+
reasoning: taskAnalysis.reasoning,
|
|
382
|
+
});
|
|
383
|
+
// Update recent steps with reasoning (backfill from end_turn response)
|
|
384
|
+
if (taskAnalysis.step_reasoning && activeSessionId) {
|
|
385
|
+
const updatedCount = updateRecentStepsReasoning(activeSessionId, taskAnalysis.step_reasoning);
|
|
386
|
+
}
|
|
387
|
+
// Handle task orchestration based on analysis
|
|
388
|
+
switch (taskAnalysis.action) {
|
|
389
|
+
case 'continue':
|
|
390
|
+
// Use existing session or reactivate completed session
|
|
391
|
+
if (sessionInfo.currentSession) {
|
|
392
|
+
activeSessionId = sessionInfo.currentSession.session_id;
|
|
393
|
+
activeSession = sessionInfo.currentSession;
|
|
394
|
+
// Update goal if Haiku detected a new instruction from user
|
|
395
|
+
// (same task/topic, but new specific instruction)
|
|
396
|
+
if (taskAnalysis.current_goal &&
|
|
397
|
+
taskAnalysis.current_goal !== activeSession.original_goal &&
|
|
398
|
+
latestUserMessage.length > 30) {
|
|
399
|
+
updateSessionState(activeSessionId, {
|
|
400
|
+
original_goal: taskAnalysis.current_goal,
|
|
401
|
+
});
|
|
402
|
+
activeSession.original_goal = taskAnalysis.current_goal;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else if (sessionInfo.completedSession) {
|
|
406
|
+
// Reactivate completed session (user wants to continue/add to it)
|
|
407
|
+
activeSessionId = sessionInfo.completedSession.session_id;
|
|
408
|
+
activeSession = sessionInfo.completedSession;
|
|
409
|
+
updateSessionState(activeSessionId, {
|
|
410
|
+
status: 'active',
|
|
411
|
+
original_goal: taskAnalysis.current_goal || activeSession.original_goal,
|
|
412
|
+
});
|
|
413
|
+
activeSession.status = 'active';
|
|
414
|
+
activeSessions.set(activeSessionId, {
|
|
415
|
+
sessionId: activeSessionId,
|
|
416
|
+
promptCount: 1,
|
|
417
|
+
projectPath: sessionInfo.projectPath,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
case 'new_task': {
|
|
422
|
+
// Clean up completed session if it exists (it was kept for comparison)
|
|
423
|
+
if (sessionInfo.completedSession) {
|
|
424
|
+
deleteStepsForSession(sessionInfo.completedSession.session_id);
|
|
425
|
+
deleteSessionState(sessionInfo.completedSession.session_id);
|
|
426
|
+
}
|
|
427
|
+
// Extract full intent for new task (goal, scope, constraints, keywords)
|
|
428
|
+
let intentData = {
|
|
429
|
+
goal: taskAnalysis.current_goal,
|
|
430
|
+
expected_scope: [],
|
|
431
|
+
constraints: [],
|
|
432
|
+
keywords: [],
|
|
433
|
+
};
|
|
434
|
+
if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
|
|
435
|
+
try {
|
|
436
|
+
intentData = await extractIntent(latestUserMessage);
|
|
437
|
+
logger.info({ msg: 'Intent extracted for new task', scopeCount: intentData.expected_scope.length });
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
logger.info({ msg: 'Intent extraction failed, using basic goal', error: String(err) });
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
const newSessionId = randomUUID();
|
|
444
|
+
activeSession = createSessionState({
|
|
445
|
+
session_id: newSessionId,
|
|
446
|
+
project_path: sessionInfo.projectPath,
|
|
447
|
+
original_goal: intentData.goal,
|
|
448
|
+
expected_scope: intentData.expected_scope,
|
|
449
|
+
constraints: intentData.constraints,
|
|
450
|
+
keywords: intentData.keywords,
|
|
451
|
+
task_type: 'main',
|
|
452
|
+
});
|
|
453
|
+
activeSessionId = newSessionId;
|
|
454
|
+
activeSessions.set(newSessionId, {
|
|
455
|
+
sessionId: newSessionId,
|
|
456
|
+
promptCount: 1,
|
|
457
|
+
projectPath: sessionInfo.projectPath,
|
|
458
|
+
});
|
|
459
|
+
logger.info({ msg: 'Created new task session', sessionId: newSessionId.substring(0, 8) });
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
case 'subtask': {
|
|
463
|
+
// Extract intent for subtask
|
|
464
|
+
let intentData = {
|
|
465
|
+
goal: taskAnalysis.current_goal,
|
|
466
|
+
expected_scope: [],
|
|
467
|
+
constraints: [],
|
|
468
|
+
keywords: [],
|
|
469
|
+
};
|
|
470
|
+
if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
|
|
471
|
+
try {
|
|
472
|
+
intentData = await extractIntent(latestUserMessage);
|
|
473
|
+
}
|
|
474
|
+
catch { /* use fallback */ }
|
|
475
|
+
}
|
|
476
|
+
const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
|
|
477
|
+
const subtaskId = randomUUID();
|
|
478
|
+
activeSession = createSessionState({
|
|
479
|
+
session_id: subtaskId,
|
|
480
|
+
project_path: sessionInfo.projectPath,
|
|
481
|
+
original_goal: intentData.goal,
|
|
482
|
+
expected_scope: intentData.expected_scope,
|
|
483
|
+
constraints: intentData.constraints,
|
|
484
|
+
keywords: intentData.keywords,
|
|
485
|
+
task_type: 'subtask',
|
|
486
|
+
parent_session_id: parentId,
|
|
487
|
+
});
|
|
488
|
+
activeSessionId = subtaskId;
|
|
489
|
+
activeSessions.set(subtaskId, {
|
|
490
|
+
sessionId: subtaskId,
|
|
491
|
+
promptCount: 1,
|
|
492
|
+
projectPath: sessionInfo.projectPath,
|
|
493
|
+
});
|
|
494
|
+
logger.info({ msg: 'Created subtask session', sessionId: subtaskId.substring(0, 8), parent: parentId?.substring(0, 8) });
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case 'parallel_task': {
|
|
498
|
+
// Extract intent for parallel task
|
|
499
|
+
let intentData = {
|
|
500
|
+
goal: taskAnalysis.current_goal,
|
|
501
|
+
expected_scope: [],
|
|
502
|
+
constraints: [],
|
|
503
|
+
keywords: [],
|
|
504
|
+
};
|
|
505
|
+
if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
|
|
506
|
+
try {
|
|
507
|
+
intentData = await extractIntent(latestUserMessage);
|
|
508
|
+
}
|
|
509
|
+
catch { /* use fallback */ }
|
|
510
|
+
}
|
|
511
|
+
const parentId = sessionInfo.currentSession?.session_id || taskAnalysis.parent_task_id;
|
|
512
|
+
const parallelId = randomUUID();
|
|
513
|
+
activeSession = createSessionState({
|
|
514
|
+
session_id: parallelId,
|
|
515
|
+
project_path: sessionInfo.projectPath,
|
|
516
|
+
original_goal: intentData.goal,
|
|
517
|
+
expected_scope: intentData.expected_scope,
|
|
518
|
+
constraints: intentData.constraints,
|
|
519
|
+
keywords: intentData.keywords,
|
|
520
|
+
task_type: 'parallel',
|
|
521
|
+
parent_session_id: parentId,
|
|
522
|
+
});
|
|
523
|
+
activeSessionId = parallelId;
|
|
524
|
+
activeSessions.set(parallelId, {
|
|
525
|
+
sessionId: parallelId,
|
|
526
|
+
promptCount: 1,
|
|
527
|
+
projectPath: sessionInfo.projectPath,
|
|
528
|
+
});
|
|
529
|
+
logger.info({ msg: 'Created parallel task session', sessionId: parallelId.substring(0, 8), parent: parentId?.substring(0, 8) });
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
case 'task_complete': {
|
|
533
|
+
// Save to team memory and mark as completed (don't delete yet - keep for new_task detection)
|
|
534
|
+
if (sessionInfo.currentSession) {
|
|
535
|
+
try {
|
|
536
|
+
await saveToTeamMemory(sessionInfo.currentSession.session_id, 'complete');
|
|
537
|
+
markSessionCompleted(sessionInfo.currentSession.session_id);
|
|
538
|
+
activeSessions.delete(sessionInfo.currentSession.session_id);
|
|
539
|
+
lastDriftResults.delete(sessionInfo.currentSession.session_id);
|
|
540
|
+
logger.info({ msg: 'Task complete - saved to team memory, marked completed' });
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
logger.info({ msg: 'Failed to save completed task', error: String(err) });
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return; // Done, no more processing needed
|
|
547
|
+
}
|
|
548
|
+
case 'subtask_complete': {
|
|
549
|
+
// Save subtask and mark completed, return to parent
|
|
550
|
+
if (sessionInfo.currentSession) {
|
|
551
|
+
const parentId = sessionInfo.currentSession.parent_session_id;
|
|
552
|
+
try {
|
|
553
|
+
await saveToTeamMemory(sessionInfo.currentSession.session_id, 'complete');
|
|
554
|
+
markSessionCompleted(sessionInfo.currentSession.session_id);
|
|
555
|
+
activeSessions.delete(sessionInfo.currentSession.session_id);
|
|
556
|
+
lastDriftResults.delete(sessionInfo.currentSession.session_id);
|
|
557
|
+
// Switch to parent session
|
|
558
|
+
if (parentId) {
|
|
559
|
+
const parentSession = getSessionState(parentId);
|
|
560
|
+
if (parentSession) {
|
|
561
|
+
activeSessionId = parentId;
|
|
562
|
+
activeSession = parentSession;
|
|
563
|
+
logger.info({ msg: 'Subtask complete - returning to parent', parent: parentId.substring(0, 8) });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
logger.info({ msg: 'Failed to save completed subtask', error: String(err) });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
logger.info({ msg: 'Task analysis failed, using existing session', error: String(error) });
|
|
577
|
+
// Fall back to existing session or create new with intent extraction
|
|
578
|
+
if (!sessionInfo.currentSession) {
|
|
579
|
+
let intentData = {
|
|
580
|
+
goal: latestUserMessage.substring(0, 500),
|
|
581
|
+
expected_scope: [],
|
|
582
|
+
constraints: [],
|
|
583
|
+
keywords: [],
|
|
584
|
+
};
|
|
585
|
+
if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
|
|
586
|
+
try {
|
|
587
|
+
intentData = await extractIntent(latestUserMessage);
|
|
588
|
+
}
|
|
589
|
+
catch { /* use fallback */ }
|
|
590
|
+
}
|
|
591
|
+
const newSessionId = randomUUID();
|
|
592
|
+
activeSession = createSessionState({
|
|
593
|
+
session_id: newSessionId,
|
|
594
|
+
project_path: sessionInfo.projectPath,
|
|
595
|
+
original_goal: intentData.goal,
|
|
596
|
+
expected_scope: intentData.expected_scope,
|
|
597
|
+
constraints: intentData.constraints,
|
|
598
|
+
keywords: intentData.keywords,
|
|
599
|
+
task_type: 'main',
|
|
600
|
+
});
|
|
601
|
+
activeSessionId = newSessionId;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
// No task analysis available - fallback with intent extraction
|
|
607
|
+
if (!sessionInfo.currentSession) {
|
|
608
|
+
let intentData = {
|
|
609
|
+
goal: latestUserMessage.substring(0, 500),
|
|
610
|
+
expected_scope: [],
|
|
611
|
+
constraints: [],
|
|
612
|
+
keywords: [],
|
|
613
|
+
};
|
|
614
|
+
if (isIntentExtractionAvailable() && latestUserMessage.length > 10) {
|
|
615
|
+
try {
|
|
616
|
+
intentData = await extractIntent(latestUserMessage);
|
|
617
|
+
logger.info({ msg: 'Intent extracted (fallback)', scopeCount: intentData.expected_scope.length });
|
|
618
|
+
}
|
|
619
|
+
catch { /* use fallback */ }
|
|
620
|
+
}
|
|
621
|
+
const newSessionId = randomUUID();
|
|
622
|
+
activeSession = createSessionState({
|
|
623
|
+
session_id: newSessionId,
|
|
624
|
+
project_path: sessionInfo.projectPath,
|
|
625
|
+
original_goal: intentData.goal,
|
|
626
|
+
expected_scope: intentData.expected_scope,
|
|
627
|
+
constraints: intentData.constraints,
|
|
628
|
+
keywords: intentData.keywords,
|
|
629
|
+
task_type: 'main',
|
|
630
|
+
});
|
|
631
|
+
activeSessionId = newSessionId;
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
activeSession = sessionInfo.currentSession;
|
|
635
|
+
activeSessionId = sessionInfo.currentSession.session_id;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// Extract token usage
|
|
639
|
+
const usage = extractTokenUsage(response);
|
|
640
|
+
if (activeSession) {
|
|
641
|
+
updateTokenCount(activeSessionId, usage.totalTokens);
|
|
642
|
+
}
|
|
643
|
+
logger.info({
|
|
644
|
+
msg: 'Token usage',
|
|
645
|
+
input: usage.inputTokens,
|
|
646
|
+
output: usage.outputTokens,
|
|
647
|
+
total: usage.totalTokens,
|
|
648
|
+
activeSession: activeSessionId.substring(0, 8),
|
|
649
|
+
});
|
|
650
|
+
if (actions.length === 0) {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
logger.info({
|
|
654
|
+
msg: 'Actions parsed',
|
|
655
|
+
count: actions.length,
|
|
656
|
+
tools: actions.map(a => a.toolName),
|
|
657
|
+
});
|
|
658
|
+
// Recovery alignment check (Section 4.4)
|
|
659
|
+
if (activeSession && activeSession.waiting_for_recovery) {
|
|
660
|
+
const lastDrift = lastDriftResults.get(activeSessionId);
|
|
661
|
+
const recoveryPlan = lastDrift?.recoverySteps ? { steps: lastDrift.recoverySteps } : undefined;
|
|
662
|
+
for (const action of actions) {
|
|
663
|
+
const alignment = checkRecoveryAlignment({ actionType: action.actionType, files: action.files, command: action.command }, recoveryPlan, activeSession);
|
|
664
|
+
if (alignment.aligned) {
|
|
665
|
+
// Recovered! Reset to normal
|
|
666
|
+
updateSessionMode(activeSessionId, 'normal');
|
|
667
|
+
markWaitingForRecovery(activeSessionId, false);
|
|
668
|
+
updateSessionState(activeSessionId, { escalation_count: 0 });
|
|
669
|
+
lastDriftResults.delete(activeSessionId);
|
|
670
|
+
logger.info({
|
|
671
|
+
msg: 'Recovery alignment SUCCESS - resuming normal mode',
|
|
672
|
+
reason: alignment.reason,
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
incrementEscalation(activeSessionId);
|
|
677
|
+
logger.info({
|
|
678
|
+
msg: 'Recovery alignment FAILED - escalating',
|
|
679
|
+
reason: alignment.reason,
|
|
680
|
+
escalation: activeSession.escalation_count + 1,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// Run drift check every N prompts
|
|
686
|
+
let driftScore;
|
|
687
|
+
let skipSteps = false;
|
|
688
|
+
const memSessionInfo = activeSessions.get(activeSessionId);
|
|
689
|
+
const promptCount = memSessionInfo?.promptCount || sessionInfo.promptCount;
|
|
690
|
+
if (promptCount % config.DRIFT_CHECK_INTERVAL === 0 && isDriftCheckAvailable()) {
|
|
691
|
+
if (activeSession) {
|
|
692
|
+
const stepsForDrift = getRecentSteps(activeSessionId, 10);
|
|
693
|
+
const driftResult = await checkDrift({ sessionState: activeSession, recentSteps: stepsForDrift, latestUserMessage });
|
|
694
|
+
lastDriftResults.set(activeSessionId, driftResult);
|
|
695
|
+
driftScore = driftResult.score;
|
|
696
|
+
skipSteps = shouldSkipSteps(driftScore);
|
|
697
|
+
logger.info({
|
|
698
|
+
msg: 'Drift check',
|
|
699
|
+
score: driftResult.score,
|
|
700
|
+
type: driftResult.driftType,
|
|
701
|
+
diagnostic: driftResult.diagnostic,
|
|
702
|
+
});
|
|
703
|
+
const correctionLevel = scoreToCorrectionLevel(driftScore);
|
|
704
|
+
if (correctionLevel === 'intervene' || correctionLevel === 'halt') {
|
|
705
|
+
updateSessionMode(activeSessionId, 'drifted');
|
|
706
|
+
markWaitingForRecovery(activeSessionId, true);
|
|
707
|
+
incrementEscalation(activeSessionId);
|
|
708
|
+
}
|
|
709
|
+
else if (driftScore >= 8) {
|
|
710
|
+
updateSessionMode(activeSessionId, 'normal');
|
|
711
|
+
markWaitingForRecovery(activeSessionId, false);
|
|
712
|
+
lastDriftResults.delete(activeSessionId);
|
|
713
|
+
}
|
|
714
|
+
updateLastChecked(activeSessionId, Date.now());
|
|
715
|
+
if (skipSteps) {
|
|
716
|
+
for (const action of actions) {
|
|
717
|
+
logDriftEvent({
|
|
718
|
+
session_id: activeSessionId,
|
|
719
|
+
action_type: action.actionType,
|
|
720
|
+
files: action.files,
|
|
721
|
+
drift_score: driftScore,
|
|
722
|
+
drift_reason: driftResult.diagnostic,
|
|
723
|
+
recovery_plan: driftResult.recoverySteps ? { steps: driftResult.recoverySteps } : undefined,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
logger.info({
|
|
727
|
+
msg: 'Actions logged to drift_log (skipped steps)',
|
|
728
|
+
reason: 'score < 5',
|
|
729
|
+
});
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Save each action as a step (with reasoning from Claude's text)
|
|
735
|
+
for (const action of actions) {
|
|
736
|
+
createStep({
|
|
737
|
+
session_id: activeSessionId,
|
|
738
|
+
action_type: action.actionType,
|
|
739
|
+
files: action.files,
|
|
740
|
+
folders: action.folders,
|
|
741
|
+
command: action.command,
|
|
742
|
+
reasoning: textContent.substring(0, 1000), // Claude's explanation (truncated)
|
|
743
|
+
drift_score: driftScore,
|
|
744
|
+
is_validated: !skipSteps,
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Extract text content from response for analysis
|
|
750
|
+
*/
|
|
751
|
+
function extractTextContent(response) {
|
|
752
|
+
return response.content
|
|
753
|
+
.filter((block) => block.type === 'text')
|
|
754
|
+
.map(block => block.text)
|
|
755
|
+
.join('\n');
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Detect task completion from response text
|
|
759
|
+
* Returns trigger type or null
|
|
760
|
+
*/
|
|
761
|
+
function detectTaskCompletion(text) {
|
|
762
|
+
const lowerText = text.toLowerCase();
|
|
763
|
+
// Strong completion indicators
|
|
764
|
+
const completionPhrases = [
|
|
765
|
+
'task is complete',
|
|
766
|
+
'task complete',
|
|
767
|
+
'implementation is complete',
|
|
768
|
+
'implementation complete',
|
|
769
|
+
'successfully implemented',
|
|
770
|
+
'all changes have been made',
|
|
771
|
+
'finished implementing',
|
|
772
|
+
'completed the implementation',
|
|
773
|
+
'done with the implementation',
|
|
774
|
+
'completed all the',
|
|
775
|
+
'all tests pass',
|
|
776
|
+
'build succeeds',
|
|
777
|
+
];
|
|
778
|
+
for (const phrase of completionPhrases) {
|
|
779
|
+
if (lowerText.includes(phrase)) {
|
|
780
|
+
return 'complete';
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
// Subtask completion indicators
|
|
784
|
+
const subtaskPhrases = [
|
|
785
|
+
'step complete',
|
|
786
|
+
'phase complete',
|
|
787
|
+
'finished this step',
|
|
788
|
+
'moving on to',
|
|
789
|
+
'now let\'s',
|
|
790
|
+
'next step',
|
|
791
|
+
];
|
|
792
|
+
for (const phrase of subtaskPhrases) {
|
|
793
|
+
if (lowerText.includes(phrase)) {
|
|
794
|
+
return 'subtask';
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Extract project path from request body
|
|
801
|
+
*/
|
|
802
|
+
function extractProjectPath(body) {
|
|
803
|
+
// Try to extract from system prompt or messages
|
|
804
|
+
// Handle both string and array format for system prompt
|
|
805
|
+
let systemPrompt = '';
|
|
806
|
+
if (typeof body.system === 'string') {
|
|
807
|
+
systemPrompt = body.system;
|
|
808
|
+
}
|
|
809
|
+
else if (Array.isArray(body.system)) {
|
|
810
|
+
// New API format: system is array of {type: 'text', text: '...'}
|
|
811
|
+
systemPrompt = body.system
|
|
812
|
+
.filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
813
|
+
.map(block => block.text)
|
|
814
|
+
.join('\n');
|
|
815
|
+
}
|
|
816
|
+
const cwdMatch = systemPrompt.match(/Working directory:\s*([^\n]+)/);
|
|
817
|
+
if (cwdMatch) {
|
|
818
|
+
return cwdMatch[1].trim();
|
|
819
|
+
}
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Extract goal from LATEST user message (not first!)
|
|
824
|
+
* Filters out system-reminder tags to get the actual user prompt
|
|
825
|
+
*/
|
|
826
|
+
function extractGoalFromMessages(messages) {
|
|
827
|
+
// Find the LAST user message (most recent prompt)
|
|
828
|
+
const userMessages = messages?.filter(m => m.role === 'user') || [];
|
|
829
|
+
const lastUser = userMessages[userMessages.length - 1];
|
|
830
|
+
if (!lastUser)
|
|
831
|
+
return undefined;
|
|
832
|
+
let rawContent = '';
|
|
833
|
+
// Handle string content
|
|
834
|
+
if (typeof lastUser.content === 'string') {
|
|
835
|
+
rawContent = lastUser.content;
|
|
836
|
+
}
|
|
837
|
+
// Handle array content (new API format)
|
|
838
|
+
if (Array.isArray(lastUser.content)) {
|
|
839
|
+
const textBlocks = lastUser.content
|
|
840
|
+
.filter((block) => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string')
|
|
841
|
+
.map(block => block.text);
|
|
842
|
+
rawContent = textBlocks.join('\n');
|
|
843
|
+
}
|
|
844
|
+
// Remove <system-reminder>...</system-reminder> tags
|
|
845
|
+
const cleanContent = rawContent
|
|
846
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '')
|
|
847
|
+
.trim();
|
|
848
|
+
// If nothing left after removing reminders, return undefined
|
|
849
|
+
if (!cleanContent || cleanContent.length < 5) {
|
|
850
|
+
return undefined;
|
|
851
|
+
}
|
|
852
|
+
return cleanContent.substring(0, 500);
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Filter response headers for forwarding to client
|
|
856
|
+
*/
|
|
857
|
+
function filterResponseHeaders(headers) {
|
|
858
|
+
const filtered = {};
|
|
859
|
+
const allowedHeaders = [
|
|
860
|
+
'content-type',
|
|
861
|
+
'x-request-id',
|
|
862
|
+
'anthropic-ratelimit-requests-limit',
|
|
863
|
+
'anthropic-ratelimit-requests-remaining',
|
|
864
|
+
'anthropic-ratelimit-tokens-limit',
|
|
865
|
+
'anthropic-ratelimit-tokens-remaining',
|
|
866
|
+
];
|
|
867
|
+
for (const header of allowedHeaders) {
|
|
868
|
+
const value = headers[header];
|
|
869
|
+
if (value) {
|
|
870
|
+
filtered[header] = Array.isArray(value) ? value[0] : value;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return filtered;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Type guard for AnthropicResponse
|
|
877
|
+
*/
|
|
878
|
+
function isAnthropicResponse(body) {
|
|
879
|
+
return (typeof body === 'object' &&
|
|
880
|
+
body !== null &&
|
|
881
|
+
'type' in body &&
|
|
882
|
+
body.type === 'message' &&
|
|
883
|
+
'content' in body &&
|
|
884
|
+
'usage' in body);
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Start the proxy server
|
|
888
|
+
*/
|
|
889
|
+
export async function startServer() {
|
|
890
|
+
const server = createServer();
|
|
891
|
+
// Cleanup old completed sessions (older than 24 hours)
|
|
892
|
+
const cleanedUp = cleanupOldCompletedSessions();
|
|
893
|
+
if (cleanedUp > 0) {
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
await server.listen({
|
|
897
|
+
host: config.HOST,
|
|
898
|
+
port: config.PORT,
|
|
899
|
+
});
|
|
900
|
+
console.log(`✓ Grov Proxy: http://${config.HOST}:${config.PORT} → ${config.ANTHROPIC_BASE_URL}`);
|
|
901
|
+
return server;
|
|
902
|
+
}
|
|
903
|
+
catch (err) {
|
|
904
|
+
server.log.error(err);
|
|
905
|
+
process.exit(1);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// CLI entry point
|
|
909
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
910
|
+
startServer();
|
|
911
|
+
}
|