@link-assistant/agent 0.24.2 → 0.25.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/README.md +6 -1
- package/package.json +1 -1
- package/src/provider/provider.ts +99 -0
- package/src/tool/grep.ts +40 -13
- package/src/tool/grep.txt +1 -0
- package/src/tool/read.ts +121 -13
- package/src/tool/read.txt +4 -3
- package/src/tool/text-window.ts +121 -0
package/README.md
CHANGED
|
@@ -24,7 +24,7 @@ This is an MVP implementation of an OpenCode-compatible CLI agent, focused on ma
|
|
|
24
24
|
|
|
25
25
|
- ✅ **JSON Input/Output**: Compatible with `opencode run --format json --model opencode/minimax-m2.5-free`
|
|
26
26
|
- ✅ **Plain Text Input**: Also accepts plain text messages (auto-converted to JSON format)
|
|
27
|
-
- ✅ **Flexible Model Selection**: Defaults to free OpenCode Zen MiniMax M2.5 Free, supports [OpenCode Zen](https://opencode.ai/docs/zen/), [Claude OAuth](../docs/claude-oauth.md), [Groq](../docs/groq.md), and [OpenRouter](../docs/openrouter.md) providers
|
|
27
|
+
- ✅ **Flexible Model Selection**: Defaults to free OpenCode Zen MiniMax M2.5 Free, supports [OpenCode Zen](https://opencode.ai/docs/zen/), [Formal AI](../docs/formal-ai.md), [Claude OAuth](../docs/claude-oauth.md), [Groq](../docs/groq.md), and [OpenRouter](../docs/openrouter.md) providers
|
|
28
28
|
- ✅ **No Restrictions**: Fully unrestricted file system and command execution access (no sandbox)
|
|
29
29
|
- ✅ **Minimal Footprint**: Built with Bun.sh for maximum efficiency
|
|
30
30
|
- ✅ **Full Tool Support**: 13 tools including websearch, codesearch, batch - all enabled by default
|
|
@@ -201,6 +201,10 @@ echo "hi" | agent --model opencode/nemotron-3-super-free
|
|
|
201
201
|
echo "hi" | agent --model opencode/gpt-5-nano
|
|
202
202
|
echo "hi" | agent --model opencode/big-pickle
|
|
203
203
|
|
|
204
|
+
# Formal AI local server
|
|
205
|
+
echo "hi" | agent --model formal-ai
|
|
206
|
+
echo "hi" | agent --model @link-assistant/formal-ai
|
|
207
|
+
|
|
204
208
|
# Premium models (OpenCode Zen subscription)
|
|
205
209
|
echo "hi" | agent --model opencode/sonnet # Claude Sonnet 4.5
|
|
206
210
|
echo "hi" | agent --model opencode/haiku # Claude Haiku 4.5
|
|
@@ -237,6 +241,7 @@ echo "hi" | agent --model github-copilot/gpt-4o # Uses Copilot
|
|
|
237
241
|
```
|
|
238
242
|
|
|
239
243
|
See [MODELS.md](../MODELS.md) for complete list of available models and pricing.
|
|
244
|
+
See [docs/formal-ai.md](../docs/formal-ai.md) for Formal AI local server setup.
|
|
240
245
|
See [docs/groq.md](../docs/groq.md) for Groq provider documentation.
|
|
241
246
|
See [docs/openrouter.md](../docs/openrouter.md) for OpenRouter provider documentation.
|
|
242
247
|
See [docs/claude-oauth.md](../docs/claude-oauth.md) for Claude OAuth provider documentation.
|
package/package.json
CHANGED
package/src/provider/provider.ts
CHANGED
|
@@ -75,7 +75,79 @@ export namespace Provider {
|
|
|
75
75
|
|
|
76
76
|
type Source = 'env' | 'config' | 'custom' | 'api';
|
|
77
77
|
|
|
78
|
+
const FORMAL_AI_DEFAULT_BASE_URL = 'http://127.0.0.1:8080/api/openai/v1';
|
|
79
|
+
const FORMAL_AI_API_KEY_ENV = 'FORMAL_AI_API_KEY';
|
|
80
|
+
const FORMAL_AI_BASE_URL_ENV = 'FORMAL_AI_BASE_URL';
|
|
81
|
+
const FORMAL_AI_DEFAULT_API_KEY = 'formal-ai';
|
|
82
|
+
|
|
83
|
+
function getFormalAiBaseURL(provider?: ModelsDev.Provider) {
|
|
84
|
+
return (
|
|
85
|
+
process.env[FORMAL_AI_BASE_URL_ENV] ??
|
|
86
|
+
provider?.api ??
|
|
87
|
+
FORMAL_AI_DEFAULT_BASE_URL
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createFormalAiLoader(input?: ModelsDev.Provider) {
|
|
92
|
+
return {
|
|
93
|
+
autoload: true,
|
|
94
|
+
options: {
|
|
95
|
+
apiKey: process.env[FORMAL_AI_API_KEY_ENV] ?? FORMAL_AI_DEFAULT_API_KEY,
|
|
96
|
+
baseURL: getFormalAiBaseURL(input),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createFormalAiModel(name = 'Formal AI'): ModelsDev.Model {
|
|
102
|
+
return {
|
|
103
|
+
id: 'formal-ai',
|
|
104
|
+
name,
|
|
105
|
+
release_date: '2026-07-03',
|
|
106
|
+
attachment: false,
|
|
107
|
+
reasoning: true,
|
|
108
|
+
temperature: true,
|
|
109
|
+
tool_call: true,
|
|
110
|
+
cost: {
|
|
111
|
+
input: 0,
|
|
112
|
+
output: 0,
|
|
113
|
+
cache_read: 0,
|
|
114
|
+
cache_write: 0,
|
|
115
|
+
},
|
|
116
|
+
limit: {
|
|
117
|
+
context: 60000,
|
|
118
|
+
output: 8192,
|
|
119
|
+
},
|
|
120
|
+
modalities: {
|
|
121
|
+
input: ['text'],
|
|
122
|
+
output: ['text'],
|
|
123
|
+
},
|
|
124
|
+
options: {},
|
|
125
|
+
provider: {
|
|
126
|
+
npm: '@ai-sdk/openai-compatible',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createFormalAiProvider(
|
|
132
|
+
id: string,
|
|
133
|
+
name: string
|
|
134
|
+
): ModelsDev.Provider {
|
|
135
|
+
return {
|
|
136
|
+
id,
|
|
137
|
+
name,
|
|
138
|
+
npm: '@ai-sdk/openai-compatible',
|
|
139
|
+
api: getFormalAiBaseURL(),
|
|
140
|
+
env: [FORMAL_AI_API_KEY_ENV],
|
|
141
|
+
models: {
|
|
142
|
+
'formal-ai': createFormalAiModel('formal-ai'),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
78
147
|
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
|
148
|
+
'formal-ai': async (input) => createFormalAiLoader(input),
|
|
149
|
+
formalai: async (input) => createFormalAiLoader(input),
|
|
150
|
+
'@link-assistant': async (input) => createFormalAiLoader(input),
|
|
79
151
|
async anthropic(input) {
|
|
80
152
|
// Check if OAuth credentials are available via the auth plugin
|
|
81
153
|
const auth = await Auth.get('anthropic');
|
|
@@ -791,6 +863,22 @@ export namespace Provider {
|
|
|
791
863
|
realIdByKey.set('google/gemini-3-pro', 'gemini-3-pro-preview');
|
|
792
864
|
}
|
|
793
865
|
|
|
866
|
+
// Add Formal AI local OpenAI-compatible provider and selectors.
|
|
867
|
+
// Formal AI serves the canonical model ID "formal-ai"; Agent also accepts
|
|
868
|
+
// the scoped selector "@link-assistant/formal-ai" as a provider alias.
|
|
869
|
+
database['formal-ai'] = createFormalAiProvider(
|
|
870
|
+
'formal-ai',
|
|
871
|
+
'Formal AI Local Server'
|
|
872
|
+
);
|
|
873
|
+
database['formalai'] = createFormalAiProvider(
|
|
874
|
+
'formalai',
|
|
875
|
+
'Formal AI Local Server'
|
|
876
|
+
);
|
|
877
|
+
database['@link-assistant'] = createFormalAiProvider(
|
|
878
|
+
'@link-assistant',
|
|
879
|
+
'Formal AI Local Server (@link-assistant alias)'
|
|
880
|
+
);
|
|
881
|
+
|
|
794
882
|
// Add link-assistant echo provider for dry-run testing
|
|
795
883
|
// This synthetic provider echoes back user input without API calls
|
|
796
884
|
// @see https://github.com/link-assistant/agent/issues/89
|
|
@@ -1994,6 +2082,17 @@ export namespace Provider {
|
|
|
1994
2082
|
}
|
|
1995
2083
|
}
|
|
1996
2084
|
|
|
2085
|
+
if (modelID === 'formal-ai') {
|
|
2086
|
+
const formalAiProvider = s.providers['formal-ai'];
|
|
2087
|
+
if (formalAiProvider && formalAiProvider.info.models[modelID]) {
|
|
2088
|
+
log.info(() => ({
|
|
2089
|
+
message: 'resolved short model name to formal-ai (canonical)',
|
|
2090
|
+
modelID,
|
|
2091
|
+
}));
|
|
2092
|
+
return { providerID: 'formal-ai', modelID };
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
1997
2096
|
// Check if model exists in any provider
|
|
1998
2097
|
const matchingProviders: string[] = [];
|
|
1999
2098
|
for (const [providerID, provider] of Object.entries(s.providers)) {
|
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
|
|
70
|
+
for (const line of output.trim().split('\n')) {
|
|
71
71
|
if (!line) continue;
|
|
72
72
|
|
|
73
|
-
const
|
|
74
|
-
if (
|
|
73
|
+
const event = JSON.parse(line);
|
|
74
|
+
if (event.type !== 'match') continue;
|
|
75
75
|
|
|
76
|
-
const
|
|
77
|
-
const
|
|
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(
|
|
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 {
|
|
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
|
|
124
|
-
const offset = params.offset
|
|
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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
142
|
+
const selected = selectLines({
|
|
143
|
+
lines,
|
|
144
|
+
offset,
|
|
145
|
+
limit,
|
|
146
|
+
summarizeMiddle: !hasExplicitLineRange,
|
|
130
147
|
});
|
|
131
|
-
const content =
|
|
132
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
7
|
-
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the
|
|
8
|
-
-
|
|
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
|
+
}
|