erosolar-cli 1.0.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 (70) hide show
  1. package/ARCHITECTURE.json +157 -0
  2. package/Agents.md +207 -0
  3. package/LICENSE +21 -0
  4. package/SOURCE_OF_TRUTH.json +103 -0
  5. package/dist/adapters/browser/index.js +10 -0
  6. package/dist/adapters/node/index.js +34 -0
  7. package/dist/adapters/remote/index.js +19 -0
  8. package/dist/adapters/types.js +1 -0
  9. package/dist/bin/erosolar.js +6 -0
  10. package/dist/capabilities/bashCapability.js +23 -0
  11. package/dist/capabilities/filesystemCapability.js +23 -0
  12. package/dist/capabilities/index.js +3 -0
  13. package/dist/capabilities/searchCapability.js +23 -0
  14. package/dist/capabilities/tavilyCapability.js +26 -0
  15. package/dist/capabilities/toolRegistry.js +98 -0
  16. package/dist/config.js +60 -0
  17. package/dist/contracts/v1/agent.js +7 -0
  18. package/dist/contracts/v1/provider.js +6 -0
  19. package/dist/contracts/v1/tool.js +6 -0
  20. package/dist/core/agent.js +135 -0
  21. package/dist/core/agentProfiles.js +34 -0
  22. package/dist/core/contextWindow.js +29 -0
  23. package/dist/core/errors/apiKeyErrors.js +114 -0
  24. package/dist/core/preferences.js +157 -0
  25. package/dist/core/secretStore.js +143 -0
  26. package/dist/core/toolRuntime.js +180 -0
  27. package/dist/core/types.js +1 -0
  28. package/dist/plugins/providers/anthropic/index.js +24 -0
  29. package/dist/plugins/providers/deepseek/index.js +24 -0
  30. package/dist/plugins/providers/google/index.js +25 -0
  31. package/dist/plugins/providers/index.js +17 -0
  32. package/dist/plugins/providers/openai/index.js +25 -0
  33. package/dist/plugins/providers/xai/index.js +24 -0
  34. package/dist/plugins/tools/bash/localBashPlugin.js +13 -0
  35. package/dist/plugins/tools/filesystem/localFilesystemPlugin.js +13 -0
  36. package/dist/plugins/tools/index.js +2 -0
  37. package/dist/plugins/tools/nodeDefaults.js +16 -0
  38. package/dist/plugins/tools/registry.js +57 -0
  39. package/dist/plugins/tools/search/localSearchPlugin.js +13 -0
  40. package/dist/plugins/tools/tavily/tavilyPlugin.js +16 -0
  41. package/dist/providers/anthropicProvider.js +218 -0
  42. package/dist/providers/googleProvider.js +193 -0
  43. package/dist/providers/openaiChatCompletionsProvider.js +148 -0
  44. package/dist/providers/openaiResponsesProvider.js +182 -0
  45. package/dist/providers/providerFactory.js +21 -0
  46. package/dist/runtime/agentHost.js +152 -0
  47. package/dist/runtime/agentSession.js +65 -0
  48. package/dist/runtime/browser.js +9 -0
  49. package/dist/runtime/cloud.js +9 -0
  50. package/dist/runtime/node.js +10 -0
  51. package/dist/runtime/universal.js +28 -0
  52. package/dist/shell/__tests__/bracketedPasteManager.test.js +35 -0
  53. package/dist/shell/bracketedPasteManager.js +75 -0
  54. package/dist/shell/interactiveShell.js +1426 -0
  55. package/dist/shell/shellApp.js +392 -0
  56. package/dist/tools/bashTools.js +117 -0
  57. package/dist/tools/diffUtils.js +137 -0
  58. package/dist/tools/fileTools.js +232 -0
  59. package/dist/tools/searchTools.js +175 -0
  60. package/dist/tools/tavilyTools.js +176 -0
  61. package/dist/ui/__tests__/richText.test.js +36 -0
  62. package/dist/ui/codeHighlighter.js +843 -0
  63. package/dist/ui/designSystem.js +98 -0
  64. package/dist/ui/display.js +731 -0
  65. package/dist/ui/layout.js +108 -0
  66. package/dist/ui/richText.js +318 -0
  67. package/dist/ui/theme.js +91 -0
  68. package/dist/workspace.js +44 -0
  69. package/package.json +62 -0
  70. package/scripts/preinstall-clean-bins.mjs +66 -0
@@ -0,0 +1,232 @@
1
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname, relative } from 'node:path';
3
+ import { buildDiffSegments, formatDiffLines } from './diffUtils.js';
4
+ export function createFileTools(workingDir) {
5
+ return [
6
+ {
7
+ name: 'read_file',
8
+ description: 'Read the contents of a file at the specified path',
9
+ parameters: {
10
+ type: 'object',
11
+ properties: {
12
+ path: {
13
+ type: 'string',
14
+ description: 'The file path (relative to working directory or absolute)',
15
+ },
16
+ },
17
+ required: ['path'],
18
+ },
19
+ handler: async (args) => {
20
+ const filePath = resolveFilePath(workingDir, args.path);
21
+ if (!existsSync(filePath)) {
22
+ return `Error: File not found: ${filePath}`;
23
+ }
24
+ try {
25
+ const content = readFileSync(filePath, 'utf-8');
26
+ return `File: ${filePath}\n\n${content}`;
27
+ }
28
+ catch (error) {
29
+ return `Error reading file: ${error instanceof Error ? error.message : String(error)}`;
30
+ }
31
+ },
32
+ },
33
+ {
34
+ name: 'write_file',
35
+ description: 'Write content to a file at the specified path (creates directories if needed)',
36
+ parameters: {
37
+ type: 'object',
38
+ properties: {
39
+ path: {
40
+ type: 'string',
41
+ description: 'The file path (relative to working directory or absolute)',
42
+ },
43
+ content: {
44
+ type: 'string',
45
+ description: 'The content to write to the file',
46
+ },
47
+ },
48
+ required: ['path', 'content'],
49
+ },
50
+ handler: async (args) => {
51
+ const filePath = resolveFilePath(workingDir, args.path);
52
+ try {
53
+ const dir = dirname(filePath);
54
+ if (!existsSync(dir)) {
55
+ mkdirSync(dir, { recursive: true });
56
+ }
57
+ const nextContent = typeof args.content === 'string' ? args.content : '';
58
+ const filePreviouslyExisted = existsSync(filePath);
59
+ const previousContent = filePreviouslyExisted ? readFileSync(filePath, 'utf-8') : '';
60
+ const diffSegments = buildDiffSegments(previousContent, nextContent);
61
+ writeFileSync(filePath, nextContent, 'utf-8');
62
+ return buildWriteSummary(filePath, diffSegments, workingDir, filePreviouslyExisted);
63
+ }
64
+ catch (error) {
65
+ return `Error writing file: ${error instanceof Error ? error.message : String(error)}`;
66
+ }
67
+ },
68
+ },
69
+ {
70
+ name: 'list_files',
71
+ description: 'List files and directories at the specified path',
72
+ parameters: {
73
+ type: 'object',
74
+ properties: {
75
+ path: {
76
+ type: 'string',
77
+ description: 'The directory path (defaults to current working directory)',
78
+ },
79
+ recursive: {
80
+ type: 'boolean',
81
+ description: 'Whether to list files recursively',
82
+ },
83
+ },
84
+ },
85
+ handler: async (args) => {
86
+ const dirPath = args.path ? resolveFilePath(workingDir, args.path) : workingDir;
87
+ const recursive = args.recursive === true;
88
+ if (!existsSync(dirPath)) {
89
+ return `Error: Directory not found: ${dirPath}`;
90
+ }
91
+ try {
92
+ const files = listFilesRecursive(dirPath, recursive ? 5 : 1, workingDir);
93
+ return `Directory: ${dirPath}\n\n${files.join('\n')}`;
94
+ }
95
+ catch (error) {
96
+ return `Error listing files: ${error instanceof Error ? error.message : String(error)}`;
97
+ }
98
+ },
99
+ },
100
+ {
101
+ name: 'search_files',
102
+ description: 'Search for files matching a pattern (supports glob patterns)',
103
+ parameters: {
104
+ type: 'object',
105
+ properties: {
106
+ pattern: {
107
+ type: 'string',
108
+ description: 'The search pattern (e.g., "*.ts", "src/**/*.js")',
109
+ },
110
+ path: {
111
+ type: 'string',
112
+ description: 'The directory to search in (defaults to current working directory)',
113
+ },
114
+ },
115
+ required: ['pattern'],
116
+ },
117
+ handler: async (args) => {
118
+ const searchPath = args.path ? resolveFilePath(workingDir, args.path) : workingDir;
119
+ const pattern = args.pattern;
120
+ try {
121
+ const results = searchFilesGlob(searchPath, pattern);
122
+ if (results.length === 0) {
123
+ return `No files found matching pattern: ${pattern}`;
124
+ }
125
+ return `Found ${results.length} files:\n\n${results.map((f) => relative(workingDir, f)).join('\n')}`;
126
+ }
127
+ catch (error) {
128
+ return `Error searching files: ${error instanceof Error ? error.message : String(error)}`;
129
+ }
130
+ },
131
+ },
132
+ ];
133
+ }
134
+ function resolveFilePath(workingDir, path) {
135
+ return path.startsWith('/') ? path : join(workingDir, path);
136
+ }
137
+ function listFilesRecursive(dir, maxDepth, baseDir, currentDepth = 0) {
138
+ if (currentDepth >= maxDepth) {
139
+ return [];
140
+ }
141
+ const ignoredDirs = new Set(['.git', 'node_modules', 'dist', '.next', 'build', 'coverage']);
142
+ const results = [];
143
+ try {
144
+ const entries = readdirSync(dir, { withFileTypes: true });
145
+ for (const entry of entries) {
146
+ if (ignoredDirs.has(entry.name)) {
147
+ continue;
148
+ }
149
+ const fullPath = join(dir, entry.name);
150
+ const relativePath = relative(baseDir, fullPath);
151
+ const indent = ' '.repeat(currentDepth);
152
+ if (entry.isDirectory()) {
153
+ results.push(`${indent}${entry.name}/`);
154
+ results.push(...listFilesRecursive(fullPath, maxDepth, baseDir, currentDepth + 1));
155
+ }
156
+ else {
157
+ const stats = statSync(fullPath);
158
+ const size = formatFileSize(stats.size);
159
+ results.push(`${indent}${entry.name} ${size}`);
160
+ }
161
+ }
162
+ }
163
+ catch (error) {
164
+ // Skip directories we can't read
165
+ }
166
+ return results;
167
+ }
168
+ function searchFilesGlob(dir, pattern) {
169
+ const results = [];
170
+ const regex = globToRegex(pattern);
171
+ function search(currentDir) {
172
+ const ignoredDirs = new Set(['.git', 'node_modules', 'dist', '.next', 'build', 'coverage']);
173
+ try {
174
+ const entries = readdirSync(currentDir, { withFileTypes: true });
175
+ for (const entry of entries) {
176
+ if (ignoredDirs.has(entry.name)) {
177
+ continue;
178
+ }
179
+ const fullPath = join(currentDir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ search(fullPath);
182
+ }
183
+ else if (regex.test(fullPath)) {
184
+ results.push(fullPath);
185
+ }
186
+ }
187
+ }
188
+ catch (error) {
189
+ // Skip directories we can't read
190
+ }
191
+ }
192
+ search(dir);
193
+ return results;
194
+ }
195
+ function buildWriteSummary(filePath, diffSegments, workingDir, filePreviouslyExisted) {
196
+ const readablePath = formatRelativeFilePath(filePath, workingDir);
197
+ const addedLines = diffSegments.filter((segment) => segment.type === 'added').length;
198
+ const removedLines = diffSegments.filter((segment) => segment.type === 'removed').length;
199
+ const hasChanges = diffSegments.length > 0;
200
+ const actionLabel = !filePreviouslyExisted ? 'Added' : hasChanges ? 'Edited' : 'Updated';
201
+ const header = `#### ${actionLabel} ${readablePath}`;
202
+ if (!hasChanges) {
203
+ return `${header}\nNo textual changes.`;
204
+ }
205
+ const statsLine = `Lines changed: +${addedLines} / -${removedLines}`;
206
+ const diffLines = formatDiffLines(diffSegments);
207
+ const diffBlock = ['```diff', ...diffLines, '```'].join('\n');
208
+ const sections = [header, statsLine, '', 'Diff preview:', diffBlock];
209
+ return sections.join('\n').trimEnd();
210
+ }
211
+ function formatRelativeFilePath(filePath, workingDir) {
212
+ const relPath = relative(workingDir, filePath);
213
+ if (!relPath || relPath.startsWith('..')) {
214
+ return filePath;
215
+ }
216
+ return relPath;
217
+ }
218
+ function globToRegex(pattern) {
219
+ const escaped = pattern
220
+ .replace(/\./g, '\\.')
221
+ .replace(/\*\*/g, '.*')
222
+ .replace(/\*/g, '[^/]*')
223
+ .replace(/\?/g, '.');
224
+ return new RegExp(escaped);
225
+ }
226
+ function formatFileSize(bytes) {
227
+ if (bytes < 1024)
228
+ return `${bytes}B`;
229
+ if (bytes < 1024 * 1024)
230
+ return `${(bytes / 1024).toFixed(1)}KB`;
231
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
232
+ }
@@ -0,0 +1,175 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ export function createSearchTools(workingDir) {
4
+ return [
5
+ {
6
+ name: 'grep_search',
7
+ description: 'Search for text patterns in files (similar to grep)',
8
+ parameters: {
9
+ type: 'object',
10
+ properties: {
11
+ pattern: {
12
+ type: 'string',
13
+ description: 'The text pattern to search for (regex supported)',
14
+ },
15
+ path: {
16
+ type: 'string',
17
+ description: 'The directory or file to search in',
18
+ },
19
+ filePattern: {
20
+ type: 'string',
21
+ description: 'File pattern to filter (e.g., "*.ts", "*.js")',
22
+ },
23
+ caseSensitive: {
24
+ type: 'boolean',
25
+ description: 'Whether to perform case-sensitive search',
26
+ },
27
+ },
28
+ required: ['pattern'],
29
+ },
30
+ handler: async (args) => {
31
+ const pattern = args.pattern;
32
+ const searchPath = args.path ? join(workingDir, args.path) : workingDir;
33
+ const filePattern = args.filePattern;
34
+ const caseSensitive = args.caseSensitive === true;
35
+ try {
36
+ const regex = new RegExp(pattern, caseSensitive ? 'g' : 'gi');
37
+ const results = searchInFiles(searchPath, regex, filePattern);
38
+ if (results.length === 0) {
39
+ return `No matches found for pattern: ${pattern}`;
40
+ }
41
+ return formatSearchResults(results);
42
+ }
43
+ catch (error) {
44
+ return `Error searching: ${error instanceof Error ? error.message : String(error)}`;
45
+ }
46
+ },
47
+ },
48
+ {
49
+ name: 'find_definition',
50
+ description: 'Find function/class/interface definitions in code',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: {
54
+ name: {
55
+ type: 'string',
56
+ description: 'The name of the function, class, or interface to find',
57
+ },
58
+ type: {
59
+ type: 'string',
60
+ enum: ['function', 'class', 'interface', 'type', 'const', 'any'],
61
+ description: 'The type of definition to search for',
62
+ },
63
+ },
64
+ required: ['name'],
65
+ },
66
+ handler: async (args) => {
67
+ const name = args.name;
68
+ const type = args.type || 'any';
69
+ const patterns = {
70
+ function: `(function\\s+${name}|const\\s+${name}\\s*=.*=>|${name}\\s*\\([^)]*\\)\\s*{)`,
71
+ class: `class\\s+${name}`,
72
+ interface: `interface\\s+${name}`,
73
+ type: `type\\s+${name}`,
74
+ const: `const\\s+${name}`,
75
+ any: `(function\\s+${name}|class\\s+${name}|interface\\s+${name}|type\\s+${name}|const\\s+${name})`,
76
+ };
77
+ const pattern = patterns[type] || patterns.any;
78
+ const regex = new RegExp(pattern, 'gi');
79
+ try {
80
+ const results = searchInFiles(workingDir, regex, '*.{ts,js,tsx,jsx}');
81
+ if (results.length === 0) {
82
+ return `No definitions found for: ${name}`;
83
+ }
84
+ return formatSearchResults(results);
85
+ }
86
+ catch (error) {
87
+ return `Error finding definition: ${error instanceof Error ? error.message : String(error)}`;
88
+ }
89
+ },
90
+ },
91
+ ];
92
+ }
93
+ function searchInFiles(path, regex, filePattern) {
94
+ const results = [];
95
+ const ignoredDirs = new Set(['.git', 'node_modules', 'dist', '.next', 'build', 'coverage']);
96
+ function search(currentPath) {
97
+ try {
98
+ const stat = statSync(currentPath);
99
+ if (stat.isDirectory()) {
100
+ const entries = readdirSync(currentPath);
101
+ for (const entry of entries) {
102
+ if (ignoredDirs.has(entry))
103
+ continue;
104
+ search(join(currentPath, entry));
105
+ }
106
+ }
107
+ else if (stat.isFile()) {
108
+ // Check file pattern
109
+ if (filePattern && !matchFilePattern(currentPath, filePattern)) {
110
+ return;
111
+ }
112
+ // Skip binary files
113
+ if (isBinaryFile(currentPath)) {
114
+ return;
115
+ }
116
+ try {
117
+ const content = readFileSync(currentPath, 'utf-8');
118
+ const lines = content.split('\n');
119
+ lines.forEach((line, index) => {
120
+ const matches = line.match(regex);
121
+ if (matches) {
122
+ results.push({
123
+ file: currentPath,
124
+ line: index + 1,
125
+ content: line.trim(),
126
+ match: matches[0],
127
+ });
128
+ }
129
+ });
130
+ }
131
+ catch (error) {
132
+ // Skip files we can't read
133
+ }
134
+ }
135
+ }
136
+ catch (error) {
137
+ // Skip paths we can't access
138
+ }
139
+ }
140
+ search(path);
141
+ return results;
142
+ }
143
+ function formatSearchResults(results) {
144
+ const grouped = new Map();
145
+ for (const result of results) {
146
+ if (!grouped.has(result.file)) {
147
+ grouped.set(result.file, []);
148
+ }
149
+ grouped.get(result.file).push(result);
150
+ }
151
+ const output = [`Found ${results.length} matches in ${grouped.size} files:\n`];
152
+ for (const [file, matches] of grouped) {
153
+ output.push(`\n${file}:`);
154
+ for (const match of matches) {
155
+ output.push(` Line ${match.line}: ${match.content}`);
156
+ }
157
+ }
158
+ return output.join('\n');
159
+ }
160
+ function matchFilePattern(filePath, pattern) {
161
+ const regex = new RegExp(pattern
162
+ .replace(/\./g, '\\.')
163
+ .replace(/\*/g, '.*')
164
+ .replace(/\?/g, '.'), 'i');
165
+ return regex.test(filePath);
166
+ }
167
+ function isBinaryFile(filePath) {
168
+ const textExtensions = new Set([
169
+ '.ts', '.js', '.tsx', '.jsx', '.json', '.md', '.txt',
170
+ '.html', '.css', '.scss', '.sass', '.less',
171
+ '.yml', '.yaml', '.xml', '.svg', '.sh', '.bash',
172
+ '.py', '.rb', '.go', '.rs', '.c', '.cpp', '.h',
173
+ ]);
174
+ return !textExtensions.has(filePath.slice(filePath.lastIndexOf('.')));
175
+ }
@@ -0,0 +1,176 @@
1
+ const DEFAULT_BASE_URL = 'https://api.tavily.com';
2
+ const DEFAULT_MAX_RESULTS = 5;
3
+ const MAX_ALLOWED_RESULTS = 10;
4
+ export function createTavilyTools(config) {
5
+ const baseUrl = config.baseUrl ?? DEFAULT_BASE_URL;
6
+ return [buildSearchTool(config.apiKey, baseUrl), buildExtractTool(config.apiKey, baseUrl)];
7
+ }
8
+ function buildSearchTool(apiKey, baseUrl) {
9
+ return {
10
+ name: 'tavily_search',
11
+ description: 'Search the live web using Tavily and return summarized results.',
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ query: {
16
+ type: 'string',
17
+ description: 'Search query to run against Tavily.',
18
+ },
19
+ searchDepth: {
20
+ type: 'string',
21
+ description: 'Depth of the search (basic or advanced).',
22
+ enum: ['basic', 'advanced'],
23
+ },
24
+ includeImages: {
25
+ type: 'boolean',
26
+ description: 'Include links to images when true.',
27
+ },
28
+ includeRawContent: {
29
+ type: 'boolean',
30
+ description: 'Include raw page content in the response.',
31
+ },
32
+ maxResults: {
33
+ type: 'number',
34
+ description: 'Maximum number of results to return (1-10).',
35
+ },
36
+ },
37
+ required: ['query'],
38
+ additionalProperties: false,
39
+ },
40
+ handler: async (args) => {
41
+ const query = typeof args.query === 'string' ? args.query.trim() : '';
42
+ if (!query) {
43
+ return 'A non-empty query is required to run Tavily search.';
44
+ }
45
+ const payload = {
46
+ query,
47
+ search_depth: args.searchDepth === 'advanced' ? 'advanced' : 'basic',
48
+ include_images: args.includeImages === true,
49
+ include_raw_content: args.includeRawContent === true,
50
+ max_results: normalizeMaxResults(args.maxResults),
51
+ };
52
+ try {
53
+ const response = await callTavily(baseUrl, '/search', apiKey, payload);
54
+ return formatSearchResponse(response);
55
+ }
56
+ catch (error) {
57
+ return formatTavilyError(error);
58
+ }
59
+ },
60
+ };
61
+ }
62
+ function buildExtractTool(apiKey, baseUrl) {
63
+ return {
64
+ name: 'tavily_extract',
65
+ description: 'Extract structured content from a URL using Tavily.',
66
+ parameters: {
67
+ type: 'object',
68
+ properties: {
69
+ url: {
70
+ type: 'string',
71
+ description: 'Canonical URL to extract text from.',
72
+ },
73
+ question: {
74
+ type: 'string',
75
+ description: 'Optional focus question to guide the extraction.',
76
+ },
77
+ },
78
+ required: ['url'],
79
+ additionalProperties: false,
80
+ },
81
+ handler: async (args) => {
82
+ const targetUrl = typeof args.url === 'string' ? args.url.trim() : '';
83
+ if (!targetUrl) {
84
+ return 'A valid URL is required for Tavily extraction.';
85
+ }
86
+ const payload = {
87
+ url: targetUrl,
88
+ };
89
+ if (typeof args.question === 'string' && args.question.trim()) {
90
+ payload.question = args.question.trim();
91
+ }
92
+ try {
93
+ const response = await callTavily(baseUrl, '/extract', apiKey, payload);
94
+ return formatExtractResponse(response);
95
+ }
96
+ catch (error) {
97
+ return formatTavilyError(error);
98
+ }
99
+ },
100
+ };
101
+ }
102
+ async function callTavily(baseUrl, path, apiKey, body) {
103
+ const response = await fetch(`${baseUrl}${path}`, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ },
108
+ body: JSON.stringify({ api_key: apiKey, ...body }),
109
+ });
110
+ const text = await response.text();
111
+ if (!response.ok) {
112
+ throw new Error(text || `Tavily request failed with status ${response.status}`);
113
+ }
114
+ try {
115
+ return JSON.parse(text);
116
+ }
117
+ catch {
118
+ throw new Error('Unable to parse Tavily response.');
119
+ }
120
+ }
121
+ function formatSearchResponse(payload) {
122
+ if (!payload?.results?.length) {
123
+ return 'No Tavily search results were returned.';
124
+ }
125
+ const lines = [];
126
+ if (payload.answer) {
127
+ lines.push(`Answer: ${payload.answer.trim()}`);
128
+ lines.push('');
129
+ }
130
+ payload.results.slice(0, MAX_ALLOWED_RESULTS).forEach((result, index) => {
131
+ const title = result.title?.trim() || `Result ${index + 1}`;
132
+ lines.push(`${index + 1}. ${title}`);
133
+ lines.push(` URL: ${result.url}`);
134
+ if (result.score) {
135
+ lines.push(` Score: ${result.score.toFixed(2)}`);
136
+ }
137
+ if (result.content) {
138
+ lines.push(` ${truncate(result.content.trim(), 280)}`);
139
+ }
140
+ lines.push('');
141
+ });
142
+ return lines.join('\n').trimEnd();
143
+ }
144
+ function formatExtractResponse(payload) {
145
+ const lines = [payload.title ? `Title: ${payload.title.trim()}` : 'Title: (unknown)', `URL: ${payload.url}`];
146
+ if (payload.language) {
147
+ lines.push(`Language: ${payload.language}`);
148
+ }
149
+ lines.push('');
150
+ if (payload.content) {
151
+ lines.push(payload.content.trim());
152
+ }
153
+ else {
154
+ lines.push('No content returned by Tavily extract.');
155
+ }
156
+ return lines.join('\n');
157
+ }
158
+ function formatTavilyError(error) {
159
+ if (error instanceof Error) {
160
+ return `Tavily request failed: ${error.message}`;
161
+ }
162
+ return `Tavily request failed: ${String(error)}`;
163
+ }
164
+ function normalizeMaxResults(rawValue) {
165
+ if (typeof rawValue !== 'number' || Number.isNaN(rawValue)) {
166
+ return DEFAULT_MAX_RESULTS;
167
+ }
168
+ const clamped = Math.max(1, Math.min(MAX_ALLOWED_RESULTS, Math.round(rawValue)));
169
+ return clamped;
170
+ }
171
+ function truncate(value, max) {
172
+ if (value.length <= max) {
173
+ return value;
174
+ }
175
+ return `${value.slice(0, max - 1)}…`;
176
+ }
@@ -0,0 +1,36 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { renderMessageBody } from '../richText.js';
4
+ import { theme } from '../theme.js';
5
+ test('renderMessageBody outputs compact, link-aware content', () => {
6
+ const sample = [
7
+ '• Japan stop: Trump met with business leaders and reaffirmed defense ties in Tokyo.',
8
+ '• South Korea finale: follow-on talks at the Busan economic summit reinforced chip cooperation.',
9
+ '',
10
+ 'Sources:',
11
+ '- CNN recap: https://www.cnn.com/politics/trump-asia',
12
+ '- Reuters briefing: [Full itinerary](https://www.reuters.com/world/example-trip)',
13
+ ].join('\n');
14
+ const body = renderMessageBody(sample, 64);
15
+ assert.ok(!body.includes('┌'));
16
+ assert.ok(!body.includes('│'));
17
+ assert.ok(!body.includes('└'));
18
+ const lines = body.split('\n');
19
+ let blankRun = 0;
20
+ for (const line of lines) {
21
+ assert.equal(line, line.trimEnd());
22
+ if (!line.trim()) {
23
+ blankRun += 1;
24
+ assert.ok(blankRun <= 1, 'should not render multiple consecutive blank separators');
25
+ }
26
+ else {
27
+ blankRun = 0;
28
+ }
29
+ }
30
+ const bareLink = (theme.link?.url ?? theme.info)('https://www.cnn.com/politics/trump-asia');
31
+ assert.ok(body.includes(bareLink), 'bare links should be colorized');
32
+ const markdownLinkLabel = (theme.link?.label ?? theme.secondary)('Full itinerary');
33
+ const markdownLinkUrl = (theme.link?.url ?? theme.info)('(https://www.reuters.com/world/example-trip)');
34
+ assert.ok(body.includes(markdownLinkLabel), 'markdown link labels should be highlighted');
35
+ assert.ok(body.includes(markdownLinkUrl), 'markdown link URLs should reuse link color');
36
+ });