grov 0.1.1 → 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 +10 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { StepActionType } from '../lib/store.js';
|
|
2
|
+
export interface AnthropicResponse {
|
|
3
|
+
id: string;
|
|
4
|
+
type: 'message';
|
|
5
|
+
role: 'assistant';
|
|
6
|
+
content: ContentBlock[];
|
|
7
|
+
model: string;
|
|
8
|
+
stop_reason: string | null;
|
|
9
|
+
stop_sequence: string | null;
|
|
10
|
+
usage: {
|
|
11
|
+
input_tokens: number;
|
|
12
|
+
output_tokens: number;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export type ContentBlock = TextBlock | ToolUseBlock;
|
|
16
|
+
export interface TextBlock {
|
|
17
|
+
type: 'text';
|
|
18
|
+
text: string;
|
|
19
|
+
}
|
|
20
|
+
export interface ToolUseBlock {
|
|
21
|
+
type: 'tool_use';
|
|
22
|
+
id: string;
|
|
23
|
+
name: string;
|
|
24
|
+
input: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface ParsedAction {
|
|
27
|
+
toolName: string;
|
|
28
|
+
toolId: string;
|
|
29
|
+
actionType: StepActionType;
|
|
30
|
+
files: string[];
|
|
31
|
+
folders: string[];
|
|
32
|
+
command?: string;
|
|
33
|
+
rawInput: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parse tool_use blocks from Anthropic API response
|
|
37
|
+
*/
|
|
38
|
+
export declare function parseToolUseBlocks(response: AnthropicResponse): ParsedAction[];
|
|
39
|
+
/**
|
|
40
|
+
* Extract token usage from response
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractTokenUsage(response: AnthropicResponse): {
|
|
43
|
+
inputTokens: number;
|
|
44
|
+
outputTokens: number;
|
|
45
|
+
totalTokens: number;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Check if response contains any file-modifying actions
|
|
49
|
+
*/
|
|
50
|
+
export declare function hasModifyingActions(actions: ParsedAction[]): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Get all unique files from actions
|
|
53
|
+
*/
|
|
54
|
+
export declare function getAllFiles(actions: ParsedAction[]): string[];
|
|
55
|
+
/**
|
|
56
|
+
* Get all unique folders from actions
|
|
57
|
+
*/
|
|
58
|
+
export declare function getAllFolders(actions: ParsedAction[]): string[];
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Parse tool_use blocks from Anthropic API response
|
|
2
|
+
// Replaces JSONL parsing - works with API response JSON directly
|
|
3
|
+
// Tool name to action type mapping
|
|
4
|
+
const TOOL_ACTION_MAP = {
|
|
5
|
+
'Edit': 'edit',
|
|
6
|
+
'Write': 'write',
|
|
7
|
+
'Read': 'read',
|
|
8
|
+
'Bash': 'bash',
|
|
9
|
+
'Glob': 'glob',
|
|
10
|
+
'Grep': 'grep',
|
|
11
|
+
'Task': 'task',
|
|
12
|
+
'MultiEdit': 'edit',
|
|
13
|
+
'NotebookEdit': 'edit',
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Parse tool_use blocks from Anthropic API response
|
|
17
|
+
*/
|
|
18
|
+
export function parseToolUseBlocks(response) {
|
|
19
|
+
const actions = [];
|
|
20
|
+
for (const block of response.content) {
|
|
21
|
+
if (block.type === 'tool_use') {
|
|
22
|
+
const action = parseToolUseBlock(block);
|
|
23
|
+
if (action) {
|
|
24
|
+
actions.push(action);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return actions;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Parse a single tool_use block
|
|
32
|
+
*/
|
|
33
|
+
function parseToolUseBlock(block) {
|
|
34
|
+
const actionType = TOOL_ACTION_MAP[block.name] || 'other';
|
|
35
|
+
const files = [];
|
|
36
|
+
const folders = [];
|
|
37
|
+
let command;
|
|
38
|
+
// Extract file paths based on tool type
|
|
39
|
+
switch (block.name) {
|
|
40
|
+
case 'Edit':
|
|
41
|
+
case 'Write':
|
|
42
|
+
case 'Read':
|
|
43
|
+
case 'NotebookEdit':
|
|
44
|
+
if (typeof block.input.file_path === 'string') {
|
|
45
|
+
files.push(block.input.file_path);
|
|
46
|
+
}
|
|
47
|
+
if (typeof block.input.notebook_path === 'string') {
|
|
48
|
+
files.push(block.input.notebook_path);
|
|
49
|
+
}
|
|
50
|
+
break;
|
|
51
|
+
case 'MultiEdit':
|
|
52
|
+
// MultiEdit has an array of edits
|
|
53
|
+
if (Array.isArray(block.input.edits)) {
|
|
54
|
+
for (const edit of block.input.edits) {
|
|
55
|
+
if (typeof edit === 'object' && edit && typeof edit.file_path === 'string') {
|
|
56
|
+
files.push(edit.file_path);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
case 'Bash':
|
|
62
|
+
if (typeof block.input.command === 'string') {
|
|
63
|
+
command = block.input.command;
|
|
64
|
+
// Try to extract file paths from common command patterns
|
|
65
|
+
const bashFiles = extractFilesFromBashCommand(command);
|
|
66
|
+
files.push(...bashFiles);
|
|
67
|
+
}
|
|
68
|
+
break;
|
|
69
|
+
case 'Glob':
|
|
70
|
+
if (typeof block.input.path === 'string') {
|
|
71
|
+
folders.push(block.input.path);
|
|
72
|
+
}
|
|
73
|
+
if (typeof block.input.pattern === 'string') {
|
|
74
|
+
// pattern might contain path info
|
|
75
|
+
const patternPath = extractPathFromGlobPattern(block.input.pattern);
|
|
76
|
+
if (patternPath) {
|
|
77
|
+
folders.push(patternPath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
case 'Grep':
|
|
82
|
+
if (typeof block.input.path === 'string') {
|
|
83
|
+
folders.push(block.input.path);
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
toolName: block.name,
|
|
89
|
+
toolId: block.id,
|
|
90
|
+
actionType,
|
|
91
|
+
files: [...new Set(files)],
|
|
92
|
+
folders: [...new Set(folders)],
|
|
93
|
+
command,
|
|
94
|
+
rawInput: block.input
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Extract file paths from bash command
|
|
99
|
+
*/
|
|
100
|
+
function extractFilesFromBashCommand(command) {
|
|
101
|
+
const files = [];
|
|
102
|
+
// Match absolute paths
|
|
103
|
+
const absolutePathRegex = /(?:^|\s)(\/[^\s"']+)/g;
|
|
104
|
+
let match;
|
|
105
|
+
while ((match = absolutePathRegex.exec(command)) !== null) {
|
|
106
|
+
const path = match[1];
|
|
107
|
+
// Filter out common non-file paths
|
|
108
|
+
if (!path.startsWith('/dev/') && !path.startsWith('/proc/') && !path.startsWith('/sys/')) {
|
|
109
|
+
files.push(path);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Match quoted paths
|
|
113
|
+
const quotedPathRegex = /["'](\/[^"']+)["']/g;
|
|
114
|
+
while ((match = quotedPathRegex.exec(command)) !== null) {
|
|
115
|
+
files.push(match[1]);
|
|
116
|
+
}
|
|
117
|
+
return files;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Extract base path from glob pattern
|
|
121
|
+
*/
|
|
122
|
+
function extractPathFromGlobPattern(pattern) {
|
|
123
|
+
// e.g., "src/**/*.ts" -> "src"
|
|
124
|
+
const parts = pattern.split('/');
|
|
125
|
+
const nonGlobParts = [];
|
|
126
|
+
for (const part of parts) {
|
|
127
|
+
if (part.includes('*') || part.includes('?') || part.includes('[')) {
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
nonGlobParts.push(part);
|
|
131
|
+
}
|
|
132
|
+
return nonGlobParts.length > 0 ? nonGlobParts.join('/') : null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Extract token usage from response
|
|
136
|
+
*/
|
|
137
|
+
export function extractTokenUsage(response) {
|
|
138
|
+
return {
|
|
139
|
+
inputTokens: response.usage.input_tokens,
|
|
140
|
+
outputTokens: response.usage.output_tokens,
|
|
141
|
+
totalTokens: response.usage.input_tokens + response.usage.output_tokens
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Check if response contains any file-modifying actions
|
|
146
|
+
*/
|
|
147
|
+
export function hasModifyingActions(actions) {
|
|
148
|
+
return actions.some(a => a.actionType === 'edit' ||
|
|
149
|
+
a.actionType === 'write' ||
|
|
150
|
+
(a.actionType === 'bash' && a.command && isModifyingBashCommand(a.command)));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Check if bash command modifies files
|
|
154
|
+
*/
|
|
155
|
+
function isModifyingBashCommand(command) {
|
|
156
|
+
const modifyingPatterns = [
|
|
157
|
+
/\brm\b/,
|
|
158
|
+
/\bmv\b/,
|
|
159
|
+
/\bcp\b/,
|
|
160
|
+
/\bmkdir\b/,
|
|
161
|
+
/\btouch\b/,
|
|
162
|
+
/\bchmod\b/,
|
|
163
|
+
/\bchown\b/,
|
|
164
|
+
/\bsed\b.*-i/,
|
|
165
|
+
/\btee\b/,
|
|
166
|
+
/>/, // redirect
|
|
167
|
+
/\bgit\s+(add|commit|push|checkout|reset)/,
|
|
168
|
+
/\bnpm\s+(install|uninstall)/,
|
|
169
|
+
/\byarn\s+(add|remove)/,
|
|
170
|
+
];
|
|
171
|
+
return modifyingPatterns.some(p => p.test(command));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Get all unique files from actions
|
|
175
|
+
*/
|
|
176
|
+
export function getAllFiles(actions) {
|
|
177
|
+
const files = new Set();
|
|
178
|
+
for (const action of actions) {
|
|
179
|
+
for (const file of action.files) {
|
|
180
|
+
files.add(file);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return [...files];
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Get all unique folders from actions
|
|
187
|
+
*/
|
|
188
|
+
export function getAllFolders(actions) {
|
|
189
|
+
const folders = new Set();
|
|
190
|
+
for (const action of actions) {
|
|
191
|
+
for (const folder of action.folders) {
|
|
192
|
+
folders.add(folder);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return [...folders];
|
|
196
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const config: {
|
|
2
|
+
HOST: string;
|
|
3
|
+
PORT: number;
|
|
4
|
+
ANTHROPIC_BASE_URL: string;
|
|
5
|
+
REQUEST_TIMEOUT: number;
|
|
6
|
+
BODY_LIMIT: number;
|
|
7
|
+
DRIFT_CHECK_INTERVAL: number;
|
|
8
|
+
TOKEN_WARNING_THRESHOLD: number;
|
|
9
|
+
TOKEN_CLEAR_THRESHOLD: number;
|
|
10
|
+
ENABLE_AUTH: boolean;
|
|
11
|
+
ENABLE_RATE_LIMIT: boolean;
|
|
12
|
+
ENABLE_TLS: boolean;
|
|
13
|
+
LOG_LEVEL: string;
|
|
14
|
+
LOG_REQUESTS: boolean;
|
|
15
|
+
};
|
|
16
|
+
export declare const FORWARD_HEADERS: string[];
|
|
17
|
+
export declare const SENSITIVE_HEADERS: string[];
|
|
18
|
+
/**
|
|
19
|
+
* Mask sensitive header value for logging
|
|
20
|
+
*/
|
|
21
|
+
export declare function maskSensitiveValue(key: string, value: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Build safe headers for forwarding
|
|
24
|
+
* Handles case-insensitive header matching
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildSafeHeaders(incomingHeaders: Record<string, string | string[] | undefined>): Record<string, string>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Proxy configuration
|
|
2
|
+
export const config = {
|
|
3
|
+
// Server
|
|
4
|
+
HOST: process.env.PROXY_HOST || '127.0.0.1',
|
|
5
|
+
PORT: parseInt(process.env.PROXY_PORT || '8080', 10),
|
|
6
|
+
// Anthropic target
|
|
7
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_TARGET || 'https://api.anthropic.com',
|
|
8
|
+
// Timeouts
|
|
9
|
+
REQUEST_TIMEOUT: parseInt(process.env.REQUEST_TIMEOUT || '300000', 10), // 5 minutes
|
|
10
|
+
BODY_LIMIT: parseInt(process.env.BODY_LIMIT || '10485760', 10), // 10MB
|
|
11
|
+
// Drift settings
|
|
12
|
+
DRIFT_CHECK_INTERVAL: parseInt(process.env.DRIFT_CHECK_INTERVAL || '3', 10),
|
|
13
|
+
TOKEN_WARNING_THRESHOLD: parseInt(process.env.TOKEN_WARNING_THRESHOLD || '160000', 10), // 80%
|
|
14
|
+
TOKEN_CLEAR_THRESHOLD: parseInt(process.env.TOKEN_CLEAR_THRESHOLD || '180000', 10), // 90%
|
|
15
|
+
// Security (Phase 2 - disabled for local)
|
|
16
|
+
ENABLE_AUTH: process.env.ENABLE_AUTH === 'true',
|
|
17
|
+
ENABLE_RATE_LIMIT: process.env.ENABLE_RATE_LIMIT === 'true',
|
|
18
|
+
ENABLE_TLS: process.env.ENABLE_TLS === 'true',
|
|
19
|
+
// Logging
|
|
20
|
+
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
|
|
21
|
+
LOG_REQUESTS: process.env.LOG_REQUESTS !== 'false',
|
|
22
|
+
};
|
|
23
|
+
// Headers to forward to Anthropic (whitelist approach)
|
|
24
|
+
export const FORWARD_HEADERS = [
|
|
25
|
+
'x-api-key',
|
|
26
|
+
'authorization', // Claude Code uses this instead of x-api-key
|
|
27
|
+
'anthropic-version',
|
|
28
|
+
'content-type',
|
|
29
|
+
'anthropic-beta',
|
|
30
|
+
];
|
|
31
|
+
// Headers to never log
|
|
32
|
+
export const SENSITIVE_HEADERS = [
|
|
33
|
+
'x-api-key',
|
|
34
|
+
'authorization',
|
|
35
|
+
];
|
|
36
|
+
/**
|
|
37
|
+
* Mask sensitive header value for logging
|
|
38
|
+
*/
|
|
39
|
+
export function maskSensitiveValue(key, value) {
|
|
40
|
+
const lowerKey = key.toLowerCase();
|
|
41
|
+
if (SENSITIVE_HEADERS.includes(lowerKey)) {
|
|
42
|
+
if (value.length <= 10) {
|
|
43
|
+
return '***';
|
|
44
|
+
}
|
|
45
|
+
return value.substring(0, 7) + '...' + value.substring(value.length - 4);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Build safe headers for forwarding
|
|
51
|
+
* Handles case-insensitive header matching
|
|
52
|
+
*/
|
|
53
|
+
export function buildSafeHeaders(incomingHeaders) {
|
|
54
|
+
const safe = {};
|
|
55
|
+
// Create lowercase map of incoming headers
|
|
56
|
+
const lowerHeaders = {};
|
|
57
|
+
for (const [key, value] of Object.entries(incomingHeaders)) {
|
|
58
|
+
lowerHeaders[key.toLowerCase()] = value;
|
|
59
|
+
}
|
|
60
|
+
for (const header of FORWARD_HEADERS) {
|
|
61
|
+
const value = lowerHeaders[header.toLowerCase()];
|
|
62
|
+
if (value) {
|
|
63
|
+
safe[header] = Array.isArray(value) ? value[0] : value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return safe;
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnthropicResponse } from './action-parser.js';
|
|
2
|
+
export interface ForwardResult {
|
|
3
|
+
statusCode: number;
|
|
4
|
+
headers: Record<string, string | string[]>;
|
|
5
|
+
body: AnthropicResponse | Record<string, unknown>;
|
|
6
|
+
rawBody: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ForwardError {
|
|
9
|
+
type: 'timeout' | 'network' | 'parse' | 'unknown';
|
|
10
|
+
message: string;
|
|
11
|
+
statusCode?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Forward request to Anthropic API
|
|
15
|
+
* Buffers full response for processing
|
|
16
|
+
*/
|
|
17
|
+
export declare function forwardToAnthropic(body: Record<string, unknown>, headers: Record<string, string | string[] | undefined>, logger?: {
|
|
18
|
+
info: (msg: string, data?: Record<string, unknown>) => void;
|
|
19
|
+
error: (msg: string, data?: Record<string, unknown>) => void;
|
|
20
|
+
}): Promise<ForwardResult>;
|
|
21
|
+
/**
|
|
22
|
+
* Check if error is a ForwardError
|
|
23
|
+
*/
|
|
24
|
+
export declare function isForwardError(error: unknown): error is ForwardError;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Forward requests to Anthropic API using undici
|
|
2
|
+
import { request, Agent } from 'undici';
|
|
3
|
+
// Custom agent with longer connect timeout and better IPv4/IPv6 handling
|
|
4
|
+
const agent = new Agent({
|
|
5
|
+
connect: {
|
|
6
|
+
timeout: 30000, // 30s connect timeout
|
|
7
|
+
},
|
|
8
|
+
// autoSelectFamily helps when IPv6 isn't working properly
|
|
9
|
+
autoSelectFamily: true,
|
|
10
|
+
autoSelectFamilyAttemptTimeout: 500, // Try next address family after 500ms
|
|
11
|
+
});
|
|
12
|
+
import { config, buildSafeHeaders, maskSensitiveValue } from './config.js';
|
|
13
|
+
/**
|
|
14
|
+
* Forward request to Anthropic API
|
|
15
|
+
* Buffers full response for processing
|
|
16
|
+
*/
|
|
17
|
+
export async function forwardToAnthropic(body, headers, logger) {
|
|
18
|
+
const targetUrl = `${config.ANTHROPIC_BASE_URL}/v1/messages`;
|
|
19
|
+
const safeHeaders = buildSafeHeaders(headers);
|
|
20
|
+
// Log request (mask sensitive data)
|
|
21
|
+
if (logger && config.LOG_REQUESTS) {
|
|
22
|
+
const maskedHeaders = {};
|
|
23
|
+
for (const [key, value] of Object.entries(safeHeaders)) {
|
|
24
|
+
maskedHeaders[key] = maskSensitiveValue(key, value);
|
|
25
|
+
}
|
|
26
|
+
logger.info('Forwarding to Anthropic', {
|
|
27
|
+
url: targetUrl,
|
|
28
|
+
model: body.model,
|
|
29
|
+
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
30
|
+
headers: maskedHeaders,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const response = await request(targetUrl, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: {
|
|
37
|
+
...safeHeaders,
|
|
38
|
+
'content-type': 'application/json',
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify(body),
|
|
41
|
+
bodyTimeout: config.REQUEST_TIMEOUT,
|
|
42
|
+
headersTimeout: config.REQUEST_TIMEOUT,
|
|
43
|
+
dispatcher: agent,
|
|
44
|
+
});
|
|
45
|
+
// Buffer the full response
|
|
46
|
+
const chunks = [];
|
|
47
|
+
for await (const chunk of response.body) {
|
|
48
|
+
chunks.push(Buffer.from(chunk));
|
|
49
|
+
}
|
|
50
|
+
const rawBody = Buffer.concat(chunks).toString('utf-8');
|
|
51
|
+
// Parse response
|
|
52
|
+
let parsedBody;
|
|
53
|
+
try {
|
|
54
|
+
parsedBody = JSON.parse(rawBody);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Return raw body if not JSON
|
|
58
|
+
parsedBody = { error: 'Invalid JSON response', raw: rawBody.substring(0, 500) };
|
|
59
|
+
}
|
|
60
|
+
// Convert headers to record
|
|
61
|
+
const responseHeaders = {};
|
|
62
|
+
for (const [key, value] of Object.entries(response.headers)) {
|
|
63
|
+
if (value !== undefined) {
|
|
64
|
+
responseHeaders[key] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (logger && config.LOG_REQUESTS) {
|
|
68
|
+
logger.info('Received from Anthropic', {
|
|
69
|
+
statusCode: response.statusCode,
|
|
70
|
+
bodyLength: rawBody.length,
|
|
71
|
+
hasUsage: 'usage' in parsedBody,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
statusCode: response.statusCode,
|
|
76
|
+
headers: responseHeaders,
|
|
77
|
+
body: parsedBody,
|
|
78
|
+
rawBody,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const err = error;
|
|
83
|
+
if (logger) {
|
|
84
|
+
logger.error('Forward error', {
|
|
85
|
+
message: err.message,
|
|
86
|
+
code: err.code,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
// Handle specific error types
|
|
90
|
+
if (err.code === 'UND_ERR_HEADERS_TIMEOUT' || err.code === 'UND_ERR_BODY_TIMEOUT') {
|
|
91
|
+
throw createForwardError('timeout', 'Request to Anthropic timed out', 504);
|
|
92
|
+
}
|
|
93
|
+
if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') {
|
|
94
|
+
throw createForwardError('network', 'Cannot connect to Anthropic API', 502);
|
|
95
|
+
}
|
|
96
|
+
if (err.code === 'ECONNRESET' || err.message?.includes('ECONNRESET')) {
|
|
97
|
+
throw createForwardError('network', 'Connection reset by Anthropic API', 502);
|
|
98
|
+
}
|
|
99
|
+
if (err.code === 'UND_ERR_CONNECT_TIMEOUT' || err.message?.includes('Connect Timeout')) {
|
|
100
|
+
throw createForwardError('timeout', 'Connection to Anthropic API timed out', 504);
|
|
101
|
+
}
|
|
102
|
+
throw createForwardError('unknown', err.message || 'Unknown error', 502);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Create a typed forward error
|
|
107
|
+
*/
|
|
108
|
+
function createForwardError(type, message, statusCode) {
|
|
109
|
+
return { type, message, statusCode };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Check if error is a ForwardError
|
|
113
|
+
*/
|
|
114
|
+
export function isForwardError(error) {
|
|
115
|
+
return (typeof error === 'object' &&
|
|
116
|
+
error !== null &&
|
|
117
|
+
'type' in error &&
|
|
118
|
+
'message' in error);
|
|
119
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Grov Proxy CLI entry point
|
|
2
|
+
// Load .env file for API keys
|
|
3
|
+
import { config } from 'dotenv';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
// Load from current directory .env first
|
|
8
|
+
config();
|
|
9
|
+
// Also load ~/.grov/.env as fallback
|
|
10
|
+
const grovEnvPath = join(homedir(), '.grov', '.env');
|
|
11
|
+
if (existsSync(grovEnvPath)) {
|
|
12
|
+
config({ path: grovEnvPath });
|
|
13
|
+
}
|
|
14
|
+
import { startServer } from './server.js';
|
|
15
|
+
// Check for API key before starting proxy
|
|
16
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
17
|
+
console.error('Error: ANTHROPIC_API_KEY is required to run the proxy.\n');
|
|
18
|
+
console.error('To set it up:\n');
|
|
19
|
+
console.error(' 1. Get your API key at:');
|
|
20
|
+
console.error(' https://console.anthropic.com/settings/keys\n');
|
|
21
|
+
console.error(' 2. Add to ~/.zshrc (or ~/.bashrc):');
|
|
22
|
+
console.error(' export ANTHROPIC_API_KEY=sk-ant-...\n');
|
|
23
|
+
console.error(' 3. Restart terminal or run: source ~/.zshrc\n');
|
|
24
|
+
console.error('Then try again: grov proxy');
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
startServer().catch((err) => {
|
|
28
|
+
console.error('Proxy failed:', err);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build context from team memory for injection
|
|
3
|
+
* Queries tasks and file_reasoning tables
|
|
4
|
+
*/
|
|
5
|
+
export declare function buildTeamMemoryContext(projectPath: string, mentionedFiles: string[]): string | null;
|
|
6
|
+
/**
|
|
7
|
+
* Extract file paths from messages
|
|
8
|
+
*/
|
|
9
|
+
export declare function extractFilesFromMessages(messages: Array<{
|
|
10
|
+
role: string;
|
|
11
|
+
content: unknown;
|
|
12
|
+
}>): string[];
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Request processor - handles context injection from team memory
|
|
2
|
+
// Reference: plan_proxy_local.md Section 2.1
|
|
3
|
+
import { getTasksForProject, getTasksByFiles, getStepsReasoningByPath, } from '../lib/store.js';
|
|
4
|
+
import { truncate } from '../lib/utils.js';
|
|
5
|
+
/**
|
|
6
|
+
* Build context from team memory for injection
|
|
7
|
+
* Queries tasks and file_reasoning tables
|
|
8
|
+
*/
|
|
9
|
+
export function buildTeamMemoryContext(projectPath, mentionedFiles) {
|
|
10
|
+
// Get recent completed tasks for this project
|
|
11
|
+
const tasks = getTasksForProject(projectPath, {
|
|
12
|
+
status: 'complete',
|
|
13
|
+
limit: 10,
|
|
14
|
+
});
|
|
15
|
+
// Get tasks that touched mentioned files
|
|
16
|
+
const fileTasks = mentionedFiles.length > 0
|
|
17
|
+
? getTasksByFiles(projectPath, mentionedFiles, { status: 'complete', limit: 5 })
|
|
18
|
+
: [];
|
|
19
|
+
// Get file-level reasoning from steps table (proxy version)
|
|
20
|
+
const fileReasonings = mentionedFiles.length > 0
|
|
21
|
+
? mentionedFiles.flatMap(f => getStepsReasoningByPath(f, 3))
|
|
22
|
+
: [];
|
|
23
|
+
// Combine unique tasks
|
|
24
|
+
const allTasks = [...new Map([...tasks, ...fileTasks].map(t => [t.id, t])).values()];
|
|
25
|
+
if (allTasks.length === 0 && fileReasonings.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return formatTeamMemoryContext(allTasks, fileReasonings, mentionedFiles);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Format team memory context for injection
|
|
32
|
+
*/
|
|
33
|
+
function formatTeamMemoryContext(tasks, fileReasonings, files) {
|
|
34
|
+
const lines = [];
|
|
35
|
+
lines.push('[GROV CONTEXT - Relevant past reasoning]');
|
|
36
|
+
lines.push('');
|
|
37
|
+
// File-level context
|
|
38
|
+
if (fileReasonings.length > 0) {
|
|
39
|
+
lines.push('File-level context:');
|
|
40
|
+
for (const fr of fileReasonings.slice(0, 5)) {
|
|
41
|
+
const anchor = fr.anchor ? ` (${fr.anchor})` : '';
|
|
42
|
+
lines.push(`- ${fr.file_path}${anchor}: ${truncate(fr.reasoning, 100)}`);
|
|
43
|
+
}
|
|
44
|
+
lines.push('');
|
|
45
|
+
}
|
|
46
|
+
// Task context with decisions and constraints
|
|
47
|
+
if (tasks.length > 0) {
|
|
48
|
+
lines.push('Related past tasks:');
|
|
49
|
+
for (const task of tasks.slice(0, 5)) {
|
|
50
|
+
lines.push(`- ${truncate(task.original_query, 60)}`);
|
|
51
|
+
if (task.files_touched.length > 0) {
|
|
52
|
+
const fileList = task.files_touched.slice(0, 3).map(f => f.split('/').pop()).join(', ');
|
|
53
|
+
lines.push(` Files: ${fileList}`);
|
|
54
|
+
}
|
|
55
|
+
if (task.reasoning_trace.length > 0) {
|
|
56
|
+
lines.push(` Key: ${truncate(task.reasoning_trace[0], 80)}`);
|
|
57
|
+
}
|
|
58
|
+
// Include decisions if available
|
|
59
|
+
if (task.decisions && task.decisions.length > 0) {
|
|
60
|
+
lines.push(` Decision: ${task.decisions[0].choice} (${truncate(task.decisions[0].reason, 50)})`);
|
|
61
|
+
}
|
|
62
|
+
// Include constraints if available
|
|
63
|
+
if (task.constraints && task.constraints.length > 0) {
|
|
64
|
+
lines.push(` Constraints: ${task.constraints.slice(0, 2).join(', ')}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
lines.push('');
|
|
68
|
+
}
|
|
69
|
+
if (files.length > 0) {
|
|
70
|
+
lines.push(`You may already have context for: ${files.join(', ')}`);
|
|
71
|
+
}
|
|
72
|
+
lines.push('[END GROV CONTEXT]');
|
|
73
|
+
return lines.join('\n');
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Extract file paths from messages
|
|
77
|
+
*/
|
|
78
|
+
export function extractFilesFromMessages(messages) {
|
|
79
|
+
const files = [];
|
|
80
|
+
const filePattern = /(?:^|\s|["'`])([\/\w.-]+\.[a-zA-Z]{1,10})(?:["'`]|\s|$|:|\))/g;
|
|
81
|
+
for (const msg of messages) {
|
|
82
|
+
if (typeof msg.content === 'string') {
|
|
83
|
+
let match;
|
|
84
|
+
while ((match = filePattern.exec(msg.content)) !== null) {
|
|
85
|
+
const path = match[1];
|
|
86
|
+
// Filter out common false positives
|
|
87
|
+
if (!path.includes('http') && !path.startsWith('.') && path.length > 3) {
|
|
88
|
+
files.push(path);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return [...new Set(files)];
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type TriggerReason } from '../lib/store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Save session to team memory
|
|
4
|
+
* Called on: task complete, subtask complete, session abandoned
|
|
5
|
+
*/
|
|
6
|
+
export declare function saveToTeamMemory(sessionId: string, triggerReason: TriggerReason): Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Clean up session data after save
|
|
9
|
+
*/
|
|
10
|
+
export declare function cleanupSession(sessionId: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Save and cleanup session (for session end)
|
|
13
|
+
*/
|
|
14
|
+
export declare function saveAndCleanupSession(sessionId: string, triggerReason: TriggerReason): Promise<void>;
|