@link-assistant/agent 0.27.0 → 0.27.1
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 +47 -5
- package/package.json +41 -42
- package/src/cli/argv.ts +1 -1
- package/src/cli/cmd/run.ts +7 -1
- package/src/cli/continuous-mode.js +38 -12
- package/src/cli/event-handler.js +3 -1
- package/src/cli/model-config.js +20 -4
- package/src/cli/run-options.js +144 -78
- package/src/config/config.ts +306 -105
- package/src/config/defaults.ts +13 -5
- package/src/config/file-config.ts +16 -0
- package/src/file/ripgrep.ts +38 -5
- package/src/file/time.ts +86 -9
- package/src/index.js +39 -33
- package/src/mcp/index.ts +370 -106
- package/src/permission/index.ts +22 -0
- package/src/project/instance.ts +5 -1
- package/src/project/project.ts +35 -2
- package/src/provider/free-tier-error.ts +1 -1
- package/src/provider/kilo-deprecation.ts +30 -0
- package/src/provider/kilo.ts +25 -7
- package/src/provider/provider.ts +53 -22
- package/src/provider/retry-fetch.ts +5 -0
- package/src/provider/transform.ts +139 -17
- package/src/session/compaction.ts +18 -2
- package/src/session/index.ts +30 -3
- package/src/session/message-v2.ts +318 -32
- package/src/session/processor.ts +274 -140
- package/src/session/prompt.ts +154 -128
- package/src/session/retry.ts +26 -13
- package/src/session/summary.ts +4 -1
- package/src/tool/bash.ts +232 -45
- package/src/tool/bash.txt +1 -1
- package/src/tool/codesearch.ts +15 -78
- package/src/tool/edit.ts +111 -30
- package/src/tool/glob.ts +2 -1
- package/src/tool/grep.ts +175 -57
- package/src/tool/ls.ts +2 -1
- package/src/tool/multiedit.ts +3 -1
- package/src/tool/read.ts +169 -41
- package/src/tool/task.ts +26 -18
- package/src/tool/tool.ts +4 -1
- package/src/tool/webfetch.ts +68 -56
- package/src/tool/webfetch.txt +4 -4
- package/src/tool/websearch.ts +20 -75
- package/src/tool/websearch.txt +2 -0
- package/src/tool/write.ts +23 -6
- package/src/util/bom.ts +32 -0
- package/src/util/exa-mcp.ts +82 -0
- package/src/util/legacy-error-names.ts +39 -0
- package/src/util/read-bounded.ts +32 -0
- package/src/util/sse-usage-extractor.ts +67 -13
- package/src/util/timeout.ts +3 -4
- package/src/util/verbose-fetch.ts +40 -1
package/src/tool/websearch.ts
CHANGED
|
@@ -3,14 +3,11 @@ import { Tool } from './tool';
|
|
|
3
3
|
import DESCRIPTION from './websearch.txt';
|
|
4
4
|
import { Config } from '../config/file-config';
|
|
5
5
|
import { createVerboseFetch } from '../util/verbose-fetch';
|
|
6
|
+
import { ExaMcp } from '../util/exa-mcp';
|
|
6
7
|
|
|
7
8
|
const verboseFetch = createVerboseFetch(fetch, { caller: 'websearch' });
|
|
8
9
|
|
|
9
10
|
const API_CONFIG = {
|
|
10
|
-
BASE_URL: 'https://mcp.exa.ai',
|
|
11
|
-
ENDPOINTS: {
|
|
12
|
-
SEARCH: '/mcp',
|
|
13
|
-
},
|
|
14
11
|
DEFAULT_NUM_RESULTS: 8,
|
|
15
12
|
} as const;
|
|
16
13
|
|
|
@@ -30,18 +27,13 @@ interface McpSearchRequest {
|
|
|
30
27
|
};
|
|
31
28
|
}
|
|
32
29
|
|
|
33
|
-
interface McpSearchResponse {
|
|
34
|
-
jsonrpc: string;
|
|
35
|
-
result: {
|
|
36
|
-
content: Array<{
|
|
37
|
-
type: string;
|
|
38
|
-
text: string;
|
|
39
|
-
}>;
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
30
|
export const WebSearchTool = Tool.define('websearch', {
|
|
44
|
-
|
|
31
|
+
// The year, not the date, so the prompt cache survives midnight (upstream
|
|
32
|
+
// 1a43e5fe87, 179c40749d #13559). Tool.define copies the object on each
|
|
33
|
+
// init(), so the getter is read per init.
|
|
34
|
+
get description() {
|
|
35
|
+
return DESCRIPTION.replace('{{year}}', String(new Date().getFullYear()));
|
|
36
|
+
},
|
|
45
37
|
parameters: z.object({
|
|
46
38
|
query: z.string().describe('Websearch query'),
|
|
47
39
|
numResults: z
|
|
@@ -85,66 +77,19 @@ export const WebSearchTool = Tool.define('websearch', {
|
|
|
85
77
|
},
|
|
86
78
|
};
|
|
87
79
|
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
};
|
|
96
|
-
|
|
97
|
-
const response = await verboseFetch(
|
|
98
|
-
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.SEARCH}`,
|
|
99
|
-
{
|
|
100
|
-
method: 'POST',
|
|
101
|
-
headers,
|
|
102
|
-
body: JSON.stringify(searchRequest),
|
|
103
|
-
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
|
104
|
-
}
|
|
105
|
-
);
|
|
80
|
+
const output = await ExaMcp.call({
|
|
81
|
+
fetch: verboseFetch,
|
|
82
|
+
body: searchRequest,
|
|
83
|
+
timeout: 25000,
|
|
84
|
+
abort: ctx.abort,
|
|
85
|
+
label: 'Search',
|
|
86
|
+
});
|
|
106
87
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const responseText = await response.text();
|
|
115
|
-
|
|
116
|
-
// Parse SSE response
|
|
117
|
-
const lines = responseText.split('\n');
|
|
118
|
-
for (const line of lines) {
|
|
119
|
-
if (line.startsWith('data: ')) {
|
|
120
|
-
const data: McpSearchResponse = JSON.parse(line.substring(6));
|
|
121
|
-
if (
|
|
122
|
-
data.result &&
|
|
123
|
-
data.result.content &&
|
|
124
|
-
data.result.content.length > 0
|
|
125
|
-
) {
|
|
126
|
-
return {
|
|
127
|
-
output: data.result.content[0].text,
|
|
128
|
-
title: `Web search: ${params.query}`,
|
|
129
|
-
metadata: {},
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
output: 'No search results found. Please try a different query.',
|
|
137
|
-
title: `Web search: ${params.query}`,
|
|
138
|
-
metadata: {},
|
|
139
|
-
};
|
|
140
|
-
} catch (error) {
|
|
141
|
-
clearTimeout(timeoutId);
|
|
142
|
-
|
|
143
|
-
if (error instanceof Error && error.name === 'AbortError') {
|
|
144
|
-
throw new Error('Search request timed out');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
throw error;
|
|
148
|
-
}
|
|
88
|
+
return {
|
|
89
|
+
output:
|
|
90
|
+
output ?? 'No search results found. Please try a different query.',
|
|
91
|
+
title: `Web search: ${params.query}`,
|
|
92
|
+
metadata: {},
|
|
93
|
+
};
|
|
149
94
|
},
|
|
150
95
|
});
|
package/src/tool/websearch.txt
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
- Supports configurable result counts and returns the content from the most relevant websites
|
|
4
4
|
- Use this tool for accessing information beyond knowledge cutoff
|
|
5
5
|
- Searches are performed automatically within a single API call
|
|
6
|
+
- The current year is {{year}}. You MUST use this year when searching for recent information or current events
|
|
7
|
+
- Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025"
|
|
6
8
|
|
|
7
9
|
Usage notes:
|
|
8
10
|
- Supports live crawling modes: 'fallback' (backup if cached unavailable) or 'preferred' (prioritize live crawling)
|
package/src/tool/write.ts
CHANGED
|
@@ -4,6 +4,11 @@ import { Tool } from './tool';
|
|
|
4
4
|
import DESCRIPTION from './write.txt';
|
|
5
5
|
import { Instance } from '../project/instance';
|
|
6
6
|
import { Permission } from '../permission';
|
|
7
|
+
import { FileTime } from '../file/time';
|
|
8
|
+
import { Bus } from '../bus';
|
|
9
|
+
import { File } from '../file';
|
|
10
|
+
import { Bom } from '../util/bom';
|
|
11
|
+
import { config } from '../config/config';
|
|
7
12
|
|
|
8
13
|
export const WriteTool = Tool.define('write', {
|
|
9
14
|
description: DESCRIPTION,
|
|
@@ -32,11 +37,22 @@ export const WriteTool = Tool.define('write', {
|
|
|
32
37
|
metadata: { filePath: filepath },
|
|
33
38
|
});
|
|
34
39
|
|
|
35
|
-
|
|
36
|
-
const exists = await
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
// Serialize with concurrent edit/write calls on the same file.
|
|
41
|
+
const exists = await FileTime.withLock(filepath, async () => {
|
|
42
|
+
// No read-before-write requirement here on purpose: this fork lets
|
|
43
|
+
// the write tool overwrite files without a prior read.
|
|
44
|
+
const file = Bun.file(filepath);
|
|
45
|
+
const exists = await file.exists();
|
|
46
|
+
// Keep an existing UTF-8 BOM (Bun.file().text() would hide it).
|
|
47
|
+
const bom = exists && (await Bom.has(filepath));
|
|
48
|
+
const next = Bom.split(params.content);
|
|
49
|
+
await Bun.write(filepath, Bom.join(next.text, bom || next.bom));
|
|
50
|
+
await Bus.publish(File.Event.Edited, { file: filepath });
|
|
51
|
+
// Record the write as a read so a follow-up edit does not fail with
|
|
52
|
+
// "You must read the file ... before overwriting it".
|
|
53
|
+
FileTime.read(ctx.sessionID, filepath);
|
|
54
|
+
return exists;
|
|
55
|
+
});
|
|
40
56
|
|
|
41
57
|
return {
|
|
42
58
|
title: path.relative(Instance.worktree, filepath),
|
|
@@ -45,7 +61,8 @@ export const WriteTool = Tool.define('write', {
|
|
|
45
61
|
filepath,
|
|
46
62
|
exists: exists,
|
|
47
63
|
},
|
|
48
|
-
output:
|
|
64
|
+
// --legacy-tool-output: write used to return an empty output.
|
|
65
|
+
output: config.legacyToolOutput ? '' : 'Wrote file successfully.',
|
|
49
66
|
};
|
|
50
67
|
},
|
|
51
68
|
});
|
package/src/util/bom.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// UTF-8 byte-order-mark helpers, ported from upstream OpenCode
|
|
2
|
+
// (packages/opencode/src/util/bom.ts, PR #23797). `Bun.file().text()` and
|
|
3
|
+
// `TextDecoder` drop the BOM by default, so tools that read-modify-write a
|
|
4
|
+
// file must decode with `ignoreBOM: true` and re-attach it on write.
|
|
5
|
+
export namespace Bom {
|
|
6
|
+
const BOM_CODE = 0xfeff;
|
|
7
|
+
const BOM = String.fromCharCode(BOM_CODE);
|
|
8
|
+
|
|
9
|
+
export function split(text: string) {
|
|
10
|
+
if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text };
|
|
11
|
+
return { bom: true, text: text.slice(1) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function join(text: string, bom: boolean) {
|
|
15
|
+
const stripped = split(text).text;
|
|
16
|
+
if (!bom) return stripped;
|
|
17
|
+
return BOM + stripped;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readFile(filePath: string) {
|
|
21
|
+
const bytes = await Bun.file(filePath).arrayBuffer();
|
|
22
|
+
return split(new TextDecoder('utf-8', { ignoreBOM: true }).decode(bytes));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Checks only the first three bytes (EF BB BF), without reading the file.
|
|
26
|
+
export async function has(filePath: string) {
|
|
27
|
+
const head = new Uint8Array(
|
|
28
|
+
await Bun.file(filePath).slice(0, 3).arrayBuffer()
|
|
29
|
+
);
|
|
30
|
+
return head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { readBounded } from './read-bounded';
|
|
2
|
+
|
|
3
|
+
// Shared transport for the Exa MCP tools (websearch, codesearch).
|
|
4
|
+
export namespace ExaMcp {
|
|
5
|
+
export const MAX_RESPONSE_SIZE = 5 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
// The anonymous endpoint is rate-limited; an EXA_API_KEY lifts the limit
|
|
8
|
+
// (upstream fde3d9133b, #16362). An empty value counts as unset.
|
|
9
|
+
export function url() {
|
|
10
|
+
const key = process.env.EXA_API_KEY;
|
|
11
|
+
return key
|
|
12
|
+
? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(key)}`
|
|
13
|
+
: 'https://mcp.exa.ai/mcp';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Message = {
|
|
17
|
+
result?: { content?: Array<{ type: string; text: string }> };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function first(message: Message | undefined) {
|
|
21
|
+
const content = message?.result?.content;
|
|
22
|
+
return content && content.length > 0 ? content[0].text : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Exa answers with SSE `data:` lines or with a plain JSON body.
|
|
26
|
+
export function parse(text: string): string | undefined {
|
|
27
|
+
for (const line of text.split('\n')) {
|
|
28
|
+
if (!line.startsWith('data:')) continue;
|
|
29
|
+
const result = first(JSON.parse(line.slice(5).trim()));
|
|
30
|
+
if (result !== undefined) return result;
|
|
31
|
+
}
|
|
32
|
+
const trimmed = text.trim();
|
|
33
|
+
if (!trimmed.startsWith('{')) return undefined;
|
|
34
|
+
try {
|
|
35
|
+
return first(JSON.parse(trimmed));
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The request timeout covers the body read too, and the body is bounded
|
|
42
|
+
// (upstream 69f1ec22e3, #33259).
|
|
43
|
+
export async function call(input: {
|
|
44
|
+
fetch: typeof fetch;
|
|
45
|
+
body: unknown;
|
|
46
|
+
timeout: number;
|
|
47
|
+
abort: AbortSignal;
|
|
48
|
+
label: string;
|
|
49
|
+
}): Promise<string | undefined> {
|
|
50
|
+
const timeoutSignal = AbortSignal.timeout(input.timeout);
|
|
51
|
+
const tooLarge = () =>
|
|
52
|
+
new Error(`${input.label} error: Response too large (exceeds 5MB limit)`);
|
|
53
|
+
try {
|
|
54
|
+
const response = await input.fetch(url(), {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
accept: 'application/json, text/event-stream',
|
|
58
|
+
'content-type': 'application/json',
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify(input.body),
|
|
61
|
+
signal: AbortSignal.any([timeoutSignal, input.abort]),
|
|
62
|
+
});
|
|
63
|
+
const text = (
|
|
64
|
+
await readBounded(response, MAX_RESPONSE_SIZE, tooLarge)
|
|
65
|
+
).toString('utf-8');
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`${input.label} error (${response.status}): ${text}`);
|
|
68
|
+
}
|
|
69
|
+
return parse(text);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// AbortSignal.timeout aborts with a TimeoutError, not an AbortError.
|
|
72
|
+
if (
|
|
73
|
+
error instanceof Error &&
|
|
74
|
+
(error.name === 'TimeoutError' || error.name === 'AbortError') &&
|
|
75
|
+
!input.abort.aborted
|
|
76
|
+
) {
|
|
77
|
+
throw new Error(`${input.label} request timed out`);
|
|
78
|
+
}
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { config } from '../config/config';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backward compatibility for the error objects written to JSON output
|
|
5
|
+
* (issue #335).
|
|
6
|
+
*
|
|
7
|
+
* The upstream sync introduced `ContextOverflowError` for requests that exceed
|
|
8
|
+
* the model's context window. Before, the same failure was reported as a
|
|
9
|
+
* non-retryable `APIError`. Consumers that switch on `error.name` can ask for
|
|
10
|
+
* the previous name with --legacy-error-names /
|
|
11
|
+
* LINK_ASSISTANT_AGENT_LEGACY_ERROR_NAMES=true. Stored session messages keep
|
|
12
|
+
* the new name; only the JSON output is mapped.
|
|
13
|
+
*/
|
|
14
|
+
const LEGACY_NAMES: Record<
|
|
15
|
+
string,
|
|
16
|
+
(data: Record<string, unknown>) => { name: string; data: unknown }
|
|
17
|
+
> = {
|
|
18
|
+
ContextOverflowError: (data) => ({
|
|
19
|
+
name: 'APIError',
|
|
20
|
+
data: { ...data, isRetryable: false },
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function legacyErrorObject<T>(
|
|
25
|
+
error: T,
|
|
26
|
+
enabled: boolean = config.legacyErrorNames
|
|
27
|
+
): T {
|
|
28
|
+
if (!enabled || !error || typeof error !== 'object') return error;
|
|
29
|
+
const { name, data } = error as { name?: unknown; data?: unknown };
|
|
30
|
+
if (typeof name !== 'string') return error;
|
|
31
|
+
const map = LEGACY_NAMES[name];
|
|
32
|
+
if (!map) return error;
|
|
33
|
+
return {
|
|
34
|
+
...error,
|
|
35
|
+
...map(
|
|
36
|
+
data && typeof data === 'object' ? (data as Record<string, unknown>) : {}
|
|
37
|
+
),
|
|
38
|
+
} as T;
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Reads a response body, failing as soon as it exceeds `max` bytes instead of
|
|
2
|
+
// buffering an oversized, chunked or endless body before checking its size
|
|
3
|
+
// (upstream 69f1ec22e3, #33259).
|
|
4
|
+
export async function readBounded(
|
|
5
|
+
response: Response,
|
|
6
|
+
max: number,
|
|
7
|
+
tooLarge: () => Error
|
|
8
|
+
): Promise<Buffer> {
|
|
9
|
+
const length = Number.parseInt(
|
|
10
|
+
response.headers.get('content-length') ?? '',
|
|
11
|
+
10
|
|
12
|
+
);
|
|
13
|
+
if (Number.isSafeInteger(length) && length > max) {
|
|
14
|
+
await response.body?.cancel().catch(() => {});
|
|
15
|
+
throw tooLarge();
|
|
16
|
+
}
|
|
17
|
+
if (!response.body) return Buffer.alloc(0);
|
|
18
|
+
const reader = response.body.getReader();
|
|
19
|
+
const chunks: Uint8Array[] = [];
|
|
20
|
+
let size = 0;
|
|
21
|
+
for (;;) {
|
|
22
|
+
const { done, value } = await reader.read();
|
|
23
|
+
if (done) break;
|
|
24
|
+
size += value.byteLength;
|
|
25
|
+
if (size > max) {
|
|
26
|
+
await reader.cancel().catch(() => {});
|
|
27
|
+
throw tooLarge();
|
|
28
|
+
}
|
|
29
|
+
chunks.push(value);
|
|
30
|
+
}
|
|
31
|
+
return Buffer.concat(chunks, size);
|
|
32
|
+
}
|
|
@@ -13,6 +13,13 @@ export interface SSEUsageData {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const pendingUsage = new Map<string, SSEUsageData>();
|
|
16
|
+
/**
|
|
17
|
+
* Usage is only consumed when the provider response lacks it, so entries for
|
|
18
|
+
* every other streamed request used to stay in memory for the life of the
|
|
19
|
+
* process (issue #335 memory-leak audit). Only recent requests can still be
|
|
20
|
+
* consumed, so keep a bounded number and evict the oldest first.
|
|
21
|
+
*/
|
|
22
|
+
export const MAX_PENDING_SSE_USAGE = 64;
|
|
16
23
|
let requestCounter = 0;
|
|
17
24
|
|
|
18
25
|
export namespace SSEUsageExtractor {
|
|
@@ -77,24 +84,71 @@ export namespace SSEUsageExtractor {
|
|
|
77
84
|
return lastUsage;
|
|
78
85
|
}
|
|
79
86
|
|
|
87
|
+
/** Longest SSE line kept while waiting for its newline. */
|
|
88
|
+
export const MAX_SSE_LINE_CHARS = 1024 * 1024;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Incremental extractor for a streamed SSE body. Only the unfinished last
|
|
92
|
+
* line is buffered, so the whole stream can be read (usage arrives in the
|
|
93
|
+
* final events) with bounded memory. The previous code buffered the first
|
|
94
|
+
* 50 000 characters and stopped reading the tee branch, which both lost the
|
|
95
|
+
* usage of longer responses and left the tee buffering the rest of the
|
|
96
|
+
* response for a reader that never came back (issue #335).
|
|
97
|
+
*/
|
|
98
|
+
export function createTracker() {
|
|
99
|
+
let partial = '';
|
|
100
|
+
let latest: SSEUsageData | undefined;
|
|
101
|
+
const scan = (text: string) => {
|
|
102
|
+
const usage = extractUsageFromSSEChunk(text);
|
|
103
|
+
if (usage) latest = usage;
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
push(text: string) {
|
|
107
|
+
partial += text;
|
|
108
|
+
const end = partial.lastIndexOf('\n');
|
|
109
|
+
if (end === -1) {
|
|
110
|
+
if (partial.length > MAX_SSE_LINE_CHARS) partial = '';
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
scan(partial.slice(0, end));
|
|
114
|
+
partial = partial.slice(end + 1);
|
|
115
|
+
if (partial.length > MAX_SSE_LINE_CHARS) partial = '';
|
|
116
|
+
},
|
|
117
|
+
finish(): SSEUsageData | undefined {
|
|
118
|
+
if (partial) scan(partial);
|
|
119
|
+
partial = '';
|
|
120
|
+
return latest;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stores usage found by a tracker under `requestId`. */
|
|
126
|
+
export function storeUsage(requestId: string, usage: SSEUsageData) {
|
|
127
|
+
pendingUsage.delete(requestId);
|
|
128
|
+
pendingUsage.set(requestId, usage);
|
|
129
|
+
while (pendingUsage.size > MAX_PENDING_SSE_USAGE) {
|
|
130
|
+
const oldest = pendingUsage.keys().next().value;
|
|
131
|
+
if (oldest === undefined) break;
|
|
132
|
+
pendingUsage.delete(oldest);
|
|
133
|
+
}
|
|
134
|
+
if (isVerbose()) {
|
|
135
|
+
log.info('raw SSE usage extracted', {
|
|
136
|
+
requestId,
|
|
137
|
+
promptTokens: usage.promptTokens,
|
|
138
|
+
completionTokens: usage.completionTokens,
|
|
139
|
+
totalTokens: usage.totalTokens,
|
|
140
|
+
cachedTokens: usage.cachedTokens,
|
|
141
|
+
reasoningTokens: usage.reasoningTokens,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
80
146
|
export function processStreamForUsage(
|
|
81
147
|
requestId: string,
|
|
82
148
|
streamBody: string
|
|
83
149
|
): void {
|
|
84
150
|
const usage = extractUsageFromSSEChunk(streamBody);
|
|
85
|
-
if (usage)
|
|
86
|
-
pendingUsage.set(requestId, usage);
|
|
87
|
-
if (isVerbose()) {
|
|
88
|
-
log.info('raw SSE usage extracted', {
|
|
89
|
-
requestId,
|
|
90
|
-
promptTokens: usage.promptTokens,
|
|
91
|
-
completionTokens: usage.completionTokens,
|
|
92
|
-
totalTokens: usage.totalTokens,
|
|
93
|
-
cachedTokens: usage.cachedTokens,
|
|
94
|
-
reasoningTokens: usage.reasoningTokens,
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
}
|
|
151
|
+
if (usage) storeUsage(requestId, usage);
|
|
98
152
|
}
|
|
99
153
|
|
|
100
154
|
export function getUsage(requestId: string): SSEUsageData | undefined {
|
package/src/util/timeout.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
2
2
|
let timeout: NodeJS.Timeout;
|
|
3
3
|
return Promise.race([
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}),
|
|
4
|
+
// Clear the timer on rejection too, so a failed call does not keep the
|
|
5
|
+
// timer and its closure alive until the deadline (issue #335).
|
|
6
|
+
promise.finally(() => clearTimeout(timeout)),
|
|
8
7
|
new Promise<never>((_, reject) => {
|
|
9
8
|
timeout = setTimeout(() => {
|
|
10
9
|
reject(new Error(`Operation timed out after ${ms}ms`));
|
|
@@ -74,6 +74,43 @@ export function sanitizeHeaders(
|
|
|
74
74
|
return sanitized;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
const SENSITIVE_QUERY_PARAMS = new Set([
|
|
78
|
+
'exaapikey',
|
|
79
|
+
'apikey',
|
|
80
|
+
'api_key',
|
|
81
|
+
'api-key',
|
|
82
|
+
'key',
|
|
83
|
+
'token',
|
|
84
|
+
'access_token',
|
|
85
|
+
'auth',
|
|
86
|
+
'secret',
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Mask credentials passed as URL query parameters (for example the Exa MCP
|
|
91
|
+
* URL carries `exaApiKey=...`) so verbose logs do not leak them (issue #335).
|
|
92
|
+
* Unparseable URLs are returned unchanged.
|
|
93
|
+
*/
|
|
94
|
+
export function sanitizeUrl(raw: string): string {
|
|
95
|
+
let parsed: URL;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(raw);
|
|
98
|
+
} catch {
|
|
99
|
+
return raw;
|
|
100
|
+
}
|
|
101
|
+
let changed = false;
|
|
102
|
+
if (parsed.password) {
|
|
103
|
+
parsed.password = 'REDACTED';
|
|
104
|
+
changed = true;
|
|
105
|
+
}
|
|
106
|
+
for (const name of Array.from(parsed.searchParams.keys())) {
|
|
107
|
+
if (!SENSITIVE_QUERY_PARAMS.has(name.toLowerCase())) continue;
|
|
108
|
+
parsed.searchParams.set(name, '[REDACTED]');
|
|
109
|
+
changed = true;
|
|
110
|
+
}
|
|
111
|
+
return changed ? parsed.toString() : raw;
|
|
112
|
+
}
|
|
113
|
+
|
|
77
114
|
/**
|
|
78
115
|
* Create a body preview string, truncated to maxChars.
|
|
79
116
|
*/
|
|
@@ -147,12 +184,14 @@ export function createVerboseFetch(
|
|
|
147
184
|
globalHttpCallCount++;
|
|
148
185
|
const callNum = globalHttpCallCount;
|
|
149
186
|
|
|
150
|
-
const
|
|
187
|
+
const rawUrl =
|
|
151
188
|
typeof input === 'string'
|
|
152
189
|
? input
|
|
153
190
|
: input instanceof URL
|
|
154
191
|
? input.toString()
|
|
155
192
|
: input.url;
|
|
193
|
+
// Only used for logging; never sent anywhere.
|
|
194
|
+
const url = sanitizeUrl(rawUrl);
|
|
156
195
|
const method = init?.method ?? 'GET';
|
|
157
196
|
|
|
158
197
|
// Prepare request details for logging (error-resilient)
|