grov 0.2.3 → 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -5
- package/dist/cli.js +40 -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 +57 -0
- package/dist/lib/api-client.js +174 -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 +15 -39
- package/dist/lib/llm-extractor.js +400 -418
- package/dist/lib/store/convenience.d.ts +40 -0
- package/dist/lib/store/convenience.js +104 -0
- package/dist/lib/store/database.d.ts +22 -0
- package/dist/lib/store/database.js +375 -0
- package/dist/lib/store/drift.d.ts +9 -0
- package/dist/lib/store/drift.js +89 -0
- package/dist/lib/store/index.d.ts +7 -0
- package/dist/lib/store/index.js +13 -0
- package/dist/lib/store/sessions.d.ts +32 -0
- package/dist/lib/store/sessions.js +240 -0
- package/dist/lib/store/steps.d.ts +40 -0
- package/dist/lib/store/steps.js +161 -0
- package/dist/lib/store/tasks.d.ts +33 -0
- package/dist/lib/store/tasks.js +133 -0
- package/dist/lib/store/types.d.ts +167 -0
- package/dist/lib/store/types.js +2 -0
- package/dist/lib/store.d.ts +1 -406
- package/dist/lib/store.js +2 -1356
- 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/cache.d.ts +36 -0
- package/dist/proxy/cache.js +51 -0
- package/dist/proxy/config.d.ts +1 -0
- package/dist/proxy/config.js +2 -0
- package/dist/proxy/extended-cache.d.ts +10 -0
- package/dist/proxy/extended-cache.js +155 -0
- package/dist/proxy/forwarder.d.ts +7 -1
- package/dist/proxy/forwarder.js +157 -7
- package/dist/proxy/handlers/preprocess.d.ts +20 -0
- package/dist/proxy/handlers/preprocess.js +169 -0
- package/dist/proxy/injection/delta-tracking.d.ts +11 -0
- package/dist/proxy/injection/delta-tracking.js +93 -0
- package/dist/proxy/injection/injectors.d.ts +7 -0
- package/dist/proxy/injection/injectors.js +139 -0
- package/dist/proxy/request-processor.d.ts +18 -3
- package/dist/proxy/request-processor.js +151 -28
- package/dist/proxy/response-processor.js +116 -47
- package/dist/proxy/server.d.ts +4 -1
- package/dist/proxy/server.js +592 -253
- package/dist/proxy/types.d.ts +13 -0
- package/dist/proxy/types.js +2 -0
- package/dist/proxy/utils/extractors.d.ts +18 -0
- package/dist/proxy/utils/extractors.js +109 -0
- package/dist/proxy/utils/logging.d.ts +18 -0
- package/dist/proxy/utils/logging.js +42 -0
- package/package.json +22 -4
package/dist/lib/utils.d.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* Truncate a string to a maximum length, adding ellipsis if truncated.
|
|
6
6
|
*/
|
|
7
7
|
export declare function truncate(str: string, maxLength: number): string;
|
|
8
|
+
/**
|
|
9
|
+
* Smart truncate: cleans markdown noise, prefers sentence/punctuation boundaries.
|
|
10
|
+
* Used for reasoning content that may contain markdown tables, bullets, etc.
|
|
11
|
+
*/
|
|
12
|
+
export declare function smartTruncate(text: string, maxLen?: number): string;
|
|
8
13
|
/**
|
|
9
14
|
* Capitalize the first letter of a string.
|
|
10
15
|
*/
|
package/dist/lib/utils.js
CHANGED
|
@@ -9,6 +9,51 @@ export function truncate(str, maxLength) {
|
|
|
9
9
|
return str;
|
|
10
10
|
return str.substring(0, maxLength - 3) + '...';
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Smart truncate: cleans markdown noise, prefers sentence/punctuation boundaries.
|
|
14
|
+
* Used for reasoning content that may contain markdown tables, bullets, etc.
|
|
15
|
+
*/
|
|
16
|
+
export function smartTruncate(text, maxLen = 120) {
|
|
17
|
+
// 1. Clean markdown noise
|
|
18
|
+
let clean = text
|
|
19
|
+
.replace(/\|[^|]+\|/g, '') // markdown table cells
|
|
20
|
+
.replace(/^[-*]\s*/gm, '') // bullet points
|
|
21
|
+
.replace(/#{1,6}\s*/g, '') // headers
|
|
22
|
+
.replace(/\n+/g, ' ') // newlines to space
|
|
23
|
+
.replace(/\s+/g, ' ') // multiple spaces to one
|
|
24
|
+
.trim();
|
|
25
|
+
// 2. If short enough, return as-is
|
|
26
|
+
if (clean.length <= maxLen)
|
|
27
|
+
return clean;
|
|
28
|
+
// 3. Try to keep complete sentences
|
|
29
|
+
const sentences = clean.match(/[^.!?]+[.!?]+/g) || [];
|
|
30
|
+
let result = '';
|
|
31
|
+
for (const sentence of sentences) {
|
|
32
|
+
if ((result + sentence).length <= maxLen) {
|
|
33
|
+
result += sentence;
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// 4. If we got at least one meaningful sentence, return it
|
|
40
|
+
if (result.length > 20)
|
|
41
|
+
return result.trim();
|
|
42
|
+
// 5. Fallback: find punctuation boundary
|
|
43
|
+
const truncated = clean.slice(0, maxLen);
|
|
44
|
+
const breakPoints = [
|
|
45
|
+
truncated.lastIndexOf('. '),
|
|
46
|
+
truncated.lastIndexOf(', '),
|
|
47
|
+
truncated.lastIndexOf('; '),
|
|
48
|
+
truncated.lastIndexOf(': '),
|
|
49
|
+
truncated.lastIndexOf(' - '),
|
|
50
|
+
truncated.lastIndexOf(' '),
|
|
51
|
+
].filter(p => p > maxLen * 0.6);
|
|
52
|
+
const cutPoint = breakPoints.length > 0
|
|
53
|
+
? Math.max(...breakPoints)
|
|
54
|
+
: truncated.lastIndexOf(' ');
|
|
55
|
+
return truncated.slice(0, cutPoint > 0 ? cutPoint : maxLen).trim() + '...';
|
|
56
|
+
}
|
|
12
57
|
/**
|
|
13
58
|
* Capitalize the first letter of a string.
|
|
14
59
|
*/
|
|
@@ -10,9 +10,11 @@ export interface AnthropicResponse {
|
|
|
10
10
|
usage: {
|
|
11
11
|
input_tokens: number;
|
|
12
12
|
output_tokens: number;
|
|
13
|
+
cache_creation_input_tokens?: number;
|
|
14
|
+
cache_read_input_tokens?: number;
|
|
13
15
|
};
|
|
14
16
|
}
|
|
15
|
-
export type ContentBlock = TextBlock | ToolUseBlock;
|
|
17
|
+
export type ContentBlock = TextBlock | ToolUseBlock | ThinkingBlock;
|
|
16
18
|
export interface TextBlock {
|
|
17
19
|
type: 'text';
|
|
18
20
|
text: string;
|
|
@@ -23,6 +25,10 @@ export interface ToolUseBlock {
|
|
|
23
25
|
name: string;
|
|
24
26
|
input: Record<string, unknown>;
|
|
25
27
|
}
|
|
28
|
+
export interface ThinkingBlock {
|
|
29
|
+
type: 'thinking';
|
|
30
|
+
thinking: string;
|
|
31
|
+
}
|
|
26
32
|
export interface ParsedAction {
|
|
27
33
|
toolName: string;
|
|
28
34
|
toolId: string;
|
|
@@ -37,12 +43,14 @@ export interface ParsedAction {
|
|
|
37
43
|
*/
|
|
38
44
|
export declare function parseToolUseBlocks(response: AnthropicResponse): ParsedAction[];
|
|
39
45
|
/**
|
|
40
|
-
* Extract token usage from response
|
|
46
|
+
* Extract token usage from response (including cache metrics)
|
|
41
47
|
*/
|
|
42
48
|
export declare function extractTokenUsage(response: AnthropicResponse): {
|
|
43
49
|
inputTokens: number;
|
|
44
50
|
outputTokens: number;
|
|
45
51
|
totalTokens: number;
|
|
52
|
+
cacheCreation: number;
|
|
53
|
+
cacheRead: number;
|
|
46
54
|
};
|
|
47
55
|
/**
|
|
48
56
|
* Check if response contains any file-modifying actions
|
|
@@ -132,13 +132,15 @@ function extractPathFromGlobPattern(pattern) {
|
|
|
132
132
|
return nonGlobParts.length > 0 ? nonGlobParts.join('/') : null;
|
|
133
133
|
}
|
|
134
134
|
/**
|
|
135
|
-
* Extract token usage from response
|
|
135
|
+
* Extract token usage from response (including cache metrics)
|
|
136
136
|
*/
|
|
137
137
|
export function extractTokenUsage(response) {
|
|
138
138
|
return {
|
|
139
139
|
inputTokens: response.usage.input_tokens,
|
|
140
140
|
outputTokens: response.usage.output_tokens,
|
|
141
|
-
totalTokens: response.usage.input_tokens + response.usage.output_tokens
|
|
141
|
+
totalTokens: response.usage.input_tokens + response.usage.output_tokens,
|
|
142
|
+
cacheCreation: response.usage.cache_creation_input_tokens || 0,
|
|
143
|
+
cacheRead: response.usage.cache_read_input_tokens || 0,
|
|
142
144
|
};
|
|
143
145
|
}
|
|
144
146
|
/**
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Global team memory cache
|
|
3
|
+
* - Calculated ONCE on first request, reused for ALL subsequent requests
|
|
4
|
+
* - Invalidated only on: sync completion (in .then() callback), proxy restart
|
|
5
|
+
* - Ensures system prompt prefix stays CONSTANT for Anthropic cache preservation
|
|
6
|
+
*/
|
|
7
|
+
export declare let globalTeamMemoryCache: {
|
|
8
|
+
projectPath: string;
|
|
9
|
+
content: string;
|
|
10
|
+
} | null;
|
|
11
|
+
/**
|
|
12
|
+
* Invalidate the global team memory cache
|
|
13
|
+
* Called after successful sync to cloud (in .then() callback)
|
|
14
|
+
* This ensures cache is only invalidated AFTER data is in cloud
|
|
15
|
+
*/
|
|
16
|
+
export declare function invalidateTeamMemoryCache(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Set the global team memory cache
|
|
19
|
+
* @param projectPath - Project path for cache key
|
|
20
|
+
* @param content - Formatted team memory content
|
|
21
|
+
*/
|
|
22
|
+
export declare function setTeamMemoryCache(projectPath: string, content: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Get the current cache content if it matches the project path
|
|
25
|
+
* @param projectPath - Project path to check
|
|
26
|
+
* @returns Cached content or null if not cached/different project
|
|
27
|
+
*/
|
|
28
|
+
export declare function getTeamMemoryCache(projectPath: string): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* Check if cache exists for a specific project
|
|
31
|
+
*/
|
|
32
|
+
export declare function hasCacheForProject(projectPath: string): boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Get current cache project path (for logging/debugging)
|
|
35
|
+
*/
|
|
36
|
+
export declare function getCacheProjectPath(): string | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Phase 0 verified
|
|
2
|
+
// Cache management for team memory injection
|
|
3
|
+
// Shared between server.ts and response-processor.ts to avoid circular dependencies
|
|
4
|
+
/**
|
|
5
|
+
* Global team memory cache
|
|
6
|
+
* - Calculated ONCE on first request, reused for ALL subsequent requests
|
|
7
|
+
* - Invalidated only on: sync completion (in .then() callback), proxy restart
|
|
8
|
+
* - Ensures system prompt prefix stays CONSTANT for Anthropic cache preservation
|
|
9
|
+
*/
|
|
10
|
+
export let globalTeamMemoryCache = null;
|
|
11
|
+
/**
|
|
12
|
+
* Invalidate the global team memory cache
|
|
13
|
+
* Called after successful sync to cloud (in .then() callback)
|
|
14
|
+
* This ensures cache is only invalidated AFTER data is in cloud
|
|
15
|
+
*/
|
|
16
|
+
export function invalidateTeamMemoryCache() {
|
|
17
|
+
globalTeamMemoryCache = null;
|
|
18
|
+
console.log('[CACHE] Team memory cache invalidated');
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Set the global team memory cache
|
|
22
|
+
* @param projectPath - Project path for cache key
|
|
23
|
+
* @param content - Formatted team memory content
|
|
24
|
+
*/
|
|
25
|
+
export function setTeamMemoryCache(projectPath, content) {
|
|
26
|
+
globalTeamMemoryCache = { projectPath, content };
|
|
27
|
+
console.log(`[CACHE] Team memory cache set for project: ${projectPath} (${content.length} chars)`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Get the current cache content if it matches the project path
|
|
31
|
+
* @param projectPath - Project path to check
|
|
32
|
+
* @returns Cached content or null if not cached/different project
|
|
33
|
+
*/
|
|
34
|
+
export function getTeamMemoryCache(projectPath) {
|
|
35
|
+
if (globalTeamMemoryCache && globalTeamMemoryCache.projectPath === projectPath) {
|
|
36
|
+
return globalTeamMemoryCache.content;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if cache exists for a specific project
|
|
42
|
+
*/
|
|
43
|
+
export function hasCacheForProject(projectPath) {
|
|
44
|
+
return globalTeamMemoryCache?.projectPath === projectPath;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Get current cache project path (for logging/debugging)
|
|
48
|
+
*/
|
|
49
|
+
export function getCacheProjectPath() {
|
|
50
|
+
return globalTeamMemoryCache?.projectPath || null;
|
|
51
|
+
}
|
package/dist/proxy/config.d.ts
CHANGED
package/dist/proxy/config.js
CHANGED
|
@@ -19,6 +19,8 @@ export const config = {
|
|
|
19
19
|
// Logging
|
|
20
20
|
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
|
|
21
21
|
LOG_REQUESTS: process.env.LOG_REQUESTS !== 'false',
|
|
22
|
+
// Extended Cache - preserve Anthropic prompt cache during idle
|
|
23
|
+
EXTENDED_CACHE_ENABLED: process.env.GROV_EXTENDED_CACHE === 'true',
|
|
22
24
|
};
|
|
23
25
|
// Headers to forward to Anthropic (whitelist approach)
|
|
24
26
|
export const FORWARD_HEADERS = [
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ExtendedCacheEntry {
|
|
2
|
+
headers: Record<string, string>;
|
|
3
|
+
rawBody: Buffer;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
keepAliveCount: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const extendedCache: Map<string, ExtendedCacheEntry>;
|
|
8
|
+
export declare function log(msg: string): void;
|
|
9
|
+
export declare function evictOldestCacheEntry(): void;
|
|
10
|
+
export declare function checkExtendedCache(): Promise<void>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Extended Cache - Keep Anthropic cache alive during idle
|
|
2
|
+
// Sends minimal keep-alive requests to prevent cache TTL expiration
|
|
3
|
+
import { forwardToAnthropic } from './forwarder.js';
|
|
4
|
+
export const extendedCache = new Map();
|
|
5
|
+
// Timing constants
|
|
6
|
+
const EXTENDED_CACHE_IDLE_THRESHOLD = 4 * 60 * 1000; // 4 minutes (under 5-min TTL)
|
|
7
|
+
const EXTENDED_CACHE_MAX_IDLE = 10 * 60 * 1000; // 10 minutes total
|
|
8
|
+
const EXTENDED_CACHE_MAX_KEEPALIVES = 2;
|
|
9
|
+
const EXTENDED_CACHE_MAX_ENTRIES = 100; // Max concurrent sessions (memory cap)
|
|
10
|
+
export function log(msg) {
|
|
11
|
+
console.log(`[CACHE] ${msg}`);
|
|
12
|
+
}
|
|
13
|
+
export function evictOldestCacheEntry() {
|
|
14
|
+
if (extendedCache.size < EXTENDED_CACHE_MAX_ENTRIES)
|
|
15
|
+
return;
|
|
16
|
+
let oldestId = null;
|
|
17
|
+
let oldestTime = Infinity;
|
|
18
|
+
for (const [id, entry] of extendedCache) {
|
|
19
|
+
if (entry.timestamp < oldestTime) {
|
|
20
|
+
oldestTime = entry.timestamp;
|
|
21
|
+
oldestId = id;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (oldestId) {
|
|
25
|
+
extendedCache.delete(oldestId);
|
|
26
|
+
log(`Extended cache: evicted ${oldestId.substring(0, 8)} (capacity limit)`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function sendExtendedCacheKeepAlive(projectPath, entry) {
|
|
30
|
+
const projectName = projectPath.split('/').pop() || projectPath;
|
|
31
|
+
let rawBodyStr = entry.rawBody.toString('utf-8');
|
|
32
|
+
// 1. Find messages array and add "." message before closing bracket
|
|
33
|
+
const messagesMatch = rawBodyStr.match(/"messages"\s*:\s*\[/);
|
|
34
|
+
if (!messagesMatch || messagesMatch.index === undefined) {
|
|
35
|
+
throw new Error('Cannot find messages array in rawBody');
|
|
36
|
+
}
|
|
37
|
+
// Find closing bracket of messages array (handling nested arrays/objects)
|
|
38
|
+
const messagesStart = messagesMatch.index + messagesMatch[0].length;
|
|
39
|
+
let bracketDepth = 1; // We're inside the [ already
|
|
40
|
+
let braceDepth = 0; // Track {} for objects
|
|
41
|
+
let inString = false; // Track if we're inside a string
|
|
42
|
+
let messagesEnd = messagesStart;
|
|
43
|
+
for (let i = messagesStart; i < rawBodyStr.length && bracketDepth > 0; i++) {
|
|
44
|
+
const char = rawBodyStr[i];
|
|
45
|
+
const prevChar = i > 0 ? rawBodyStr[i - 1] : '';
|
|
46
|
+
// Handle string boundaries (skip escaped quotes)
|
|
47
|
+
if (char === '"' && prevChar !== '\\') {
|
|
48
|
+
inString = !inString;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// Skip everything inside strings
|
|
52
|
+
if (inString)
|
|
53
|
+
continue;
|
|
54
|
+
// Track brackets and braces
|
|
55
|
+
if (char === '[')
|
|
56
|
+
bracketDepth++;
|
|
57
|
+
else if (char === ']')
|
|
58
|
+
bracketDepth--;
|
|
59
|
+
else if (char === '{')
|
|
60
|
+
braceDepth++;
|
|
61
|
+
else if (char === '}')
|
|
62
|
+
braceDepth--;
|
|
63
|
+
// Found the closing bracket of messages array
|
|
64
|
+
if (bracketDepth === 0) {
|
|
65
|
+
messagesEnd = i;
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Safety check: did we find the end?
|
|
70
|
+
if (bracketDepth !== 0) {
|
|
71
|
+
throw new Error(`Could not find closing bracket of messages array (depth=${bracketDepth})`);
|
|
72
|
+
}
|
|
73
|
+
// Check if array has content (anything between messagesStart and messagesEnd)
|
|
74
|
+
const arrayContent = rawBodyStr.slice(messagesStart, messagesEnd).trim();
|
|
75
|
+
const messagesIsEmpty = arrayContent.length === 0;
|
|
76
|
+
// Insert minimal user message before closing bracket
|
|
77
|
+
const keepAliveMsg = messagesIsEmpty
|
|
78
|
+
? '{"role":"user","content":"."}'
|
|
79
|
+
: ',{"role":"user","content":"."}';
|
|
80
|
+
log(`Extended cache: SEND keep-alive project=${projectName} msg_array_size=${messagesEnd - messagesStart}`);
|
|
81
|
+
rawBodyStr = rawBodyStr.slice(0, messagesEnd) + keepAliveMsg + rawBodyStr.slice(messagesEnd);
|
|
82
|
+
// NOTE: We do NOT modify max_tokens or stream!
|
|
83
|
+
// Keeping them identical preserves the cache prefix for byte-exact matching.
|
|
84
|
+
// Claude will respond briefly to "." anyway, and forwarder handles streaming.
|
|
85
|
+
// 2. Validate JSON after manipulation
|
|
86
|
+
try {
|
|
87
|
+
JSON.parse(rawBodyStr);
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
throw new Error(`Invalid JSON after modifications: ${e instanceof Error ? e.message : 'unknown'}`);
|
|
91
|
+
}
|
|
92
|
+
// 3. Forward to Anthropic using same undici path as regular requests
|
|
93
|
+
const result = await forwardToAnthropic({}, entry.headers, undefined, Buffer.from(rawBodyStr, 'utf-8'));
|
|
94
|
+
if (result.statusCode !== 200) {
|
|
95
|
+
throw new Error(`Keep-alive failed: ${result.statusCode}`);
|
|
96
|
+
}
|
|
97
|
+
// Log cache metrics
|
|
98
|
+
const usage = result.body.usage;
|
|
99
|
+
const cacheRead = usage?.cache_read_input_tokens || 0;
|
|
100
|
+
const cacheCreate = usage?.cache_creation_input_tokens || 0;
|
|
101
|
+
const inputTokens = usage?.input_tokens || 0;
|
|
102
|
+
log(`Extended cache: keep-alive for ${projectName} - cache_read=${cacheRead}, cache_create=${cacheCreate}, input=${inputTokens}`);
|
|
103
|
+
}
|
|
104
|
+
export async function checkExtendedCache() {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const projectsToKeepAlive = [];
|
|
107
|
+
// First pass: cleanup stale/maxed entries, collect projects needing keep-alive
|
|
108
|
+
for (const [projectPath, entry] of extendedCache) {
|
|
109
|
+
const idleTime = now - entry.timestamp;
|
|
110
|
+
const projectName = projectPath.split('/').pop() || projectPath;
|
|
111
|
+
// Stale cleanup: user left after 10 minutes
|
|
112
|
+
if (idleTime > EXTENDED_CACHE_MAX_IDLE) {
|
|
113
|
+
extendedCache.delete(projectPath);
|
|
114
|
+
log(`Extended cache: cleared ${projectName} (stale)`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
// Skip if not idle enough yet
|
|
118
|
+
if (idleTime < EXTENDED_CACHE_IDLE_THRESHOLD) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Skip if already sent max keep-alives
|
|
122
|
+
if (entry.keepAliveCount >= EXTENDED_CACHE_MAX_KEEPALIVES) {
|
|
123
|
+
extendedCache.delete(projectPath);
|
|
124
|
+
log(`Extended cache: cleared ${projectName} (max retries)`);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
projectsToKeepAlive.push({ projectPath, entry });
|
|
128
|
+
}
|
|
129
|
+
// Second pass: send all keep-alives in PARALLEL
|
|
130
|
+
const keepAlivePromises = [];
|
|
131
|
+
for (const { projectPath, entry } of projectsToKeepAlive) {
|
|
132
|
+
const projectName = projectPath.split('/').pop() || projectPath;
|
|
133
|
+
const promise = sendExtendedCacheKeepAlive(projectPath, entry)
|
|
134
|
+
.then(() => {
|
|
135
|
+
entry.timestamp = Date.now();
|
|
136
|
+
entry.keepAliveCount++;
|
|
137
|
+
})
|
|
138
|
+
.catch((err) => {
|
|
139
|
+
extendedCache.delete(projectPath);
|
|
140
|
+
// Handle both Error instances and ForwardError objects
|
|
141
|
+
const errMsg = err instanceof Error
|
|
142
|
+
? err.message
|
|
143
|
+
: (err && typeof err === 'object' && 'message' in err)
|
|
144
|
+
? String(err.message)
|
|
145
|
+
: JSON.stringify(err);
|
|
146
|
+
const errType = err && typeof err === 'object' && 'type' in err ? ` [${err.type}]` : '';
|
|
147
|
+
log(`Extended cache: cleared ${projectName} (error${errType}: ${errMsg})`);
|
|
148
|
+
});
|
|
149
|
+
keepAlivePromises.push(promise);
|
|
150
|
+
}
|
|
151
|
+
// Wait for all keep-alives to complete
|
|
152
|
+
if (keepAlivePromises.length > 0) {
|
|
153
|
+
await Promise.all(keepAlivePromises);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -4,6 +4,7 @@ export interface ForwardResult {
|
|
|
4
4
|
headers: Record<string, string | string[]>;
|
|
5
5
|
body: AnthropicResponse | Record<string, unknown>;
|
|
6
6
|
rawBody: string;
|
|
7
|
+
wasSSE: boolean;
|
|
7
8
|
}
|
|
8
9
|
export interface ForwardError {
|
|
9
10
|
type: 'timeout' | 'network' | 'parse' | 'unknown';
|
|
@@ -13,11 +14,16 @@ export interface ForwardError {
|
|
|
13
14
|
/**
|
|
14
15
|
* Forward request to Anthropic API
|
|
15
16
|
* Buffers full response for processing
|
|
17
|
+
*
|
|
18
|
+
* @param body - Parsed body for logging
|
|
19
|
+
* @param headers - Request headers
|
|
20
|
+
* @param logger - Optional logger
|
|
21
|
+
* @param rawBody - Raw request bytes (preserves exact bytes for cache)
|
|
16
22
|
*/
|
|
17
23
|
export declare function forwardToAnthropic(body: Record<string, unknown>, headers: Record<string, string | string[] | undefined>, logger?: {
|
|
18
24
|
info: (msg: string, data?: Record<string, unknown>) => void;
|
|
19
25
|
error: (msg: string, data?: Record<string, unknown>) => void;
|
|
20
|
-
}): Promise<ForwardResult>;
|
|
26
|
+
}, rawBody?: Buffer): Promise<ForwardResult>;
|
|
21
27
|
/**
|
|
22
28
|
* Check if error is a ForwardError
|
|
23
29
|
*/
|
package/dist/proxy/forwarder.js
CHANGED
|
@@ -10,13 +10,139 @@ const agent = new Agent({
|
|
|
10
10
|
autoSelectFamilyAttemptTimeout: 500, // Try next address family after 500ms
|
|
11
11
|
});
|
|
12
12
|
import { config, buildSafeHeaders, maskSensitiveValue } from './config.js';
|
|
13
|
+
/**
|
|
14
|
+
* Parse SSE stream and reconstruct final message
|
|
15
|
+
* SSE format: "event: <type>\ndata: <json>\n\n"
|
|
16
|
+
*/
|
|
17
|
+
function parseSSEResponse(sseText) {
|
|
18
|
+
const lines = sseText.split('\n');
|
|
19
|
+
let message = null;
|
|
20
|
+
const contentBlocks = [];
|
|
21
|
+
const contentDeltas = new Map();
|
|
22
|
+
let finalUsage = null;
|
|
23
|
+
let stopReason = null;
|
|
24
|
+
let currentEvent = '';
|
|
25
|
+
let currentData = '';
|
|
26
|
+
for (const line of lines) {
|
|
27
|
+
if (line.startsWith('event: ')) {
|
|
28
|
+
currentEvent = line.slice(7).trim();
|
|
29
|
+
}
|
|
30
|
+
else if (line.startsWith('data: ')) {
|
|
31
|
+
currentData = line.slice(6);
|
|
32
|
+
try {
|
|
33
|
+
const data = JSON.parse(currentData);
|
|
34
|
+
switch (data.type) {
|
|
35
|
+
case 'message_start':
|
|
36
|
+
// Initialize message from message_start event
|
|
37
|
+
message = data.message;
|
|
38
|
+
break;
|
|
39
|
+
case 'content_block_start':
|
|
40
|
+
// Add new content block
|
|
41
|
+
if (data.content_block) {
|
|
42
|
+
contentBlocks[data.index] = data.content_block;
|
|
43
|
+
if (data.content_block.type === 'text') {
|
|
44
|
+
contentDeltas.set(data.index, []);
|
|
45
|
+
}
|
|
46
|
+
else if (data.content_block.type === 'thinking') {
|
|
47
|
+
// Initialize thinking with empty string, will accumulate via deltas
|
|
48
|
+
contentBlocks[data.index] = { type: 'thinking', thinking: '' };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
case 'content_block_delta':
|
|
53
|
+
// Accumulate text deltas
|
|
54
|
+
if (data.delta?.type === 'text_delta' && data.delta.text) {
|
|
55
|
+
const deltas = contentDeltas.get(data.index) || [];
|
|
56
|
+
deltas.push(data.delta.text);
|
|
57
|
+
contentDeltas.set(data.index, deltas);
|
|
58
|
+
}
|
|
59
|
+
else if (data.delta?.type === 'thinking_delta' && data.delta.thinking) {
|
|
60
|
+
// Handle thinking blocks
|
|
61
|
+
const block = contentBlocks[data.index];
|
|
62
|
+
if (block && block.type === 'thinking') {
|
|
63
|
+
block.thinking += data.delta.thinking;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (data.delta?.type === 'input_json_delta' && data.delta.partial_json) {
|
|
67
|
+
// Handle tool input streaming
|
|
68
|
+
const block = contentBlocks[data.index];
|
|
69
|
+
if (block && block.type === 'tool_use') {
|
|
70
|
+
// Accumulate partial JSON - will need to parse at the end
|
|
71
|
+
const partialKey = `tool_partial_${data.index}`;
|
|
72
|
+
const existing = contentDeltas.get(data.index) || [];
|
|
73
|
+
existing.push(data.delta.partial_json);
|
|
74
|
+
contentDeltas.set(data.index, existing);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case 'message_delta':
|
|
79
|
+
// Final usage and stop_reason
|
|
80
|
+
if (data.usage) {
|
|
81
|
+
finalUsage = data.usage;
|
|
82
|
+
}
|
|
83
|
+
if (data.delta?.stop_reason) {
|
|
84
|
+
stopReason = data.delta.stop_reason;
|
|
85
|
+
}
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Ignore unparseable data lines
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!message) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
// Reconstruct content blocks with accumulated text/input
|
|
98
|
+
for (let i = 0; i < contentBlocks.length; i++) {
|
|
99
|
+
const block = contentBlocks[i];
|
|
100
|
+
if (!block)
|
|
101
|
+
continue;
|
|
102
|
+
const deltas = contentDeltas.get(i);
|
|
103
|
+
if (deltas && deltas.length > 0) {
|
|
104
|
+
if (block.type === 'text') {
|
|
105
|
+
block.text = deltas.join('');
|
|
106
|
+
}
|
|
107
|
+
else if (block.type === 'tool_use') {
|
|
108
|
+
// Parse accumulated partial JSON for tool input
|
|
109
|
+
try {
|
|
110
|
+
const fullJson = deltas.join('');
|
|
111
|
+
block.input = JSON.parse(fullJson);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// Keep original input if parsing fails
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Build final response
|
|
120
|
+
const response = {
|
|
121
|
+
id: message.id || '',
|
|
122
|
+
type: 'message',
|
|
123
|
+
role: 'assistant',
|
|
124
|
+
content: contentBlocks.filter(Boolean),
|
|
125
|
+
model: message.model || '',
|
|
126
|
+
stop_reason: stopReason,
|
|
127
|
+
stop_sequence: null,
|
|
128
|
+
usage: finalUsage || message.usage || { input_tokens: 0, output_tokens: 0 },
|
|
129
|
+
};
|
|
130
|
+
return response;
|
|
131
|
+
}
|
|
13
132
|
/**
|
|
14
133
|
* Forward request to Anthropic API
|
|
15
134
|
* Buffers full response for processing
|
|
135
|
+
*
|
|
136
|
+
* @param body - Parsed body for logging
|
|
137
|
+
* @param headers - Request headers
|
|
138
|
+
* @param logger - Optional logger
|
|
139
|
+
* @param rawBody - Raw request bytes (preserves exact bytes for cache)
|
|
16
140
|
*/
|
|
17
|
-
export async function forwardToAnthropic(body, headers, logger) {
|
|
141
|
+
export async function forwardToAnthropic(body, headers, logger, rawBody) {
|
|
18
142
|
const targetUrl = `${config.ANTHROPIC_BASE_URL}/v1/messages`;
|
|
19
143
|
const safeHeaders = buildSafeHeaders(headers);
|
|
144
|
+
// Use raw bytes if available (preserves cache), otherwise re-serialize
|
|
145
|
+
const requestBody = rawBody || JSON.stringify(body);
|
|
20
146
|
// Log request (mask sensitive data)
|
|
21
147
|
if (logger && config.LOG_REQUESTS) {
|
|
22
148
|
const maskedHeaders = {};
|
|
@@ -28,6 +154,8 @@ export async function forwardToAnthropic(body, headers, logger) {
|
|
|
28
154
|
model: body.model,
|
|
29
155
|
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
30
156
|
headers: maskedHeaders,
|
|
157
|
+
usingRawBody: !!rawBody,
|
|
158
|
+
bodySize: rawBody?.length || JSON.stringify(body).length,
|
|
31
159
|
});
|
|
32
160
|
}
|
|
33
161
|
try {
|
|
@@ -37,7 +165,7 @@ export async function forwardToAnthropic(body, headers, logger) {
|
|
|
37
165
|
...safeHeaders,
|
|
38
166
|
'content-type': 'application/json',
|
|
39
167
|
},
|
|
40
|
-
body:
|
|
168
|
+
body: requestBody,
|
|
41
169
|
bodyTimeout: config.REQUEST_TIMEOUT,
|
|
42
170
|
headersTimeout: config.REQUEST_TIMEOUT,
|
|
43
171
|
dispatcher: agent,
|
|
@@ -48,14 +176,29 @@ export async function forwardToAnthropic(body, headers, logger) {
|
|
|
48
176
|
chunks.push(Buffer.from(chunk));
|
|
49
177
|
}
|
|
50
178
|
const rawBody = Buffer.concat(chunks).toString('utf-8');
|
|
179
|
+
// Check if response is SSE streaming
|
|
180
|
+
const contentType = response.headers['content-type'];
|
|
181
|
+
const isSSE = typeof contentType === 'string' && contentType.includes('text/event-stream');
|
|
51
182
|
// Parse response
|
|
52
183
|
let parsedBody;
|
|
53
|
-
|
|
54
|
-
|
|
184
|
+
if (isSSE) {
|
|
185
|
+
// Parse SSE and reconstruct final message
|
|
186
|
+
const sseMessage = parseSSEResponse(rawBody);
|
|
187
|
+
if (sseMessage) {
|
|
188
|
+
parsedBody = sseMessage;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
parsedBody = { error: 'Failed to parse SSE response', raw: rawBody.substring(0, 500) };
|
|
192
|
+
}
|
|
55
193
|
}
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
194
|
+
else {
|
|
195
|
+
// Regular JSON response
|
|
196
|
+
try {
|
|
197
|
+
parsedBody = JSON.parse(rawBody);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
parsedBody = { error: 'Invalid JSON response', raw: rawBody.substring(0, 500) };
|
|
201
|
+
}
|
|
59
202
|
}
|
|
60
203
|
// Convert headers to record
|
|
61
204
|
const responseHeaders = {};
|
|
@@ -64,11 +207,17 @@ export async function forwardToAnthropic(body, headers, logger) {
|
|
|
64
207
|
responseHeaders[key] = value;
|
|
65
208
|
}
|
|
66
209
|
}
|
|
210
|
+
// If we parsed SSE, change content-type to JSON for Claude Code
|
|
211
|
+
if (isSSE) {
|
|
212
|
+
responseHeaders['content-type'] = 'application/json';
|
|
213
|
+
}
|
|
67
214
|
if (logger && config.LOG_REQUESTS) {
|
|
68
215
|
logger.info('Received from Anthropic', {
|
|
69
216
|
statusCode: response.statusCode,
|
|
70
217
|
bodyLength: rawBody.length,
|
|
71
218
|
hasUsage: 'usage' in parsedBody,
|
|
219
|
+
wasSSE: isSSE,
|
|
220
|
+
parseSuccess: !('error' in parsedBody),
|
|
72
221
|
});
|
|
73
222
|
}
|
|
74
223
|
return {
|
|
@@ -76,6 +225,7 @@ export async function forwardToAnthropic(body, headers, logger) {
|
|
|
76
225
|
headers: responseHeaders,
|
|
77
226
|
body: parsedBody,
|
|
78
227
|
rawBody,
|
|
228
|
+
wasSSE: isSSE,
|
|
79
229
|
};
|
|
80
230
|
}
|
|
81
231
|
catch (error) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { MessagesRequestBody } from '../types.js';
|
|
2
|
+
export declare function getPendingPlanClear(): {
|
|
3
|
+
projectPath: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
} | null;
|
|
6
|
+
export declare function setPendingPlanClear(value: {
|
|
7
|
+
projectPath: string;
|
|
8
|
+
summary: string;
|
|
9
|
+
}): void;
|
|
10
|
+
export declare function clearPendingPlan(): void;
|
|
11
|
+
export declare function preProcessRequest(body: MessagesRequestBody, sessionInfo: {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
promptCount: number;
|
|
14
|
+
projectPath: string;
|
|
15
|
+
}, logger: {
|
|
16
|
+
info: (data: Record<string, unknown>) => void;
|
|
17
|
+
}, detectRequestType: (messages: Array<{
|
|
18
|
+
role: string;
|
|
19
|
+
content: unknown;
|
|
20
|
+
}>, projectPath: string) => 'first' | 'continuation' | 'retry'): Promise<MessagesRequestBody>;
|