@ottocode/sdk 0.1.265 → 0.1.267
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 +2 -2
- package/src/config/src/index.ts +4 -0
- package/src/config/src/manager.ts +8 -14
- package/src/config/src/paths.ts +4 -0
- package/src/core/src/providers/resolver.ts +29 -70
- package/src/core/src/tools/bin-manager/cache.ts +13 -0
- package/src/core/src/tools/bin-manager/filesystem.ts +32 -0
- package/src/core/src/tools/bin-manager/paths.ts +36 -0
- package/src/core/src/tools/bin-manager/vendor.ts +80 -0
- package/src/core/src/tools/bin-manager.ts +14 -140
- package/src/core/src/tools/builtin/patch/apply-hunk.ts +308 -0
- package/src/core/src/tools/builtin/patch/apply-report.ts +99 -0
- package/src/core/src/tools/builtin/patch/apply.ts +6 -663
- package/src/core/src/tools/builtin/patch/hunk-header.ts +17 -0
- package/src/core/src/tools/builtin/patch/indentation.ts +160 -0
- package/src/core/src/tools/builtin/patch/matching.ts +58 -0
- package/src/core/src/tools/builtin/patch/parse-enveloped.ts +10 -72
- package/src/core/src/tools/builtin/patch/parse-unified.ts +15 -105
- package/src/core/src/tools/builtin/patch/replace-builder.ts +64 -0
- package/src/core/src/tools/builtin/patch/unified-state.ts +86 -0
- package/src/core/src/tools/builtin/websearch-strategies.ts +197 -0
- package/src/core/src/tools/builtin/websearch.ts +9 -187
- package/src/core/src/tools/loader.ts +6 -49
- package/src/core/src/tools/plugin-discovery.ts +86 -0
- package/src/core/src/utils/logger/format.ts +50 -0
- package/src/core/src/utils/logger/sinks.ts +61 -0
- package/src/core/src/utils/logger.ts +2 -119
- package/src/index.ts +3 -0
- package/src/providers/src/index.ts +4 -0
- package/src/providers/src/model-resolution.ts +21 -0
- package/src/providers/src/zai-client.ts +5 -2
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createToolError, type ToolResponse } from '../error.ts';
|
|
2
|
+
|
|
3
|
+
export type WebFetchResult = {
|
|
4
|
+
url: string;
|
|
5
|
+
content: string;
|
|
6
|
+
contentLength: number;
|
|
7
|
+
truncated: boolean;
|
|
8
|
+
contentType: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type WebSearchResult = {
|
|
12
|
+
query: string;
|
|
13
|
+
results: Array<{ title: string; url: string; snippet: string }>;
|
|
14
|
+
count: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const FETCH_HEADERS = {
|
|
18
|
+
'User-Agent':
|
|
19
|
+
'Mozilla/5.0 (compatible; otto-bot/1.0; +https://github.com/anthropics/otto)',
|
|
20
|
+
Accept:
|
|
21
|
+
'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const SEARCH_HEADERS = {
|
|
25
|
+
'User-Agent':
|
|
26
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
27
|
+
Accept: 'text/html',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export async function fetchUrlContent(
|
|
31
|
+
url: string,
|
|
32
|
+
maxLength: number,
|
|
33
|
+
): Promise<ToolResponse<WebFetchResult>> {
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(url, {
|
|
36
|
+
headers: FETCH_HEADERS,
|
|
37
|
+
redirect: 'follow',
|
|
38
|
+
signal: AbortSignal.timeout(30000),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`HTTP error! status: ${response.status} ${response.statusText}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const contentType = response.headers.get('content-type') || '';
|
|
48
|
+
if (!isTextContentType(contentType)) {
|
|
49
|
+
return createToolError(
|
|
50
|
+
`Unsupported content type: ${contentType}. Only text-based content can be fetched.`,
|
|
51
|
+
'unsupported',
|
|
52
|
+
{ contentType },
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const content = await response.text();
|
|
57
|
+
const cleanContent = cleanHtmlContent(content);
|
|
58
|
+
const truncated = cleanContent.slice(0, maxLength);
|
|
59
|
+
const wasTruncated = cleanContent.length > maxLength;
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
url,
|
|
64
|
+
content: truncated,
|
|
65
|
+
contentLength: cleanContent.length,
|
|
66
|
+
truncated: wasTruncated,
|
|
67
|
+
contentType,
|
|
68
|
+
};
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return createToolError(
|
|
72
|
+
`Failed to fetch URL: ${errorMessage}`,
|
|
73
|
+
'execution',
|
|
74
|
+
{
|
|
75
|
+
url,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function searchDuckDuckGo(
|
|
82
|
+
query: string,
|
|
83
|
+
): Promise<ToolResponse<WebSearchResult>> {
|
|
84
|
+
try {
|
|
85
|
+
const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
86
|
+
const response = await fetch(searchUrl, {
|
|
87
|
+
headers: SEARCH_HEADERS,
|
|
88
|
+
redirect: 'follow',
|
|
89
|
+
signal: AbortSignal.timeout(30000),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`Search failed: ${response.status}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const html = await response.text();
|
|
97
|
+
const results = parseDuckDuckGoResults(html);
|
|
98
|
+
if (results.length === 0) {
|
|
99
|
+
return createToolError(
|
|
100
|
+
'No search results found. The search service may have changed its format or blocked the request.',
|
|
101
|
+
'execution',
|
|
102
|
+
{
|
|
103
|
+
query,
|
|
104
|
+
suggestion:
|
|
105
|
+
'Try using the url parameter to fetch a specific webpage instead.',
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
ok: true,
|
|
112
|
+
query,
|
|
113
|
+
results,
|
|
114
|
+
count: results.length,
|
|
115
|
+
};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
118
|
+
return createToolError(`Search failed: ${errorMessage}`, 'execution', {
|
|
119
|
+
query,
|
|
120
|
+
suggestion:
|
|
121
|
+
'Search services may be temporarily unavailable. Try using the url parameter to fetch a specific webpage instead.',
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isTextContentType(contentType: string): boolean {
|
|
127
|
+
return (
|
|
128
|
+
contentType.includes('text/') ||
|
|
129
|
+
contentType.includes('application/json') ||
|
|
130
|
+
contentType.includes('application/xml') ||
|
|
131
|
+
contentType.includes('application/xhtml')
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cleanHtmlContent(content: string): string {
|
|
136
|
+
return content
|
|
137
|
+
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
138
|
+
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
|
139
|
+
.replace(/<[^>]+>/g, ' ')
|
|
140
|
+
.replace(/\s+/g, ' ')
|
|
141
|
+
.trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseDuckDuckGoResults(
|
|
145
|
+
html: string,
|
|
146
|
+
): Array<{ title: string; url: string; snippet: string }> {
|
|
147
|
+
const parsed = parseDuckDuckGoResultBlocks(html);
|
|
148
|
+
return parsed.length > 0 ? parsed : parseDuckDuckGoSimpleLinks(html);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseDuckDuckGoResultBlocks(
|
|
152
|
+
html: string,
|
|
153
|
+
): Array<{ title: string; url: string; snippet: string }> {
|
|
154
|
+
const results: Array<{ title: string; url: string; snippet: string }> = [];
|
|
155
|
+
const resultPattern =
|
|
156
|
+
/<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
157
|
+
|
|
158
|
+
let match: RegExpExecArray | null = resultPattern.exec(html);
|
|
159
|
+
while (match !== null && results.length < 10) {
|
|
160
|
+
const url = match[1]?.trim();
|
|
161
|
+
const title = match[2]?.trim();
|
|
162
|
+
const snippet = cleanSearchSnippet(match[3]);
|
|
163
|
+
|
|
164
|
+
if (url && title) {
|
|
165
|
+
results.push({ title, url, snippet });
|
|
166
|
+
}
|
|
167
|
+
match = resultPattern.exec(html);
|
|
168
|
+
}
|
|
169
|
+
return results;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseDuckDuckGoSimpleLinks(
|
|
173
|
+
html: string,
|
|
174
|
+
): Array<{ title: string; url: string; snippet: string }> {
|
|
175
|
+
const results: Array<{ title: string; url: string; snippet: string }> = [];
|
|
176
|
+
const simplePattern =
|
|
177
|
+
/<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
|
178
|
+
let match: RegExpExecArray | null = simplePattern.exec(html);
|
|
179
|
+
while (match !== null && results.length < 10) {
|
|
180
|
+
const url = match[1]?.trim();
|
|
181
|
+
const title = match[2]?.trim();
|
|
182
|
+
if (url && title && url.startsWith('http')) {
|
|
183
|
+
results.push({ title, url, snippet: '' });
|
|
184
|
+
}
|
|
185
|
+
match = simplePattern.exec(html);
|
|
186
|
+
}
|
|
187
|
+
return results;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function cleanSearchSnippet(snippet: string | undefined): string {
|
|
191
|
+
return (
|
|
192
|
+
snippet
|
|
193
|
+
?.replace(/<[^>]+>/g, '')
|
|
194
|
+
.replace(/\s+/g, ' ')
|
|
195
|
+
.trim() || ''
|
|
196
|
+
);
|
|
197
|
+
}
|
|
@@ -2,6 +2,12 @@ import { tool, type Tool } from 'ai';
|
|
|
2
2
|
import { z } from 'zod/v3';
|
|
3
3
|
import DESCRIPTION from './websearch.txt' with { type: 'text' };
|
|
4
4
|
import { createToolError, type ToolResponse } from '../error.ts';
|
|
5
|
+
import {
|
|
6
|
+
fetchUrlContent,
|
|
7
|
+
searchDuckDuckGo,
|
|
8
|
+
type WebFetchResult,
|
|
9
|
+
type WebSearchResult,
|
|
10
|
+
} from './websearch-strategies.ts';
|
|
5
11
|
|
|
6
12
|
export function buildWebSearchTool(): {
|
|
7
13
|
name: string;
|
|
@@ -43,197 +49,13 @@ export function buildWebSearchTool(): {
|
|
|
43
49
|
url?: string;
|
|
44
50
|
query?: string;
|
|
45
51
|
maxLength?: number;
|
|
46
|
-
}): Promise<
|
|
47
|
-
ToolResponse<
|
|
48
|
-
| {
|
|
49
|
-
url: string;
|
|
50
|
-
content: string;
|
|
51
|
-
contentLength: number;
|
|
52
|
-
truncated: boolean;
|
|
53
|
-
contentType: string;
|
|
54
|
-
}
|
|
55
|
-
| {
|
|
56
|
-
query: string;
|
|
57
|
-
results: Array<{ title: string; url: string; snippet: string }>;
|
|
58
|
-
count: number;
|
|
59
|
-
}
|
|
60
|
-
>
|
|
61
|
-
> {
|
|
62
|
-
const maxLen = maxLength ?? 50000;
|
|
63
|
-
|
|
52
|
+
}): Promise<ToolResponse<WebFetchResult | WebSearchResult>> {
|
|
64
53
|
if (url) {
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
const response = await fetch(url, {
|
|
68
|
-
headers: {
|
|
69
|
-
'User-Agent':
|
|
70
|
-
'Mozilla/5.0 (compatible; otto-bot/1.0; +https://github.com/anthropics/otto)',
|
|
71
|
-
Accept:
|
|
72
|
-
'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
|
|
73
|
-
},
|
|
74
|
-
redirect: 'follow',
|
|
75
|
-
signal: AbortSignal.timeout(30000), // 30 second timeout
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (!response.ok) {
|
|
79
|
-
throw new Error(
|
|
80
|
-
`HTTP error! status: ${response.status} ${response.statusText}`,
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const contentType = response.headers.get('content-type') || '';
|
|
85
|
-
let content = '';
|
|
86
|
-
|
|
87
|
-
if (
|
|
88
|
-
contentType.includes('text/') ||
|
|
89
|
-
contentType.includes('application/json') ||
|
|
90
|
-
contentType.includes('application/xml') ||
|
|
91
|
-
contentType.includes('application/xhtml')
|
|
92
|
-
) {
|
|
93
|
-
content = await response.text();
|
|
94
|
-
} else {
|
|
95
|
-
return createToolError(
|
|
96
|
-
`Unsupported content type: ${contentType}. Only text-based content can be fetched.`,
|
|
97
|
-
'unsupported',
|
|
98
|
-
{ contentType },
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Strip HTML tags for better readability (basic cleaning)
|
|
103
|
-
const cleanContent = content
|
|
104
|
-
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
|
105
|
-
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
|
|
106
|
-
.replace(/<[^>]+>/g, ' ')
|
|
107
|
-
.replace(/\s+/g, ' ')
|
|
108
|
-
.trim();
|
|
109
|
-
|
|
110
|
-
const truncated = cleanContent.slice(0, maxLen);
|
|
111
|
-
const wasTruncated = cleanContent.length > maxLen;
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
ok: true,
|
|
115
|
-
url,
|
|
116
|
-
content: truncated,
|
|
117
|
-
contentLength: cleanContent.length,
|
|
118
|
-
truncated: wasTruncated,
|
|
119
|
-
contentType,
|
|
120
|
-
};
|
|
121
|
-
} catch (error) {
|
|
122
|
-
const errorMessage =
|
|
123
|
-
error instanceof Error ? error.message : String(error);
|
|
124
|
-
return createToolError(
|
|
125
|
-
`Failed to fetch URL: ${errorMessage}`,
|
|
126
|
-
'execution',
|
|
127
|
-
{ url },
|
|
128
|
-
);
|
|
129
|
-
}
|
|
54
|
+
return fetchUrlContent(url, maxLength ?? 50000);
|
|
130
55
|
}
|
|
131
56
|
|
|
132
57
|
if (query) {
|
|
133
|
-
|
|
134
|
-
// Use DuckDuckGo's HTML search (doesn't require API key)
|
|
135
|
-
try {
|
|
136
|
-
const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
|
|
137
|
-
const response = await fetch(searchUrl, {
|
|
138
|
-
headers: {
|
|
139
|
-
'User-Agent':
|
|
140
|
-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
|
141
|
-
Accept: 'text/html',
|
|
142
|
-
},
|
|
143
|
-
redirect: 'follow',
|
|
144
|
-
signal: AbortSignal.timeout(30000),
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
if (!response.ok) {
|
|
148
|
-
throw new Error(`Search failed: ${response.status}`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const html = await response.text();
|
|
152
|
-
|
|
153
|
-
// Parse DuckDuckGo results (basic parsing)
|
|
154
|
-
const results: Array<{
|
|
155
|
-
title: string;
|
|
156
|
-
url: string;
|
|
157
|
-
snippet: string;
|
|
158
|
-
}> = [];
|
|
159
|
-
|
|
160
|
-
// Match result blocks
|
|
161
|
-
const resultPattern =
|
|
162
|
-
/<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
163
|
-
|
|
164
|
-
let match: RegExpExecArray | null = null;
|
|
165
|
-
match = resultPattern.exec(html);
|
|
166
|
-
while (match !== null && results.length < 10) {
|
|
167
|
-
const url = match[1]?.trim();
|
|
168
|
-
const title = match[2]?.trim();
|
|
169
|
-
let snippet = match[3]?.trim();
|
|
170
|
-
|
|
171
|
-
if (url && title) {
|
|
172
|
-
// Clean snippet
|
|
173
|
-
snippet = snippet
|
|
174
|
-
?.replace(/<[^>]+>/g, '')
|
|
175
|
-
.replace(/\s+/g, ' ')
|
|
176
|
-
.trim();
|
|
177
|
-
|
|
178
|
-
results.push({
|
|
179
|
-
title,
|
|
180
|
-
url,
|
|
181
|
-
snippet: snippet || '',
|
|
182
|
-
});
|
|
183
|
-
}
|
|
184
|
-
match = resultPattern.exec(html);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Fallback: simpler pattern if the above doesn't work
|
|
188
|
-
if (results.length === 0) {
|
|
189
|
-
const simplePattern =
|
|
190
|
-
/<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi;
|
|
191
|
-
match = simplePattern.exec(html);
|
|
192
|
-
while (match !== null && results.length < 10) {
|
|
193
|
-
const url = match[1]?.trim();
|
|
194
|
-
const title = match[2]?.trim();
|
|
195
|
-
if (url && title && url.startsWith('http')) {
|
|
196
|
-
results.push({
|
|
197
|
-
title,
|
|
198
|
-
url,
|
|
199
|
-
snippet: '',
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
match = simplePattern.exec(html);
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (results.length === 0) {
|
|
207
|
-
return createToolError(
|
|
208
|
-
'No search results found. The search service may have changed its format or blocked the request.',
|
|
209
|
-
'execution',
|
|
210
|
-
{
|
|
211
|
-
query,
|
|
212
|
-
suggestion:
|
|
213
|
-
'Try using the url parameter to fetch a specific webpage instead.',
|
|
214
|
-
},
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
ok: true,
|
|
220
|
-
query,
|
|
221
|
-
results,
|
|
222
|
-
count: results.length,
|
|
223
|
-
};
|
|
224
|
-
} catch (error) {
|
|
225
|
-
const errorMessage =
|
|
226
|
-
error instanceof Error ? error.message : String(error);
|
|
227
|
-
return createToolError(
|
|
228
|
-
`Search failed: ${errorMessage}`,
|
|
229
|
-
'execution',
|
|
230
|
-
{
|
|
231
|
-
query,
|
|
232
|
-
suggestion:
|
|
233
|
-
'Search services may be temporarily unavailable. Try using the url parameter to fetch a specific webpage instead.',
|
|
234
|
-
},
|
|
235
|
-
);
|
|
236
|
-
}
|
|
58
|
+
return searchDuckDuckGo(query);
|
|
237
59
|
}
|
|
238
60
|
|
|
239
61
|
return createToolError(
|
|
@@ -24,11 +24,11 @@ import {
|
|
|
24
24
|
getMCPToolsRecord,
|
|
25
25
|
type MCPToolBrief,
|
|
26
26
|
} from '../mcp/lazy-tools.ts';
|
|
27
|
-
import fg from 'fast-glob';
|
|
28
27
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
29
28
|
import { pathToFileURL } from 'node:url';
|
|
30
29
|
import { promises as fs } from 'node:fs';
|
|
31
30
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
31
|
+
import { discoverPluginFiles } from './plugin-discovery.ts';
|
|
32
32
|
|
|
33
33
|
export type DiscoveredTool = { name: string; tool: Tool };
|
|
34
34
|
|
|
@@ -105,8 +105,6 @@ type FsHelpers = {
|
|
|
105
105
|
exists: (path: string) => Promise<boolean>;
|
|
106
106
|
};
|
|
107
107
|
|
|
108
|
-
const pluginPatterns = ['tools/*/tool.js', 'tools/*/tool.mjs'];
|
|
109
|
-
|
|
110
108
|
let globalTerminalManager: TerminalManager | null = null;
|
|
111
109
|
const staticToolDiscoveryCache = new Map<string, Promise<DiscoveredTool[]>>();
|
|
112
110
|
|
|
@@ -168,53 +166,12 @@ async function discoverStaticProjectTools(
|
|
|
168
166
|
tools.set(skillTool.name, skillTool.tool);
|
|
169
167
|
|
|
170
168
|
async function loadFromBase(base: string | null | undefined) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
169
|
+
for (const { absPath, folder } of await discoverPluginFiles(base)) {
|
|
170
|
+
try {
|
|
171
|
+
const plugin = await loadPlugin(absPath, folder, projectRoot);
|
|
172
|
+
if (plugin) tools.set(plugin.name, plugin.tool);
|
|
173
|
+
} catch {}
|
|
176
174
|
}
|
|
177
|
-
for (const pattern of pluginPatterns) {
|
|
178
|
-
const files = await fg(pattern, { cwd: base, absolute: false });
|
|
179
|
-
for (const rel of files) {
|
|
180
|
-
const match = rel.match(/^tools\/([^/]+)\/tool\.(m?js)$/);
|
|
181
|
-
if (!match || !match[1]) continue;
|
|
182
|
-
const folder = match[1];
|
|
183
|
-
const absPath = join(base, rel).replace(/\\/g, '/');
|
|
184
|
-
try {
|
|
185
|
-
const plugin = await loadPlugin(absPath, folder, projectRoot);
|
|
186
|
-
if (plugin) tools.set(plugin.name, plugin.tool);
|
|
187
|
-
} catch {}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
// Fallback: manual directory scan
|
|
191
|
-
try {
|
|
192
|
-
const toolsDir = join(base, 'tools');
|
|
193
|
-
const entries = await fs.readdir(toolsDir).catch(() => [] as string[]);
|
|
194
|
-
for (const folder of entries) {
|
|
195
|
-
const js = join(toolsDir, folder, 'tool.js');
|
|
196
|
-
const mjs = join(toolsDir, folder, 'tool.mjs');
|
|
197
|
-
const candidate = await fs
|
|
198
|
-
.stat(js)
|
|
199
|
-
.then(() => js)
|
|
200
|
-
.catch(
|
|
201
|
-
async () =>
|
|
202
|
-
await fs
|
|
203
|
-
.stat(mjs)
|
|
204
|
-
.then(() => mjs)
|
|
205
|
-
.catch(() => null),
|
|
206
|
-
);
|
|
207
|
-
if (!candidate) continue;
|
|
208
|
-
try {
|
|
209
|
-
const plugin = await loadPlugin(
|
|
210
|
-
candidate.replace(/\\/g, '/'),
|
|
211
|
-
folder,
|
|
212
|
-
projectRoot,
|
|
213
|
-
);
|
|
214
|
-
if (plugin) tools.set(plugin.name, plugin.tool);
|
|
215
|
-
} catch {}
|
|
216
|
-
}
|
|
217
|
-
} catch {}
|
|
218
175
|
}
|
|
219
176
|
|
|
220
177
|
await loadFromBase(globalConfigDir);
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
const pluginPatterns = ['tools/*/tool.js', 'tools/*/tool.mjs'];
|
|
6
|
+
|
|
7
|
+
export type PluginFileCandidate = {
|
|
8
|
+
folder: string;
|
|
9
|
+
absPath: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export async function discoverPluginFiles(
|
|
13
|
+
base: string | null | undefined,
|
|
14
|
+
): Promise<PluginFileCandidate[]> {
|
|
15
|
+
if (!base) return [];
|
|
16
|
+
try {
|
|
17
|
+
await fs.readdir(base);
|
|
18
|
+
} catch {
|
|
19
|
+
return [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const candidates = new Map<string, PluginFileCandidate>();
|
|
23
|
+
for (const candidate of await discoverPluginFilesWithGlob(base)) {
|
|
24
|
+
candidates.set(`${candidate.folder}:${candidate.absPath}`, candidate);
|
|
25
|
+
}
|
|
26
|
+
for (const candidate of await discoverPluginFilesByDirectoryScan(base)) {
|
|
27
|
+
candidates.set(`${candidate.folder}:${candidate.absPath}`, candidate);
|
|
28
|
+
}
|
|
29
|
+
return Array.from(candidates.values());
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function discoverPluginFilesWithGlob(
|
|
33
|
+
base: string,
|
|
34
|
+
): Promise<PluginFileCandidate[]> {
|
|
35
|
+
const candidates: PluginFileCandidate[] = [];
|
|
36
|
+
for (const pattern of pluginPatterns) {
|
|
37
|
+
const files = await fg(pattern, { cwd: base, absolute: false });
|
|
38
|
+
for (const rel of files) {
|
|
39
|
+
const match = rel.match(/^tools\/([^/]+)\/tool\.(m?js)$/);
|
|
40
|
+
if (!match || !match[1]) continue;
|
|
41
|
+
candidates.push({
|
|
42
|
+
folder: match[1],
|
|
43
|
+
absPath: join(base, rel).replace(/\\/g, '/'),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return candidates;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function discoverPluginFilesByDirectoryScan(
|
|
51
|
+
base: string,
|
|
52
|
+
): Promise<PluginFileCandidate[]> {
|
|
53
|
+
try {
|
|
54
|
+
const toolsDir = join(base, 'tools');
|
|
55
|
+
const entries = await fs.readdir(toolsDir).catch(() => [] as string[]);
|
|
56
|
+
const candidates: PluginFileCandidate[] = [];
|
|
57
|
+
for (const folder of entries) {
|
|
58
|
+
const candidate = await findToolFile(toolsDir, folder);
|
|
59
|
+
if (!candidate) continue;
|
|
60
|
+
candidates.push({
|
|
61
|
+
folder,
|
|
62
|
+
absPath: candidate.replace(/\\/g, '/'),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return candidates;
|
|
66
|
+
} catch {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function findToolFile(
|
|
72
|
+
toolsDir: string,
|
|
73
|
+
folder: string,
|
|
74
|
+
): Promise<string | null> {
|
|
75
|
+
const js = join(toolsDir, folder, 'tool.js');
|
|
76
|
+
const mjs = join(toolsDir, folder, 'tool.mjs');
|
|
77
|
+
return fs
|
|
78
|
+
.stat(js)
|
|
79
|
+
.then(() => js)
|
|
80
|
+
.catch(async () =>
|
|
81
|
+
fs
|
|
82
|
+
.stat(mjs)
|
|
83
|
+
.then(() => mjs)
|
|
84
|
+
.catch(() => null),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
2
|
+
|
|
3
|
+
const ANSI_RESET = '\x1b[0m';
|
|
4
|
+
const ANSI_DIM = '\x1b[2m';
|
|
5
|
+
const ANSI_CYAN = '\x1b[36m';
|
|
6
|
+
const ANSI_BLUE = '\x1b[34m';
|
|
7
|
+
const ANSI_GREEN = '\x1b[32m';
|
|
8
|
+
const ANSI_YELLOW = '\x1b[33m';
|
|
9
|
+
const ANSI_RED = '\x1b[31m';
|
|
10
|
+
|
|
11
|
+
export function safeHasMeta(
|
|
12
|
+
meta?: Record<string, unknown>,
|
|
13
|
+
): meta is Record<string, unknown> {
|
|
14
|
+
return Boolean(meta && Object.keys(meta).length);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function serializeLogMeta(meta?: Record<string, unknown>): string {
|
|
18
|
+
if (!safeHasMeta(meta)) return '';
|
|
19
|
+
try {
|
|
20
|
+
const sanitized = { ...meta };
|
|
21
|
+
delete sanitized.debugDetail;
|
|
22
|
+
return Object.keys(sanitized).length ? ` ${JSON.stringify(sanitized)}` : '';
|
|
23
|
+
} catch {
|
|
24
|
+
return ' [unserializable-meta]';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function colorizeLine(line: string, level: LogLevel): string {
|
|
29
|
+
const levelColor =
|
|
30
|
+
level === 'debug'
|
|
31
|
+
? ANSI_CYAN
|
|
32
|
+
: level === 'info'
|
|
33
|
+
? ANSI_BLUE
|
|
34
|
+
: level === 'warn'
|
|
35
|
+
? ANSI_YELLOW
|
|
36
|
+
: ANSI_RED;
|
|
37
|
+
const scopeMatch = line.match(
|
|
38
|
+
/\[(debug|info|warn|error|timing)\]\s+\[([^\]]+)\]/i,
|
|
39
|
+
);
|
|
40
|
+
if (!scopeMatch) {
|
|
41
|
+
return `${levelColor}${line}${ANSI_RESET}`;
|
|
42
|
+
}
|
|
43
|
+
const rest = line.slice(24);
|
|
44
|
+
return `${ANSI_DIM}${line.slice(0, 24)}${ANSI_RESET}${rest
|
|
45
|
+
.replace(scopeMatch[1], `${levelColor}${scopeMatch[1]}${ANSI_RESET}`)
|
|
46
|
+
.replace(
|
|
47
|
+
`[${scopeMatch[2]}]`,
|
|
48
|
+
`${ANSI_GREEN}[${scopeMatch[2]}]${ANSI_RESET}`,
|
|
49
|
+
)}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
getGlobalDebugLogPath,
|
|
5
|
+
getSessionDebugDetailsLogPath,
|
|
6
|
+
getSessionDebugLogPath,
|
|
7
|
+
} from '../../../../config/src/paths.ts';
|
|
8
|
+
import { isDebugEnabled } from '../debug.ts';
|
|
9
|
+
import { serializeLogMeta } from './format.ts';
|
|
10
|
+
|
|
11
|
+
function getDebugLogFilePath(): string | undefined {
|
|
12
|
+
if (!isDebugEnabled()) return undefined;
|
|
13
|
+
return getGlobalDebugLogPath();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function getSessionLogFilePath(
|
|
17
|
+
meta?: Record<string, unknown>,
|
|
18
|
+
): string | undefined {
|
|
19
|
+
if (!isDebugEnabled()) return undefined;
|
|
20
|
+
if (meta?.debugDetail === true) return undefined;
|
|
21
|
+
const sessionId = meta?.sessionId;
|
|
22
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return undefined;
|
|
23
|
+
return getSessionDebugLogPath(sessionId);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getSessionDetailsLogFilePath(
|
|
27
|
+
meta?: Record<string, unknown>,
|
|
28
|
+
): string | undefined {
|
|
29
|
+
if (!isDebugEnabled()) return undefined;
|
|
30
|
+
const sessionId = meta?.sessionId;
|
|
31
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) return undefined;
|
|
32
|
+
return getSessionDebugDetailsLogPath(sessionId);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function appendLogFile(filePath: string, fullLine: string): void {
|
|
36
|
+
try {
|
|
37
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
38
|
+
appendFileSync(filePath, `${fullLine}\n`, 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
// ignore file logging errors
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeLogLine(
|
|
45
|
+
line: string,
|
|
46
|
+
meta?: Record<string, unknown>,
|
|
47
|
+
): string {
|
|
48
|
+
const suffix = serializeLogMeta(meta);
|
|
49
|
+
const fullLine = `${new Date().toISOString()} ${line}${suffix}`;
|
|
50
|
+
const logFile = getDebugLogFilePath();
|
|
51
|
+
|
|
52
|
+
if (logFile) appendLogFile(logFile, fullLine);
|
|
53
|
+
|
|
54
|
+
const sessionLogFile = getSessionLogFilePath(meta);
|
|
55
|
+
if (sessionLogFile) appendLogFile(sessionLogFile, fullLine);
|
|
56
|
+
|
|
57
|
+
const sessionDetailsLogFile = getSessionDetailsLogFilePath(meta);
|
|
58
|
+
if (sessionDetailsLogFile) appendLogFile(sessionDetailsLogFile, fullLine);
|
|
59
|
+
|
|
60
|
+
return fullLine;
|
|
61
|
+
}
|