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.
Files changed (64) hide show
  1. package/README.md +44 -5
  2. package/dist/cli.js +40 -2
  3. package/dist/commands/login.d.ts +1 -0
  4. package/dist/commands/login.js +115 -0
  5. package/dist/commands/logout.d.ts +1 -0
  6. package/dist/commands/logout.js +13 -0
  7. package/dist/commands/sync.d.ts +8 -0
  8. package/dist/commands/sync.js +127 -0
  9. package/dist/lib/api-client.d.ts +57 -0
  10. package/dist/lib/api-client.js +174 -0
  11. package/dist/lib/cloud-sync.d.ts +33 -0
  12. package/dist/lib/cloud-sync.js +176 -0
  13. package/dist/lib/credentials.d.ts +53 -0
  14. package/dist/lib/credentials.js +201 -0
  15. package/dist/lib/llm-extractor.d.ts +15 -39
  16. package/dist/lib/llm-extractor.js +400 -418
  17. package/dist/lib/store/convenience.d.ts +40 -0
  18. package/dist/lib/store/convenience.js +104 -0
  19. package/dist/lib/store/database.d.ts +22 -0
  20. package/dist/lib/store/database.js +375 -0
  21. package/dist/lib/store/drift.d.ts +9 -0
  22. package/dist/lib/store/drift.js +89 -0
  23. package/dist/lib/store/index.d.ts +7 -0
  24. package/dist/lib/store/index.js +13 -0
  25. package/dist/lib/store/sessions.d.ts +32 -0
  26. package/dist/lib/store/sessions.js +240 -0
  27. package/dist/lib/store/steps.d.ts +40 -0
  28. package/dist/lib/store/steps.js +161 -0
  29. package/dist/lib/store/tasks.d.ts +33 -0
  30. package/dist/lib/store/tasks.js +133 -0
  31. package/dist/lib/store/types.d.ts +167 -0
  32. package/dist/lib/store/types.js +2 -0
  33. package/dist/lib/store.d.ts +1 -406
  34. package/dist/lib/store.js +2 -1356
  35. package/dist/lib/utils.d.ts +5 -0
  36. package/dist/lib/utils.js +45 -0
  37. package/dist/proxy/action-parser.d.ts +10 -2
  38. package/dist/proxy/action-parser.js +4 -2
  39. package/dist/proxy/cache.d.ts +36 -0
  40. package/dist/proxy/cache.js +51 -0
  41. package/dist/proxy/config.d.ts +1 -0
  42. package/dist/proxy/config.js +2 -0
  43. package/dist/proxy/extended-cache.d.ts +10 -0
  44. package/dist/proxy/extended-cache.js +155 -0
  45. package/dist/proxy/forwarder.d.ts +7 -1
  46. package/dist/proxy/forwarder.js +157 -7
  47. package/dist/proxy/handlers/preprocess.d.ts +20 -0
  48. package/dist/proxy/handlers/preprocess.js +169 -0
  49. package/dist/proxy/injection/delta-tracking.d.ts +11 -0
  50. package/dist/proxy/injection/delta-tracking.js +93 -0
  51. package/dist/proxy/injection/injectors.d.ts +7 -0
  52. package/dist/proxy/injection/injectors.js +139 -0
  53. package/dist/proxy/request-processor.d.ts +18 -3
  54. package/dist/proxy/request-processor.js +151 -28
  55. package/dist/proxy/response-processor.js +116 -47
  56. package/dist/proxy/server.d.ts +4 -1
  57. package/dist/proxy/server.js +592 -253
  58. package/dist/proxy/types.d.ts +13 -0
  59. package/dist/proxy/types.js +2 -0
  60. package/dist/proxy/utils/extractors.d.ts +18 -0
  61. package/dist/proxy/utils/extractors.js +109 -0
  62. package/dist/proxy/utils/logging.d.ts +18 -0
  63. package/dist/proxy/utils/logging.js +42 -0
  64. package/package.json +22 -4
@@ -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
+ }
@@ -12,6 +12,7 @@ export declare const config: {
12
12
  ENABLE_TLS: boolean;
13
13
  LOG_LEVEL: string;
14
14
  LOG_REQUESTS: boolean;
15
+ EXTENDED_CACHE_ENABLED: boolean;
15
16
  };
16
17
  export declare const FORWARD_HEADERS: string[];
17
18
  export declare const SENSITIVE_HEADERS: string[];
@@ -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
  */
@@ -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: JSON.stringify(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
- try {
54
- parsedBody = JSON.parse(rawBody);
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
- catch {
57
- // Return raw body if not JSON
58
- parsedBody = { error: 'Invalid JSON response', raw: rawBody.substring(0, 500) };
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>;