@skroyc/librarian 0.1.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.
Files changed (72) hide show
  1. package/CHANGELOG.md +176 -0
  2. package/LICENSE +210 -0
  3. package/README.md +614 -0
  4. package/biome.jsonc +9 -0
  5. package/dist/agents/context-schema.d.ts +17 -0
  6. package/dist/agents/context-schema.d.ts.map +1 -0
  7. package/dist/agents/context-schema.js +16 -0
  8. package/dist/agents/context-schema.js.map +1 -0
  9. package/dist/agents/react-agent.d.ts +38 -0
  10. package/dist/agents/react-agent.d.ts.map +1 -0
  11. package/dist/agents/react-agent.js +719 -0
  12. package/dist/agents/react-agent.js.map +1 -0
  13. package/dist/agents/tool-runtime.d.ts +7 -0
  14. package/dist/agents/tool-runtime.d.ts.map +1 -0
  15. package/dist/agents/tool-runtime.js +2 -0
  16. package/dist/agents/tool-runtime.js.map +1 -0
  17. package/dist/cli.d.ts +4 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +172 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/config.d.ts +4 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +243 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/index.d.ts +41 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +470 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/tools/file-finding.tool.d.ts +24 -0
  30. package/dist/tools/file-finding.tool.d.ts.map +1 -0
  31. package/dist/tools/file-finding.tool.js +198 -0
  32. package/dist/tools/file-finding.tool.js.map +1 -0
  33. package/dist/tools/file-listing.tool.d.ts +12 -0
  34. package/dist/tools/file-listing.tool.d.ts.map +1 -0
  35. package/dist/tools/file-listing.tool.js +132 -0
  36. package/dist/tools/file-listing.tool.js.map +1 -0
  37. package/dist/tools/file-reading.tool.d.ts +9 -0
  38. package/dist/tools/file-reading.tool.d.ts.map +1 -0
  39. package/dist/tools/file-reading.tool.js +112 -0
  40. package/dist/tools/file-reading.tool.js.map +1 -0
  41. package/dist/tools/grep-content.tool.d.ts +27 -0
  42. package/dist/tools/grep-content.tool.d.ts.map +1 -0
  43. package/dist/tools/grep-content.tool.js +229 -0
  44. package/dist/tools/grep-content.tool.js.map +1 -0
  45. package/dist/utils/file-utils.d.ts +2 -0
  46. package/dist/utils/file-utils.d.ts.map +1 -0
  47. package/dist/utils/file-utils.js +28 -0
  48. package/dist/utils/file-utils.js.map +1 -0
  49. package/dist/utils/logger.d.ts +32 -0
  50. package/dist/utils/logger.d.ts.map +1 -0
  51. package/dist/utils/logger.js +177 -0
  52. package/dist/utils/logger.js.map +1 -0
  53. package/dist/utils/path-utils.d.ts +2 -0
  54. package/dist/utils/path-utils.d.ts.map +1 -0
  55. package/dist/utils/path-utils.js +9 -0
  56. package/dist/utils/path-utils.js.map +1 -0
  57. package/package.json +84 -0
  58. package/src/agents/context-schema.ts +61 -0
  59. package/src/agents/react-agent.ts +928 -0
  60. package/src/agents/tool-runtime.ts +21 -0
  61. package/src/cli.ts +206 -0
  62. package/src/config.ts +309 -0
  63. package/src/index.ts +628 -0
  64. package/src/tools/file-finding.tool.ts +324 -0
  65. package/src/tools/file-listing.tool.ts +212 -0
  66. package/src/tools/file-reading.tool.ts +154 -0
  67. package/src/tools/grep-content.tool.ts +325 -0
  68. package/src/utils/file-utils.ts +39 -0
  69. package/src/utils/logger.ts +295 -0
  70. package/src/utils/path-utils.ts +17 -0
  71. package/tsconfig.json +37 -0
  72. package/tsconfig.test.json +17 -0
@@ -0,0 +1,324 @@
1
+ import { tool } from "langchain";
2
+ import { z } from "zod";
3
+ import fs from "node:fs/promises";
4
+ import type { Dirent } from "node:fs";
5
+ import path from "node:path";
6
+ import { logger } from "../utils/logger.js";
7
+
8
+ // Find files matching glob patterns in a directory
9
+ async function findFiles(
10
+ searchPath: string,
11
+ patterns: string[],
12
+ options: {
13
+ exclude?: string[];
14
+ recursive?: boolean;
15
+ maxResults?: number;
16
+ includeHidden?: boolean;
17
+ } = {},
18
+ ): Promise<string[]> {
19
+ const {
20
+ exclude = [],
21
+ recursive = true,
22
+ maxResults = 1000,
23
+ includeHidden = false,
24
+ } = options;
25
+
26
+ const foundFiles: string[] = [];
27
+ const processedPaths = new Set<string>();
28
+
29
+ // Process each pattern
30
+ for (const pattern of patterns) {
31
+ // Only add **/ prefix if recursive and pattern doesn't already contain path separators
32
+ const effectivePattern =
33
+ recursive && !pattern.includes("**") && !pattern.includes("/")
34
+ ? `**/${pattern}`
35
+ : pattern;
36
+
37
+ // Simple glob matching implementation
38
+ const matchingFiles = await simpleGlobSearch(searchPath, effectivePattern, {
39
+ recursive,
40
+ includeHidden,
41
+ });
42
+
43
+ for (const file of matchingFiles) {
44
+ if (foundFiles.length >= maxResults) {
45
+ break;
46
+ }
47
+
48
+ if (processedPaths.has(file)) {
49
+ continue;
50
+ }
51
+
52
+ processedPaths.add(file);
53
+
54
+ // Check if file should be excluded
55
+ const relativePath = path.relative(searchPath, file);
56
+ if (exclude.some((excl) => simpleMatch(relativePath, excl))) {
57
+ continue;
58
+ }
59
+
60
+ foundFiles.push(file);
61
+ }
62
+
63
+ if (foundFiles.length >= maxResults) {
64
+ break;
65
+ }
66
+ }
67
+
68
+ return foundFiles.slice(0, maxResults);
69
+ }
70
+
71
+ async function handleDirectoryEntry(
72
+ entry: Dirent,
73
+ basePath: string,
74
+ pattern: string,
75
+ options: { recursive: boolean; includeHidden: boolean },
76
+ results: string[],
77
+ ): Promise<void> {
78
+ const fullPath = path.join(basePath, entry.name);
79
+
80
+ if (pattern === "**" || pattern.includes("**") || options.recursive) {
81
+ const basePattern = pattern.split("/")[0] || pattern;
82
+ if (simpleMatch(entry.name, basePattern)) {
83
+ results.push(fullPath);
84
+ }
85
+
86
+ if (options.recursive) {
87
+ const subDirResults = await simpleGlobSearch(
88
+ fullPath,
89
+ pattern,
90
+ options,
91
+ );
92
+ results.push(...subDirResults);
93
+ }
94
+ }
95
+ }
96
+
97
+ function handleFileEntry(
98
+ entry: Dirent,
99
+ basePath: string,
100
+ pattern: string,
101
+ results: string[],
102
+ ): void {
103
+ const fullPath = path.join(basePath, entry.name);
104
+ const relativePath = path.relative(basePath, fullPath);
105
+ const basePattern = pattern.includes("**/")
106
+ ? pattern.split("**/")[1] || ""
107
+ : pattern;
108
+ if (
109
+ simpleMatch(entry.name, basePattern) ||
110
+ simpleMatch(relativePath, basePattern)
111
+ ) {
112
+ results.push(fullPath);
113
+ }
114
+ }
115
+
116
+ // Simple glob search implementation (basic pattern matching)
117
+ async function simpleGlobSearch(
118
+ basePath: string,
119
+ pattern: string,
120
+ options: {
121
+ recursive: boolean;
122
+ includeHidden: boolean;
123
+ },
124
+ ): Promise<string[]> {
125
+ const results: string[] = [];
126
+
127
+ try {
128
+ const entries = await fs.readdir(basePath, { withFileTypes: true });
129
+
130
+ for (const entry of entries) {
131
+ if (!options.includeHidden && entry.name.startsWith(".")) {
132
+ continue;
133
+ }
134
+
135
+ if (entry.isDirectory()) {
136
+ await handleDirectoryEntry(entry, basePath, pattern, options, results);
137
+ } else if (entry.isFile()) {
138
+ handleFileEntry(entry, basePath, pattern, results);
139
+ }
140
+ }
141
+ } catch {
142
+ return [];
143
+ }
144
+
145
+ return results;
146
+ }
147
+
148
+ // Simple pattern matching (supports * and ? wildcards)
149
+ function simpleMatch(str: string, pattern: string): boolean {
150
+ // Handle exact match first
151
+ if (pattern === str) {
152
+ return true;
153
+ }
154
+
155
+ // Convert glob pattern to regex
156
+ const regexPattern = pattern
157
+ .replace(/\./g, "\\.") // Escape dots
158
+ .replace(/\*/g, ".*") // Replace * with .*
159
+ .replace(/\?/g, "."); // Replace ? with .
160
+
161
+ const regex = new RegExp(`^${regexPattern}$`);
162
+ return regex.test(str);
163
+ }
164
+
165
+ // Create the modernized tool using the tool() function
166
+ export const fileFindTool = tool(
167
+ async (
168
+ {
169
+ searchPath = ".",
170
+ patterns,
171
+ exclude = [],
172
+ recursive = true,
173
+ maxResults = 1000,
174
+ includeHidden = false,
175
+ },
176
+ config,
177
+ ) => {
178
+ const timingId = logger.timingStart("fileFind");
179
+
180
+ logger.info("TOOL", "file_find called", {
181
+ searchPath,
182
+ patterns,
183
+ exclude,
184
+ recursive,
185
+ maxResults,
186
+ includeHidden,
187
+ });
188
+
189
+ try {
190
+ // Get working directory from config context - required for security
191
+ const workingDir = config?.context?.workingDir;
192
+ if (!workingDir) {
193
+ throw new Error(
194
+ "Context with workingDir is required for file operations",
195
+ );
196
+ }
197
+ logger.debug("TOOL", "Working directory", {
198
+ workingDir: workingDir.replace(Bun.env.HOME || "", "~"),
199
+ });
200
+
201
+ // Validate the path to prevent directory traversal
202
+ const resolvedPath = path.resolve(workingDir, searchPath);
203
+ const resolvedWorkingDir = path.resolve(workingDir);
204
+ const relativePath = path.relative(resolvedWorkingDir, resolvedPath);
205
+
206
+ logger.debug("TOOL", "Path validation", {
207
+ resolvedPath: resolvedPath.replace(Bun.env.HOME || "", "~"),
208
+ resolvedWorkingDir: resolvedWorkingDir.replace(Bun.env.HOME || "", "~"),
209
+ relativePath,
210
+ validated: !relativePath.startsWith(".."),
211
+ });
212
+
213
+ if (relativePath.startsWith("..")) {
214
+ logger.error(
215
+ "PATH",
216
+ "Search path escapes working directory sandbox",
217
+ undefined,
218
+ { searchPath, relativePath },
219
+ );
220
+ throw new Error(
221
+ `Search path "${searchPath}" attempts to escape the working directory sandbox`,
222
+ );
223
+ }
224
+
225
+ // Validate that the search path exists and is a directory
226
+ const stats = await fs.stat(resolvedPath);
227
+ if (!stats.isDirectory()) {
228
+ logger.error("TOOL", "Search path is not a directory", undefined, {
229
+ searchPath,
230
+ });
231
+ throw new Error(`Search path "${searchPath}" is not a directory`);
232
+ }
233
+
234
+ // Find matching files
235
+ const foundFiles = await findFiles(resolvedPath, patterns || ["*"], {
236
+ exclude,
237
+ recursive,
238
+ maxResults,
239
+ includeHidden,
240
+ });
241
+
242
+ logger.timingEnd(timingId, "TOOL", "file_find completed");
243
+ logger.debug("TOOL", "File search successful", {
244
+ searchPath,
245
+ foundCount: foundFiles.length,
246
+ patterns,
247
+ });
248
+
249
+ if (foundFiles.length === 0) {
250
+ return `No files found matching patterns: ${patterns?.join(", ") || "*"}`;
251
+ }
252
+
253
+ // Format results
254
+ let output = `Found ${foundFiles.length} files matching patterns [${patterns?.join(", ") || "*"}]:\n\n`;
255
+
256
+ for (const file of foundFiles) {
257
+ const relativePath = path.relative(resolvedPath, file);
258
+ output += `${relativePath}\n`;
259
+ }
260
+
261
+ return output;
262
+ } catch (error) {
263
+ logger.error(
264
+ "TOOL",
265
+ "file_find failed",
266
+ error instanceof Error ? error : new Error(String(error)),
267
+ { searchPath, patterns },
268
+ );
269
+ return `Error finding files: ${(error as Error).message}`;
270
+ }
271
+ },
272
+ {
273
+ name: "find_files",
274
+ description: `Discovers files using glob patterns. Respects .gitignore.
275
+ Usage
276
+ - Fast file pattern matching command that works with any codebase size
277
+ - Supports glob patterns like "**/*.js" or "src/**/*.ts"
278
+ - Returns matching file paths sorted
279
+ - Use this command when you need to find files by name patterns
280
+ - When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
281
+ - You can call multiple commands in a single response. It is always better to speculatively perform multiple searches in parallel if they are potentially useful.
282
+ `,
283
+ schema: z.object({
284
+ searchPath: z
285
+ .string()
286
+ .describe(
287
+ "The directory path to search in, relative to the working directory",
288
+ ),
289
+ patterns: z
290
+ .array(z.string())
291
+ .describe(
292
+ "Array of glob patterns to match files (e.g., ['*.js', '*.ts'])",
293
+ ),
294
+ exclude: z
295
+ .array(z.string())
296
+ .optional()
297
+ .default([])
298
+ .describe(
299
+ "Array of patterns to exclude from results. Defaults to none",
300
+ ),
301
+ recursive: z
302
+ .boolean()
303
+ .optional()
304
+ .default(true)
305
+ .describe(
306
+ "Whether to search recursively in subdirectories. Defaults to `true`",
307
+ ),
308
+ maxResults: z
309
+ .number()
310
+ .optional()
311
+ .default(100)
312
+ .describe(
313
+ "Maximum number of files to return. Maximum of 100 by default",
314
+ ),
315
+ includeHidden: z
316
+ .boolean()
317
+ .optional()
318
+ .default(false)
319
+ .describe(
320
+ "Whether to include hidden files and directories. Defaults to `false`",
321
+ ),
322
+ }),
323
+ },
324
+ );
@@ -0,0 +1,212 @@
1
+ import { tool } from "langchain";
2
+ import { z } from "zod";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { logger } from "../utils/logger.js";
6
+
7
+ // Define types for file system operations
8
+ interface FileSystemEntry {
9
+ name: string;
10
+ path: string;
11
+ isDirectory: boolean;
12
+ size?: number | undefined;
13
+ modified?: Date | undefined;
14
+ permissions?: string | undefined;
15
+ lineCount?: number | undefined;
16
+ }
17
+
18
+ interface DirectoryListing {
19
+ entries: FileSystemEntry[];
20
+ totalCount: number;
21
+ filteredCount?: number;
22
+ }
23
+
24
+ // Simplified function to check if a file should be ignored (basic .gitignore handling)
25
+ async function shouldIgnoreFile(filePath: string): Promise<boolean> {
26
+ const basename = path.basename(filePath);
27
+
28
+ // Basic checks for common files/directories to ignore
29
+ if (basename.startsWith(".") && basename !== ".env") {
30
+ // Check for common gitignored items
31
+ const ignoredItems = [
32
+ "node_modules",
33
+ ".git",
34
+ ".DS_Store",
35
+ ".idea",
36
+ ".vscode",
37
+ ".pytest_cache",
38
+ ];
39
+ if (ignoredItems.includes(basename)) {
40
+ return true;
41
+ }
42
+ }
43
+
44
+ // Check for common build/output directories
45
+ const commonBuildDirs = ["dist", "build", "out", "target", "coverage"];
46
+ if (
47
+ commonBuildDirs.includes(basename) &&
48
+ (await fs
49
+ .stat(filePath)
50
+ .then((s) => s.isDirectory())
51
+ .catch(() => false))
52
+ ) {
53
+ return true;
54
+ }
55
+
56
+ return false;
57
+ }
58
+
59
+ /**
60
+ * List directory contents with metadata
61
+ */
62
+ async function listDirectory(
63
+ dirPath: string,
64
+ includeHidden = false,
65
+ ): Promise<DirectoryListing> {
66
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
67
+ const fileEntries: FileSystemEntry[] = [];
68
+
69
+ for (const entry of entries) {
70
+ if (!includeHidden && entry.name.startsWith(".")) {
71
+ continue;
72
+ }
73
+
74
+ const fullPath = path.join(dirPath, entry.name);
75
+
76
+ try {
77
+ // Check if file should be ignored
78
+ if (!includeHidden && (await shouldIgnoreFile(fullPath))) {
79
+ continue;
80
+ }
81
+
82
+ const stats = await fs.stat(fullPath);
83
+ const name = path.basename(fullPath);
84
+
85
+ logger.debug("TOOL", "Processing file entry", {
86
+ name,
87
+ isDirectory: stats.isDirectory(),
88
+ });
89
+
90
+ const metadata: FileSystemEntry = {
91
+ name,
92
+ path: fullPath,
93
+ isDirectory: stats.isDirectory(),
94
+ size: stats.isFile() ? stats.size : undefined,
95
+ modified: stats.mtime,
96
+ permissions: stats.mode?.toString(8),
97
+ };
98
+
99
+ fileEntries.push(metadata);
100
+ } catch {
101
+ // Skip files that can't be accessed or have metadata errors
102
+ }
103
+ }
104
+
105
+ // Sort: directories first, then by name
106
+ fileEntries.sort((a, b) => {
107
+ if (a.isDirectory !== b.isDirectory) {
108
+ return a.isDirectory ? -1 : 1;
109
+ }
110
+ return a.name.localeCompare(b.name);
111
+ });
112
+
113
+ return {
114
+ entries: fileEntries,
115
+ totalCount: fileEntries.length,
116
+ filteredCount: fileEntries.length,
117
+ };
118
+ }
119
+
120
+ // Create the modernized tool using the tool() function
121
+ export const fileListTool = tool(
122
+ async ({ directoryPath = ".", includeHidden = false }, config) => {
123
+ const timingId = logger.timingStart("fileList");
124
+
125
+ logger.info("TOOL", "file_list called", { directoryPath, includeHidden });
126
+
127
+ try {
128
+ // Get working directory from config context - required for security
129
+ const workingDir = config?.context?.workingDir;
130
+ if (!workingDir) {
131
+ throw new Error(
132
+ "Context with workingDir is required for file operations",
133
+ );
134
+ }
135
+ logger.debug("TOOL", "Working directory", {
136
+ workingDir: workingDir.replace(Bun.env.HOME || "", "~"),
137
+ });
138
+
139
+ // Validate path to prevent directory traversal
140
+ const resolvedPath = path.resolve(workingDir, directoryPath);
141
+ const resolvedWorkingDir = path.resolve(workingDir);
142
+ const relativePath = path.relative(resolvedWorkingDir, resolvedPath);
143
+
144
+ logger.debug("TOOL", "Path validation", {
145
+ resolvedPath: resolvedPath.replace(Bun.env.HOME || "", "~"),
146
+ resolvedWorkingDir: resolvedWorkingDir.replace(Bun.env.HOME || "", "~"),
147
+ relativePath,
148
+ validated: !relativePath.startsWith(".."),
149
+ });
150
+
151
+ // Check if resolved path escapes working directory
152
+ if (relativePath.startsWith("..")) {
153
+ logger.error(
154
+ "PATH",
155
+ "Directory path escapes working directory sandbox",
156
+ undefined,
157
+ { directoryPath, relativePath },
158
+ );
159
+ throw new Error(
160
+ `Directory path "${directoryPath}" attempts to escape working directory sandbox`,
161
+ );
162
+ }
163
+
164
+ // List the directory
165
+ const listing = await listDirectory(resolvedPath, includeHidden);
166
+
167
+ // Format the result for the AI
168
+ let result = `Contents of directory: ${resolvedPath}\n\n`;
169
+ result += `Total entries: ${listing.totalCount}\n\n`;
170
+
171
+ for (const entry of listing.entries) {
172
+ const type = entry.isDirectory ? "DIR" : "FILE";
173
+ const size = entry.size ? ` (${entry.size} bytes)` : "";
174
+ const lineCount = entry.lineCount ? ` (${entry.lineCount} lines)` : "";
175
+ result += `${type} ${entry.name}${size}${lineCount}\n`;
176
+ }
177
+
178
+ logger.timingEnd(timingId, "TOOL", "file_list completed");
179
+ logger.debug("TOOL", "Directory listing successful", {
180
+ directoryPath,
181
+ entryCount: listing.totalCount,
182
+ dirCount: listing.entries.filter((e) => e.isDirectory).length,
183
+ fileCount: listing.entries.filter((e) => !e.isDirectory).length,
184
+ });
185
+
186
+ return result;
187
+ } catch (error) {
188
+ logger.error(
189
+ "TOOL",
190
+ "list failed",
191
+ error instanceof Error ? error : new Error(String(error)),
192
+ { directoryPath },
193
+ );
194
+ return `Error listing directory: ${(error as Error).message}`;
195
+ }
196
+ },
197
+ {
198
+ name: "list",
199
+ description:
200
+ "List the contents of a directory with metadata. Use this to understand the structure of a repository or directory.",
201
+ schema: z.object({
202
+ directoryPath: z.string().describe("The directory path to list"),
203
+ includeHidden: z
204
+ .boolean()
205
+ .optional()
206
+ .default(false)
207
+ .describe(
208
+ "Whether to include hidden files and directories. Defaults to `false`",
209
+ ),
210
+ }),
211
+ },
212
+ );
@@ -0,0 +1,154 @@
1
+ import { tool } from "langchain";
2
+ import { z } from "zod";
3
+ import path from "node:path";
4
+ import { logger } from "../utils/logger.js";
5
+ import { isTextFile } from "../utils/file-utils.js";
6
+
7
+ // Check if file is an image file
8
+ function isImageFile(filePath: string): boolean {
9
+ const imageExtensions = new Set([
10
+ ".png",
11
+ ".jpg",
12
+ ".jpeg",
13
+ ".gif",
14
+ ".bmp",
15
+ ".webp",
16
+ ".svg",
17
+ ".ico",
18
+ ".tiff",
19
+ ".tif",
20
+ ]);
21
+ const ext = path.extname(filePath).toLowerCase();
22
+ return imageExtensions.has(ext);
23
+ }
24
+
25
+ // Check if file is an audio file
26
+ function isAudioFile(filePath: string): boolean {
27
+ const audioExtensions = new Set([
28
+ ".mp3",
29
+ ".wav",
30
+ ".ogg",
31
+ ".flac",
32
+ ".m4a",
33
+ ".aac",
34
+ ".wma",
35
+ ".opus",
36
+ ".webm",
37
+ ]);
38
+ const ext = path.extname(filePath).toLowerCase();
39
+ return audioExtensions.has(ext);
40
+ }
41
+
42
+ // Safe file read with encoding detection
43
+ async function readFileContent(filePath: string): Promise<string> {
44
+ try {
45
+ return await Bun.file(filePath).text();
46
+ } catch (error: unknown) {
47
+ const errorMessage =
48
+ error instanceof Error ? error.message : "Unknown error";
49
+ throw new Error(`Failed to read file: ${errorMessage}`);
50
+ }
51
+ }
52
+
53
+ // Create the modernized tool using the tool() function
54
+ export const fileReadTool = tool(
55
+ async ({ filePath }, config) => {
56
+ const timingId = logger.timingStart("fileRead");
57
+
58
+ logger.info("TOOL", "file_read called", { filePath });
59
+
60
+ try {
61
+ // Get working directory from config context - required for security
62
+ const workingDir = config?.context?.workingDir;
63
+ if (!workingDir) {
64
+ throw new Error(
65
+ "Context with workingDir is required for file operations",
66
+ );
67
+ }
68
+ logger.debug("TOOL", "Working directory", {
69
+ workingDir: workingDir.replace(Bun.env.HOME || "", "~"),
70
+ });
71
+
72
+ // Validate the path to prevent directory traversal
73
+ const resolvedPath = path.resolve(workingDir, filePath);
74
+ const resolvedWorkingDir = path.resolve(workingDir);
75
+ const relativePath = path.relative(resolvedWorkingDir, resolvedPath);
76
+
77
+ logger.debug("TOOL", "Path validation", {
78
+ resolvedPath: resolvedPath.replace(Bun.env.HOME || "", "~"),
79
+ resolvedWorkingDir: resolvedWorkingDir.replace(Bun.env.HOME || "", "~"),
80
+ relativePath,
81
+ validated: !relativePath.startsWith(".."),
82
+ });
83
+
84
+ if (relativePath.startsWith("..")) {
85
+ logger.error(
86
+ "PATH",
87
+ "File path escapes working directory sandbox",
88
+ undefined,
89
+ { filePath, relativePath },
90
+ );
91
+ throw new Error(
92
+ `File path "${filePath}" attempts to escape the working directory sandbox`,
93
+ );
94
+ }
95
+
96
+ // Check if it's a media file
97
+ if (isImageFile(resolvedPath) || isAudioFile(resolvedPath)) {
98
+ logger.debug("TOOL", "File is media file", {
99
+ filePath,
100
+ fileType: isImageFile(resolvedPath) ? "image" : "audio",
101
+ });
102
+ return `This is a media file (${isImageFile(resolvedPath) ? "image" : "audio"}). Media files cannot be read as text. Path: ${filePath}`;
103
+ }
104
+
105
+ // Check if it's a text file
106
+ const isText = await isTextFile(resolvedPath);
107
+ if (!isText) {
108
+ logger.debug("TOOL", "File is not a text file", { filePath });
109
+ return `This file is not a text file and cannot be read as text. Path: ${filePath}`;
110
+ }
111
+
112
+ logger.debug("TOOL", "File type validated as text", { filePath });
113
+
114
+ // Read file content
115
+ const content = await readFileContent(resolvedPath);
116
+
117
+ // Limit content size to avoid overwhelming the AI
118
+ const maxContentLength = 50_000; // 50KB limit
119
+ if (content.length > maxContentLength) {
120
+ logger.debug("TOOL", "File content truncated", {
121
+ filePath,
122
+ originalSize: content.length,
123
+ maxSize: maxContentLength,
124
+ });
125
+ logger.timingEnd(timingId, "TOOL", "file_read completed (truncated)");
126
+ return `File content is too large (${content.length} characters). First ${maxContentLength} characters:\n\n${content.substring(0, maxContentLength)}\n\n[Content truncated due to length]`;
127
+ }
128
+
129
+ logger.timingEnd(timingId, "TOOL", "file_read completed");
130
+ logger.debug("TOOL", "File read successful", {
131
+ filePath,
132
+ contentLength: content.length,
133
+ });
134
+
135
+ return `Content of file: ${filePath}\n\n${content}`;
136
+ } catch (error) {
137
+ logger.error(
138
+ "TOOL",
139
+ "file_read failed",
140
+ error instanceof Error ? error : new Error(String(error)),
141
+ { filePath },
142
+ );
143
+ return `Error reading file: ${(error as Error).message}`;
144
+ }
145
+ },
146
+ {
147
+ name: "file_read",
148
+ description:
149
+ "Read the contents of a file. Use this to examine the content of a specific file.",
150
+ schema: z.object({
151
+ filePath: z.string().describe("The path to the file to read"),
152
+ }),
153
+ },
154
+ );