@link-assistant/agent 0.24.3 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.24.3",
3
+ "version": "0.25.1",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -111,13 +111,12 @@ export function convertOpenCodeToClaude(
111
111
  case 'tool_use':
112
112
  if (event.part && event.part.state) {
113
113
  const state = event.part.state as Record<string, unknown>;
114
- const tool = state.tool as Record<string, unknown> | undefined;
115
114
  return {
116
115
  type: 'tool_use',
117
116
  timestamp,
118
117
  session_id,
119
- name: (tool?.name as string) || 'unknown',
120
- input: tool?.parameters || {},
118
+ name: (event.part.tool as string) || 'unknown',
119
+ input: state.input || {},
121
120
  tool_use_id: event.part.id as string,
122
121
  };
123
122
  }
package/src/tool/grep.ts CHANGED
@@ -4,6 +4,12 @@ import { Ripgrep } from '../file/ripgrep';
4
4
 
5
5
  import DESCRIPTION from './grep.txt';
6
6
  import { Instance } from '../project/instance';
7
+ import {
8
+ byteOffsetToColumnIndex,
9
+ formatFocusedLineWindow,
10
+ } from './text-window';
11
+
12
+ const MAX_MATCHING_LINE_LENGTH = 2000;
7
13
 
8
14
  export const GrepTool = Tool.define('grep', {
9
15
  description: DESCRIPTION,
@@ -32,12 +38,7 @@ export const GrepTool = Tool.define('grep', {
32
38
  const searchPath = params.path || Instance.directory;
33
39
 
34
40
  const rgPath = await Ripgrep.filepath();
35
- const args = [
36
- '-nH',
37
- '--field-match-separator=|',
38
- '--regexp',
39
- params.pattern,
40
- ];
41
+ const args = ['--json', '--regexp', params.pattern];
41
42
  if (params.include) {
42
43
  args.push('--glob', params.include);
43
44
  }
@@ -64,17 +65,29 @@ export const GrepTool = Tool.define('grep', {
64
65
  throw new Error(`ripgrep failed: ${errorOutput}`);
65
66
  }
66
67
 
67
- const lines = output.trim().split('\n');
68
68
  const matches = [];
69
69
 
70
- for (const line of lines) {
70
+ for (const line of output.trim().split('\n')) {
71
71
  if (!line) continue;
72
72
 
73
- const [filePath, lineNumStr, ...lineTextParts] = line.split('|');
74
- if (!filePath || !lineNumStr || lineTextParts.length === 0) continue;
73
+ const event = JSON.parse(line);
74
+ if (event.type !== 'match') continue;
75
75
 
76
- const lineNum = parseInt(lineNumStr, 10);
77
- const lineText = lineTextParts.join('|');
76
+ const filePath = event.data?.path?.text;
77
+ const lineNum = event.data?.line_number;
78
+ const rawLineText = event.data?.lines?.text;
79
+ const submatch = event.data?.submatches?.[0];
80
+ if (!filePath || !lineNum || typeof rawLineText !== 'string') continue;
81
+
82
+ const lineText = stripTrailingLineEnding(rawLineText);
83
+ const matchStart = byteOffsetToColumnIndex(
84
+ lineText,
85
+ submatch?.start ?? 0
86
+ );
87
+ const matchEnd = byteOffsetToColumnIndex(
88
+ lineText,
89
+ submatch?.end ?? submatch?.start ?? 0
90
+ );
78
91
 
79
92
  const file = Bun.file(filePath);
80
93
  const stats = await file.stat().catch(() => null);
@@ -85,6 +98,8 @@ export const GrepTool = Tool.define('grep', {
85
98
  modTime: stats.mtime.getTime(),
86
99
  lineNum,
87
100
  lineText,
101
+ matchStart,
102
+ matchEnd,
88
103
  });
89
104
  }
90
105
 
@@ -113,7 +128,15 @@ export const GrepTool = Tool.define('grep', {
113
128
  currentFile = match.path;
114
129
  outputLines.push(`${match.path}:`);
115
130
  }
116
- outputLines.push(` Line ${match.lineNum}: ${match.lineText}`);
131
+ outputLines.push(
132
+ ` Line ${match.lineNum}: ${formatFocusedLineWindow({
133
+ line: match.lineText,
134
+ lineNumber: match.lineNum,
135
+ focusStart: match.matchStart,
136
+ focusEnd: match.matchEnd,
137
+ limit: MAX_MATCHING_LINE_LENGTH,
138
+ })}`
139
+ );
117
140
  }
118
141
 
119
142
  if (truncated) {
@@ -133,3 +156,7 @@ export const GrepTool = Tool.define('grep', {
133
156
  };
134
157
  },
135
158
  });
159
+
160
+ function stripTrailingLineEnding(line: string) {
161
+ return line.replace(/\r?\n$/, '');
162
+ }
package/src/tool/grep.txt CHANGED
@@ -3,6 +3,7 @@
3
3
  - Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
4
4
  - Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
5
5
  - Returns file paths with at least one match sorted by modification time
6
+ - Long matching lines are summarized around the match with omitted-column ranges, so the matching text remains visible without dumping the whole line.
6
7
  - Use this tool when you need to find files containing specific patterns
7
8
  - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
8
9
  - When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
package/src/tool/read.ts CHANGED
@@ -5,11 +5,10 @@ import { Tool } from './tool';
5
5
  import { FileTime } from '../file/time';
6
6
  import DESCRIPTION from './read.txt';
7
7
  import { config } from '../config/config';
8
- import { Filesystem } from '../util/filesystem';
9
8
  import { Instance } from '../project/instance';
10
9
  import { Provider } from '../provider/provider';
11
10
  import { Identifier } from '../id/id';
12
- import { iife } from '../util/iife';
11
+ import { formatBalancedLineWindow, formatColumnWindow } from './text-window';
13
12
 
14
13
  const DEFAULT_READ_LIMIT = 2000;
15
14
  const MAX_LINE_LENGTH = 2000;
@@ -26,6 +25,14 @@ export const ReadTool = Tool.define('read', {
26
25
  .number()
27
26
  .describe('The number of lines to read (defaults to 2000)')
28
27
  .optional(),
28
+ columnOffset: z.coerce
29
+ .number()
30
+ .describe('The column number to start reading from (0-based)')
31
+ .optional(),
32
+ columnLimit: z.coerce
33
+ .number()
34
+ .describe('The number of columns to read from each selected line')
35
+ .optional(),
29
36
  }),
30
37
  async execute(params, ctx) {
31
38
  let filepath = params.filePath;
@@ -120,25 +127,53 @@ export const ReadTool = Tool.define('read', {
120
127
  const isBinary = await isBinaryFile(filepath, file);
121
128
  if (isBinary) throw new Error(`Cannot read binary file: ${filepath}`);
122
129
 
123
- const limit = params.limit ?? DEFAULT_READ_LIMIT;
124
- const offset = params.offset || 0;
130
+ const limit = normalizeNonNegativeInteger(params.limit, DEFAULT_READ_LIMIT);
131
+ const offset = normalizeNonNegativeInteger(params.offset, 0);
132
+ const hasExplicitLineRange =
133
+ params.offset !== undefined || params.limit !== undefined;
134
+ const hasExplicitColumnRange =
135
+ params.columnOffset !== undefined || params.columnLimit !== undefined;
136
+ const columnOffset = normalizeNonNegativeInteger(params.columnOffset, 0);
137
+ const columnLimit = normalizeNonNegativeInteger(
138
+ params.columnLimit,
139
+ MAX_LINE_LENGTH
140
+ );
125
141
  const lines = await file.text().then((text) => text.split('\n'));
126
- const raw = lines.slice(offset, offset + limit).map((line) => {
127
- return line.length > MAX_LINE_LENGTH
128
- ? line.substring(0, MAX_LINE_LENGTH) + '...'
129
- : line;
142
+ const selected = selectLines({
143
+ lines,
144
+ offset,
145
+ limit,
146
+ summarizeMiddle: !hasExplicitLineRange,
130
147
  });
131
- const content = raw.map((line, index) => {
132
- return `${(index + offset + 1).toString().padStart(5, '0')}| ${line}`;
148
+ const content = selected.parts.flatMap((part) => {
149
+ if (part.type === 'omitted') {
150
+ return [`... [omitted lines ${part.start}..${part.end}] ...`];
151
+ }
152
+
153
+ return part.lines.map(({ line, lineNumber }) => {
154
+ const formattedLine = hasExplicitColumnRange
155
+ ? formatColumnWindow({
156
+ line,
157
+ lineNumber,
158
+ offset: columnOffset,
159
+ limit: columnLimit,
160
+ })
161
+ : formatBalancedLineWindow({
162
+ line,
163
+ lineNumber,
164
+ limit: MAX_LINE_LENGTH,
165
+ });
166
+ return `${lineNumber.toString().padStart(5, '0')}| ${formattedLine}`;
167
+ });
133
168
  });
134
- const preview = raw.slice(0, 20).join('\n');
169
+ const preview = content.slice(0, 20).join('\n');
135
170
 
136
171
  let output = '<file>\n';
137
172
  output += content.join('\n');
138
173
 
139
174
  const totalLines = lines.length;
140
- const lastReadLine = offset + content.length;
141
- const hasMoreLines = totalLines > lastReadLine;
175
+ const lastReadLine = selected.lastLine;
176
+ const hasMoreLines = hasExplicitLineRange && totalLines > lastReadLine;
142
177
 
143
178
  if (hasMoreLines) {
144
179
  output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`;
@@ -160,6 +195,79 @@ export const ReadTool = Tool.define('read', {
160
195
  },
161
196
  });
162
197
 
198
+ function normalizeNonNegativeInteger(
199
+ value: number | undefined,
200
+ fallback: number
201
+ ) {
202
+ if (value === undefined || !Number.isFinite(value)) return fallback;
203
+ return Math.max(0, Math.floor(value));
204
+ }
205
+
206
+ type SelectedLinePart =
207
+ | {
208
+ type: 'lines';
209
+ lines: Array<{ line: string; lineNumber: number }>;
210
+ }
211
+ | {
212
+ type: 'omitted';
213
+ start: number;
214
+ end: number;
215
+ };
216
+
217
+ function selectLines(input: {
218
+ lines: string[];
219
+ offset: number;
220
+ limit: number;
221
+ summarizeMiddle: boolean;
222
+ }): { parts: SelectedLinePart[]; lastLine: number } {
223
+ const totalLines = input.lines.length;
224
+
225
+ if (!input.summarizeMiddle || totalLines <= input.limit) {
226
+ const end = Math.min(input.offset + input.limit, totalLines);
227
+ return {
228
+ parts: [
229
+ {
230
+ type: 'lines',
231
+ lines: lineRange(input.lines, input.offset, end),
232
+ },
233
+ ],
234
+ lastLine: end,
235
+ };
236
+ }
237
+
238
+ const headCount = Math.ceil(input.limit / 2);
239
+ const tailCount = input.limit - headCount;
240
+ const tailStart = totalLines - tailCount;
241
+ const omittedStart = headCount + 1;
242
+ const omittedEnd = tailStart;
243
+
244
+ return {
245
+ parts: [
246
+ {
247
+ type: 'lines',
248
+ lines: lineRange(input.lines, 0, headCount),
249
+ },
250
+ {
251
+ type: 'omitted',
252
+ start: omittedStart,
253
+ end: omittedEnd,
254
+ },
255
+ {
256
+ type: 'lines',
257
+ lines: lineRange(input.lines, tailStart, totalLines),
258
+ },
259
+ ],
260
+ lastLine: totalLines,
261
+ };
262
+ }
263
+
264
+ function lineRange(lines: string[], start: number, end: number) {
265
+ return lines.slice(start, end).map((line, index) => ({
266
+ line,
267
+ lineNumber: start + index + 1,
268
+ }));
269
+ }
270
+
163
271
  function isImageFile(filePath: string): string | false {
164
272
  const ext = path.extname(filePath).toLowerCase();
165
273
  switch (ext) {
package/src/tool/read.txt CHANGED
@@ -3,9 +3,10 @@ Assume this tool is able to read all files on the machine. If the User provides
3
3
 
4
4
  Usage:
5
5
  - The filePath parameter must be an absolute path, not a relative path
6
- - By default, it reads up to 2000 lines starting from the beginning of the file
7
- - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
8
- - Any lines longer than 2000 characters will be truncated
6
+ - By default, it reads the whole file when it fits the read limit. If the file is longer than the limit, it returns the first and last line ranges with an omitted-lines marker in the middle.
7
+ - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the default first/last summary first and then request omitted ranges if needed.
8
+ - You can optionally specify columnOffset and columnLimit to read a window from each selected line, especially for long single-line files.
9
+ - Long lines are summarized with first and last columns plus omitted-column ranges instead of being silently truncated.
9
10
  - Results are returned using cat -n format, with line numbers starting at 1
10
11
  - You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful.
11
12
  - If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
@@ -0,0 +1,121 @@
1
+ const OMITTED_COLUMNS_LABEL = 'omitted columns';
2
+
3
+ function columnsOf(text: string) {
4
+ return Array.from(text);
5
+ }
6
+
7
+ function clamp(value: number, min: number, max: number) {
8
+ return Math.min(Math.max(value, min), max);
9
+ }
10
+
11
+ function normalizeIndex(value: number | undefined, fallback: number) {
12
+ if (value === undefined || !Number.isFinite(value)) return fallback;
13
+ return Math.max(0, Math.floor(value));
14
+ }
15
+
16
+ function joinColumnWindow(
17
+ columns: string[],
18
+ lineNumber: number,
19
+ start: number,
20
+ end: number
21
+ ) {
22
+ const before =
23
+ start > 0
24
+ ? `[${OMITTED_COLUMNS_LABEL} 1..${start} of line ${lineNumber}] ... `
25
+ : '';
26
+ const body = columns.slice(start, end).join('');
27
+ const after =
28
+ end < columns.length
29
+ ? ` ... [${OMITTED_COLUMNS_LABEL} ${end + 1}..${columns.length} of line ${lineNumber}]`
30
+ : '';
31
+
32
+ return `${before}${body}${after}`;
33
+ }
34
+
35
+ export function formatColumnWindow(input: {
36
+ line: string;
37
+ lineNumber: number;
38
+ offset?: number;
39
+ limit: number;
40
+ }) {
41
+ const columns = columnsOf(input.line);
42
+ const offset = clamp(normalizeIndex(input.offset, 0), 0, columns.length);
43
+ const limit = normalizeIndex(input.limit, columns.length);
44
+ const end = Math.min(offset + limit, columns.length);
45
+
46
+ return joinColumnWindow(columns, input.lineNumber, offset, end);
47
+ }
48
+
49
+ export function formatBalancedLineWindow(input: {
50
+ line: string;
51
+ lineNumber: number;
52
+ limit: number;
53
+ }) {
54
+ const columns = columnsOf(input.line);
55
+ const limit = normalizeIndex(input.limit, columns.length);
56
+
57
+ if (columns.length <= limit) return input.line;
58
+ if (limit === 0) {
59
+ return `[${OMITTED_COLUMNS_LABEL} 1..${columns.length} of line ${input.lineNumber}]`;
60
+ }
61
+
62
+ const headLength = Math.ceil(limit / 2);
63
+ const tailLength = limit - headLength;
64
+ const omittedStart = headLength;
65
+ const omittedEnd = columns.length - tailLength;
66
+ const head = columns.slice(0, headLength).join('');
67
+ const tail = tailLength > 0 ? columns.slice(omittedEnd).join('') : '';
68
+ const omitted = `[${OMITTED_COLUMNS_LABEL} ${omittedStart + 1}..${omittedEnd} of line ${input.lineNumber}]`;
69
+
70
+ return tail ? `${head} ... ${omitted} ... ${tail}` : `${head} ... ${omitted}`;
71
+ }
72
+
73
+ export function formatFocusedLineWindow(input: {
74
+ line: string;
75
+ lineNumber: number;
76
+ focusStart: number;
77
+ focusEnd: number;
78
+ limit: number;
79
+ }) {
80
+ const columns = columnsOf(input.line);
81
+ const limit = normalizeIndex(input.limit, columns.length);
82
+
83
+ if (columns.length <= limit) return input.line;
84
+ if (limit === 0) {
85
+ return `[${OMITTED_COLUMNS_LABEL} 1..${columns.length} of line ${input.lineNumber}]`;
86
+ }
87
+
88
+ const focusStart = clamp(
89
+ normalizeIndex(input.focusStart, 0),
90
+ 0,
91
+ columns.length
92
+ );
93
+ const focusEnd = clamp(
94
+ Math.max(normalizeIndex(input.focusEnd, focusStart + 1), focusStart + 1),
95
+ focusStart + 1,
96
+ columns.length
97
+ );
98
+ const focusLength = focusEnd - focusStart;
99
+ const maxStart = Math.max(0, columns.length - limit);
100
+ const start =
101
+ focusLength >= limit
102
+ ? clamp(focusStart, 0, maxStart)
103
+ : clamp(focusStart - Math.floor((limit - focusLength) / 2), 0, maxStart);
104
+ const end = Math.min(start + limit, columns.length);
105
+
106
+ return joinColumnWindow(columns, input.lineNumber, start, end);
107
+ }
108
+
109
+ export function byteOffsetToColumnIndex(text: string, byteOffset: number) {
110
+ let bytes = 0;
111
+ let column = 0;
112
+
113
+ for (const char of text) {
114
+ const nextBytes = bytes + Buffer.byteLength(char);
115
+ if (nextBytes > byteOffset) break;
116
+ bytes = nextBytes;
117
+ column++;
118
+ }
119
+
120
+ return column;
121
+ }