@ottocode/sdk 0.1.265 → 0.1.266

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 (28) hide show
  1. package/package.json +2 -2
  2. package/src/core/src/providers/resolver.ts +29 -70
  3. package/src/core/src/tools/bin-manager/cache.ts +13 -0
  4. package/src/core/src/tools/bin-manager/filesystem.ts +32 -0
  5. package/src/core/src/tools/bin-manager/paths.ts +36 -0
  6. package/src/core/src/tools/bin-manager/vendor.ts +80 -0
  7. package/src/core/src/tools/bin-manager.ts +14 -140
  8. package/src/core/src/tools/builtin/patch/apply-hunk.ts +308 -0
  9. package/src/core/src/tools/builtin/patch/apply-report.ts +99 -0
  10. package/src/core/src/tools/builtin/patch/apply.ts +6 -663
  11. package/src/core/src/tools/builtin/patch/hunk-header.ts +17 -0
  12. package/src/core/src/tools/builtin/patch/indentation.ts +160 -0
  13. package/src/core/src/tools/builtin/patch/matching.ts +58 -0
  14. package/src/core/src/tools/builtin/patch/parse-enveloped.ts +10 -72
  15. package/src/core/src/tools/builtin/patch/parse-unified.ts +15 -105
  16. package/src/core/src/tools/builtin/patch/replace-builder.ts +64 -0
  17. package/src/core/src/tools/builtin/patch/unified-state.ts +86 -0
  18. package/src/core/src/tools/builtin/websearch-strategies.ts +197 -0
  19. package/src/core/src/tools/builtin/websearch.ts +9 -187
  20. package/src/core/src/tools/loader.ts +6 -49
  21. package/src/core/src/tools/plugin-discovery.ts +86 -0
  22. package/src/core/src/utils/logger/format.ts +50 -0
  23. package/src/core/src/utils/logger/sinks.ts +61 -0
  24. package/src/core/src/utils/logger.ts +2 -119
  25. package/src/index.ts +2 -0
  26. package/src/providers/src/index.ts +4 -0
  27. package/src/providers/src/model-resolution.ts +21 -0
  28. 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
- // Fetch URL content
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
- // Web search functionality
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
- if (!base) return;
172
- try {
173
- await fs.readdir(base);
174
- } catch {
175
- return;
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
+ }