@link-assistant/agent 0.16.17 → 0.17.0
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/package.json +1 -1
- package/src/auth/claude-oauth.ts +5 -3
- package/src/auth/plugins.ts +56 -48
- package/src/cli/cmd/auth.ts +6 -3
- package/src/cli/continuous-mode.js +5 -1
- package/src/config/config.ts +5 -3
- package/src/file/ripgrep.ts +3 -1
- package/src/flag/flag.ts +13 -7
- package/src/index.js +19 -4
- package/src/provider/google-cloudcode.ts +4 -2
- package/src/provider/models.ts +3 -1
- package/src/provider/provider.ts +130 -51
- package/src/session/compaction.ts +88 -1
- package/src/session/message-v2.ts +24 -0
- package/src/session/processor.ts +18 -0
- package/src/session/summary.ts +121 -22
- package/src/tool/codesearch.ts +4 -1
- package/src/tool/webfetch.ts +4 -1
- package/src/tool/websearch.ts +4 -1
- package/src/util/verbose-fetch.ts +303 -0
package/src/tool/codesearch.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import z from 'zod';
|
|
2
2
|
import { Tool } from './tool';
|
|
3
3
|
import DESCRIPTION from './codesearch.txt';
|
|
4
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
5
|
+
|
|
6
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'codesearch' });
|
|
4
7
|
|
|
5
8
|
const API_CONFIG = {
|
|
6
9
|
BASE_URL: 'https://mcp.exa.ai',
|
|
@@ -73,7 +76,7 @@ export const CodeSearchTool = Tool.define('codesearch', {
|
|
|
73
76
|
'content-type': 'application/json',
|
|
74
77
|
};
|
|
75
78
|
|
|
76
|
-
const response = await
|
|
79
|
+
const response = await verboseFetch(
|
|
77
80
|
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTEXT}`,
|
|
78
81
|
{
|
|
79
82
|
method: 'POST',
|
package/src/tool/webfetch.ts
CHANGED
|
@@ -2,6 +2,9 @@ import z from 'zod';
|
|
|
2
2
|
import { Tool } from './tool';
|
|
3
3
|
import TurndownService from 'turndown';
|
|
4
4
|
import DESCRIPTION from './webfetch.txt';
|
|
5
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
|
+
|
|
7
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'webfetch' });
|
|
5
8
|
|
|
6
9
|
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
7
10
|
const DEFAULT_TIMEOUT = 30 * 1000; // 30 seconds
|
|
@@ -59,7 +62,7 @@ export const WebFetchTool = Tool.define('webfetch', {
|
|
|
59
62
|
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8';
|
|
60
63
|
}
|
|
61
64
|
|
|
62
|
-
const response = await
|
|
65
|
+
const response = await verboseFetch(params.url, {
|
|
63
66
|
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
|
64
67
|
headers: {
|
|
65
68
|
'User-Agent':
|
package/src/tool/websearch.ts
CHANGED
|
@@ -2,6 +2,9 @@ import z from 'zod';
|
|
|
2
2
|
import { Tool } from './tool';
|
|
3
3
|
import DESCRIPTION from './websearch.txt';
|
|
4
4
|
import { Config } from '../config/config';
|
|
5
|
+
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
|
+
|
|
7
|
+
const verboseFetch = createVerboseFetch(fetch, { caller: 'websearch' });
|
|
5
8
|
|
|
6
9
|
const API_CONFIG = {
|
|
7
10
|
BASE_URL: 'https://mcp.exa.ai',
|
|
@@ -91,7 +94,7 @@ export const WebSearchTool = Tool.define('websearch', {
|
|
|
91
94
|
'content-type': 'application/json',
|
|
92
95
|
};
|
|
93
96
|
|
|
94
|
-
const response = await
|
|
97
|
+
const response = await verboseFetch(
|
|
95
98
|
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`,
|
|
96
99
|
{
|
|
97
100
|
method: 'POST',
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { Log } from './log';
|
|
2
|
+
import { Flag } from '../flag/flag';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared verbose HTTP fetch wrapper.
|
|
6
|
+
*
|
|
7
|
+
* Intercepts fetch() calls and logs request/response details as JSON objects
|
|
8
|
+
* when --verbose mode is enabled. Used across the entire codebase (tools, auth,
|
|
9
|
+
* config, providers) to ensure uniform and predictable HTTP logging.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Logs HTTP request: method, URL, sanitized headers, body preview
|
|
13
|
+
* - Logs HTTP response: status, headers, duration, body preview
|
|
14
|
+
* - Logs HTTP errors: stack trace, error cause chain
|
|
15
|
+
* - Sequential call numbering for correlation
|
|
16
|
+
* - Error-resilient: logging failures never break the actual HTTP request
|
|
17
|
+
* - Runtime verbose check: respects Flag.OPENCODE_VERBOSE at call time
|
|
18
|
+
*
|
|
19
|
+
* @see https://github.com/link-assistant/agent/issues/215
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const log = Log.create({ service: 'http' });
|
|
23
|
+
|
|
24
|
+
/** Global call counter shared across all verbose fetch wrappers */
|
|
25
|
+
let globalHttpCallCount = 0;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Sanitize HTTP headers by masking sensitive values.
|
|
29
|
+
* Masks authorization, x-api-key, and api-key headers.
|
|
30
|
+
*/
|
|
31
|
+
export function sanitizeHeaders(
|
|
32
|
+
rawHeaders: HeadersInit | Record<string, string> | Headers | undefined
|
|
33
|
+
): Record<string, string> {
|
|
34
|
+
const sanitized: Record<string, string> = {};
|
|
35
|
+
if (!rawHeaders) return sanitized;
|
|
36
|
+
|
|
37
|
+
const entries =
|
|
38
|
+
rawHeaders instanceof Headers
|
|
39
|
+
? Array.from(rawHeaders.entries())
|
|
40
|
+
: Array.isArray(rawHeaders)
|
|
41
|
+
? rawHeaders
|
|
42
|
+
: Object.entries(rawHeaders);
|
|
43
|
+
|
|
44
|
+
for (const [key, value] of entries) {
|
|
45
|
+
const lower = key.toLowerCase();
|
|
46
|
+
if (
|
|
47
|
+
lower === 'authorization' ||
|
|
48
|
+
lower === 'x-api-key' ||
|
|
49
|
+
lower === 'api-key'
|
|
50
|
+
) {
|
|
51
|
+
sanitized[key] =
|
|
52
|
+
typeof value === 'string' && value.length > 8
|
|
53
|
+
? value.slice(0, 4) + '...' + value.slice(-4)
|
|
54
|
+
: '[REDACTED]';
|
|
55
|
+
} else {
|
|
56
|
+
sanitized[key] = String(value);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return sanitized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a body preview string, truncated to maxChars.
|
|
64
|
+
*/
|
|
65
|
+
export function bodyPreview(
|
|
66
|
+
body: BodyInit | null | undefined,
|
|
67
|
+
maxChars = 200000
|
|
68
|
+
): string | undefined {
|
|
69
|
+
if (!body) return undefined;
|
|
70
|
+
|
|
71
|
+
const bodyStr =
|
|
72
|
+
typeof body === 'string'
|
|
73
|
+
? body
|
|
74
|
+
: body instanceof ArrayBuffer || body instanceof Uint8Array
|
|
75
|
+
? `[binary ${(body as ArrayBuffer).byteLength ?? (body as Uint8Array).length} bytes]`
|
|
76
|
+
: body instanceof URLSearchParams
|
|
77
|
+
? body.toString()
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
if (bodyStr && typeof bodyStr === 'string') {
|
|
81
|
+
return bodyStr.length > maxChars
|
|
82
|
+
? bodyStr.slice(0, maxChars) +
|
|
83
|
+
`... [truncated, total ${bodyStr.length} chars]`
|
|
84
|
+
: bodyStr;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface VerboseFetchOptions {
|
|
90
|
+
/** Identifier for the caller (e.g. 'webfetch', 'auth-plugins', 'config') */
|
|
91
|
+
caller: string;
|
|
92
|
+
/** Maximum chars for response body preview (default: 200000) */
|
|
93
|
+
responseBodyMaxChars?: number;
|
|
94
|
+
/** Maximum chars for request body preview (default: 200000) */
|
|
95
|
+
requestBodyMaxChars?: number;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Wrap a fetch function with verbose HTTP logging.
|
|
100
|
+
*
|
|
101
|
+
* When Flag.OPENCODE_VERBOSE is true, logs all HTTP requests and responses
|
|
102
|
+
* as JSON objects. When verbose is false, returns a no-op passthrough.
|
|
103
|
+
*
|
|
104
|
+
* All logging is wrapped in try/catch so it never breaks the actual HTTP request.
|
|
105
|
+
*
|
|
106
|
+
* @param innerFetch - The fetch function to wrap (defaults to global fetch)
|
|
107
|
+
* @param options - Configuration for the wrapper
|
|
108
|
+
* @returns A wrapped fetch function with verbose logging
|
|
109
|
+
*/
|
|
110
|
+
export function createVerboseFetch(
|
|
111
|
+
innerFetch: typeof fetch = fetch,
|
|
112
|
+
options: VerboseFetchOptions
|
|
113
|
+
): typeof fetch {
|
|
114
|
+
const {
|
|
115
|
+
caller,
|
|
116
|
+
responseBodyMaxChars = 200000,
|
|
117
|
+
requestBodyMaxChars = 200000,
|
|
118
|
+
} = options;
|
|
119
|
+
|
|
120
|
+
return async (
|
|
121
|
+
input: RequestInfo | URL,
|
|
122
|
+
init?: RequestInit
|
|
123
|
+
): Promise<Response> => {
|
|
124
|
+
// Check verbose flag at call time
|
|
125
|
+
if (!Flag.OPENCODE_VERBOSE) {
|
|
126
|
+
return innerFetch(input, init);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
globalHttpCallCount++;
|
|
130
|
+
const callNum = globalHttpCallCount;
|
|
131
|
+
|
|
132
|
+
const url =
|
|
133
|
+
typeof input === 'string'
|
|
134
|
+
? input
|
|
135
|
+
: input instanceof URL
|
|
136
|
+
? input.toString()
|
|
137
|
+
: input.url;
|
|
138
|
+
const method = init?.method ?? 'GET';
|
|
139
|
+
|
|
140
|
+
// Prepare request details for logging (error-resilient)
|
|
141
|
+
let sanitizedHdrs: Record<string, string> = {};
|
|
142
|
+
let reqBodyPreview: string | undefined;
|
|
143
|
+
try {
|
|
144
|
+
sanitizedHdrs = sanitizeHeaders(init?.headers as HeadersInit | undefined);
|
|
145
|
+
reqBodyPreview = bodyPreview(init?.body, requestBodyMaxChars);
|
|
146
|
+
} catch (prepError) {
|
|
147
|
+
log.warn('verbose logging: failed to prepare request details', {
|
|
148
|
+
caller,
|
|
149
|
+
error:
|
|
150
|
+
prepError instanceof Error ? prepError.message : String(prepError),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Log request
|
|
155
|
+
log.info('HTTP request', {
|
|
156
|
+
caller,
|
|
157
|
+
callNum,
|
|
158
|
+
method,
|
|
159
|
+
url,
|
|
160
|
+
headers: sanitizedHdrs,
|
|
161
|
+
bodyPreview: reqBodyPreview,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const startMs = Date.now();
|
|
165
|
+
try {
|
|
166
|
+
const response = await innerFetch(input, init);
|
|
167
|
+
const durationMs = Date.now() - startMs;
|
|
168
|
+
|
|
169
|
+
// Log response
|
|
170
|
+
try {
|
|
171
|
+
log.info('HTTP response', {
|
|
172
|
+
caller,
|
|
173
|
+
callNum,
|
|
174
|
+
method,
|
|
175
|
+
url,
|
|
176
|
+
status: response.status,
|
|
177
|
+
statusText: response.statusText,
|
|
178
|
+
durationMs,
|
|
179
|
+
responseHeaders: Object.fromEntries(response.headers.entries()),
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
// Ignore logging errors
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Log response body
|
|
186
|
+
const contentType = response.headers.get('content-type') ?? '';
|
|
187
|
+
const isStreaming =
|
|
188
|
+
contentType.includes('event-stream') ||
|
|
189
|
+
contentType.includes('octet-stream');
|
|
190
|
+
|
|
191
|
+
if (response.body) {
|
|
192
|
+
try {
|
|
193
|
+
if (isStreaming) {
|
|
194
|
+
const [sdkStream, logStream] = response.body.tee();
|
|
195
|
+
|
|
196
|
+
// Consume log stream asynchronously
|
|
197
|
+
(async () => {
|
|
198
|
+
try {
|
|
199
|
+
const reader = logStream.getReader();
|
|
200
|
+
const decoder = new TextDecoder();
|
|
201
|
+
let preview = '';
|
|
202
|
+
let truncated = false;
|
|
203
|
+
while (true) {
|
|
204
|
+
const { done, value } = await reader.read();
|
|
205
|
+
if (done) break;
|
|
206
|
+
if (!truncated) {
|
|
207
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
208
|
+
preview += chunk;
|
|
209
|
+
if (preview.length > responseBodyMaxChars) {
|
|
210
|
+
preview = preview.slice(0, responseBodyMaxChars);
|
|
211
|
+
truncated = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
log.info('HTTP response body (stream)', {
|
|
216
|
+
caller,
|
|
217
|
+
callNum,
|
|
218
|
+
url,
|
|
219
|
+
bodyPreview: truncated
|
|
220
|
+
? preview + `... [truncated]`
|
|
221
|
+
: preview,
|
|
222
|
+
});
|
|
223
|
+
} catch {
|
|
224
|
+
// Ignore logging errors
|
|
225
|
+
}
|
|
226
|
+
})();
|
|
227
|
+
|
|
228
|
+
return new Response(sdkStream, {
|
|
229
|
+
status: response.status,
|
|
230
|
+
statusText: response.statusText,
|
|
231
|
+
headers: response.headers,
|
|
232
|
+
});
|
|
233
|
+
} else {
|
|
234
|
+
const bodyText = await response.text();
|
|
235
|
+
const preview =
|
|
236
|
+
bodyText.length > responseBodyMaxChars
|
|
237
|
+
? bodyText.slice(0, responseBodyMaxChars) +
|
|
238
|
+
`... [truncated, total ${bodyText.length} chars]`
|
|
239
|
+
: bodyText;
|
|
240
|
+
log.info('HTTP response body', {
|
|
241
|
+
caller,
|
|
242
|
+
callNum,
|
|
243
|
+
url,
|
|
244
|
+
bodyPreview: preview,
|
|
245
|
+
});
|
|
246
|
+
return new Response(bodyText, {
|
|
247
|
+
status: response.status,
|
|
248
|
+
statusText: response.statusText,
|
|
249
|
+
headers: response.headers,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// If body logging fails, return original response
|
|
254
|
+
return response;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return response;
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const durationMs = Date.now() - startMs;
|
|
261
|
+
try {
|
|
262
|
+
log.error('HTTP request failed', {
|
|
263
|
+
caller,
|
|
264
|
+
callNum,
|
|
265
|
+
method,
|
|
266
|
+
url,
|
|
267
|
+
durationMs,
|
|
268
|
+
error:
|
|
269
|
+
error instanceof Error
|
|
270
|
+
? {
|
|
271
|
+
name: error.name,
|
|
272
|
+
message: error.message,
|
|
273
|
+
stack: error.stack,
|
|
274
|
+
cause:
|
|
275
|
+
error.cause instanceof Error
|
|
276
|
+
? error.cause.message
|
|
277
|
+
: error.cause
|
|
278
|
+
? String(error.cause)
|
|
279
|
+
: undefined,
|
|
280
|
+
}
|
|
281
|
+
: String(error),
|
|
282
|
+
});
|
|
283
|
+
} catch {
|
|
284
|
+
// Ignore logging errors
|
|
285
|
+
}
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Get the current global HTTP call count (for testing).
|
|
293
|
+
*/
|
|
294
|
+
export function getHttpCallCount(): number {
|
|
295
|
+
return globalHttpCallCount;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Reset the global HTTP call count (for testing).
|
|
300
|
+
*/
|
|
301
|
+
export function resetHttpCallCount(): void {
|
|
302
|
+
globalHttpCallCount = 0;
|
|
303
|
+
}
|