@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.
Files changed (54) hide show
  1. package/README.md +47 -5
  2. package/package.json +41 -42
  3. package/src/cli/argv.ts +1 -1
  4. package/src/cli/cmd/run.ts +7 -1
  5. package/src/cli/continuous-mode.js +38 -12
  6. package/src/cli/event-handler.js +3 -1
  7. package/src/cli/model-config.js +20 -4
  8. package/src/cli/run-options.js +144 -78
  9. package/src/config/config.ts +306 -105
  10. package/src/config/defaults.ts +13 -5
  11. package/src/config/file-config.ts +16 -0
  12. package/src/file/ripgrep.ts +38 -5
  13. package/src/file/time.ts +86 -9
  14. package/src/index.js +39 -33
  15. package/src/mcp/index.ts +370 -106
  16. package/src/permission/index.ts +22 -0
  17. package/src/project/instance.ts +5 -1
  18. package/src/project/project.ts +35 -2
  19. package/src/provider/free-tier-error.ts +1 -1
  20. package/src/provider/kilo-deprecation.ts +30 -0
  21. package/src/provider/kilo.ts +25 -7
  22. package/src/provider/provider.ts +53 -22
  23. package/src/provider/retry-fetch.ts +5 -0
  24. package/src/provider/transform.ts +139 -17
  25. package/src/session/compaction.ts +18 -2
  26. package/src/session/index.ts +30 -3
  27. package/src/session/message-v2.ts +318 -32
  28. package/src/session/processor.ts +274 -140
  29. package/src/session/prompt.ts +154 -128
  30. package/src/session/retry.ts +26 -13
  31. package/src/session/summary.ts +4 -1
  32. package/src/tool/bash.ts +232 -45
  33. package/src/tool/bash.txt +1 -1
  34. package/src/tool/codesearch.ts +15 -78
  35. package/src/tool/edit.ts +111 -30
  36. package/src/tool/glob.ts +2 -1
  37. package/src/tool/grep.ts +175 -57
  38. package/src/tool/ls.ts +2 -1
  39. package/src/tool/multiedit.ts +3 -1
  40. package/src/tool/read.ts +169 -41
  41. package/src/tool/task.ts +26 -18
  42. package/src/tool/tool.ts +4 -1
  43. package/src/tool/webfetch.ts +68 -56
  44. package/src/tool/webfetch.txt +4 -4
  45. package/src/tool/websearch.ts +20 -75
  46. package/src/tool/websearch.txt +2 -0
  47. package/src/tool/write.ts +23 -6
  48. package/src/util/bom.ts +32 -0
  49. package/src/util/exa-mcp.ts +82 -0
  50. package/src/util/legacy-error-names.ts +39 -0
  51. package/src/util/read-bounded.ts +32 -0
  52. package/src/util/sse-usage-extractor.ts +67 -13
  53. package/src/util/timeout.ts +3 -4
  54. package/src/util/verbose-fetch.ts +40 -1
@@ -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
- description: DESCRIPTION,
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 controller = new AbortController();
89
- const timeoutId = setTimeout(() => controller.abort(), 25000);
90
-
91
- try {
92
- const headers: Record<string, string> = {
93
- accept: 'application/json, text/event-stream',
94
- 'content-type': 'application/json',
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
- clearTimeout(timeoutId);
108
-
109
- if (!response.ok) {
110
- const errorText = await response.text();
111
- throw new Error(`Search error (${response.status}): ${errorText}`);
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
  });
@@ -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
- const file = Bun.file(filepath);
36
- const exists = await file.exists();
37
-
38
- // Write the file without permission checks
39
- await Bun.write(filepath, params.content);
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
  });
@@ -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 {
@@ -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
- promise.then((result) => {
5
- clearTimeout(timeout);
6
- return result;
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 url =
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)