@skroyc/librarian 0.1.0 → 0.2.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 (76) hide show
  1. package/README.md +4 -16
  2. package/dist/agents/context-schema.d.ts +1 -1
  3. package/dist/agents/context-schema.d.ts.map +1 -1
  4. package/dist/agents/context-schema.js +5 -2
  5. package/dist/agents/context-schema.js.map +1 -1
  6. package/dist/agents/react-agent.d.ts.map +1 -1
  7. package/dist/agents/react-agent.js +36 -27
  8. package/dist/agents/react-agent.js.map +1 -1
  9. package/dist/agents/tool-runtime.d.ts.map +1 -1
  10. package/dist/cli.d.ts +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +53 -49
  13. package/dist/cli.js.map +1 -1
  14. package/dist/config.d.ts +1 -1
  15. package/dist/config.d.ts.map +1 -1
  16. package/dist/config.js +115 -69
  17. package/dist/config.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +246 -150
  21. package/dist/index.js.map +1 -1
  22. package/dist/tools/file-finding.tool.d.ts +1 -1
  23. package/dist/tools/file-finding.tool.d.ts.map +1 -1
  24. package/dist/tools/file-finding.tool.js +70 -130
  25. package/dist/tools/file-finding.tool.js.map +1 -1
  26. package/dist/tools/file-listing.tool.d.ts +7 -1
  27. package/dist/tools/file-listing.tool.d.ts.map +1 -1
  28. package/dist/tools/file-listing.tool.js +96 -80
  29. package/dist/tools/file-listing.tool.js.map +1 -1
  30. package/dist/tools/file-reading.tool.d.ts +4 -1
  31. package/dist/tools/file-reading.tool.d.ts.map +1 -1
  32. package/dist/tools/file-reading.tool.js +107 -45
  33. package/dist/tools/file-reading.tool.js.map +1 -1
  34. package/dist/tools/grep-content.tool.d.ts +13 -1
  35. package/dist/tools/grep-content.tool.d.ts.map +1 -1
  36. package/dist/tools/grep-content.tool.js +186 -144
  37. package/dist/tools/grep-content.tool.js.map +1 -1
  38. package/dist/utils/error-utils.d.ts +9 -0
  39. package/dist/utils/error-utils.d.ts.map +1 -0
  40. package/dist/utils/error-utils.js +61 -0
  41. package/dist/utils/error-utils.js.map +1 -0
  42. package/dist/utils/file-utils.d.ts +1 -0
  43. package/dist/utils/file-utils.d.ts.map +1 -1
  44. package/dist/utils/file-utils.js +81 -9
  45. package/dist/utils/file-utils.js.map +1 -1
  46. package/dist/utils/format-utils.d.ts +25 -0
  47. package/dist/utils/format-utils.d.ts.map +1 -0
  48. package/dist/utils/format-utils.js +111 -0
  49. package/dist/utils/format-utils.js.map +1 -0
  50. package/dist/utils/gitignore-service.d.ts +10 -0
  51. package/dist/utils/gitignore-service.d.ts.map +1 -0
  52. package/dist/utils/gitignore-service.js +91 -0
  53. package/dist/utils/gitignore-service.js.map +1 -0
  54. package/dist/utils/logger.d.ts +2 -2
  55. package/dist/utils/logger.d.ts.map +1 -1
  56. package/dist/utils/logger.js +35 -34
  57. package/dist/utils/logger.js.map +1 -1
  58. package/dist/utils/path-utils.js +3 -3
  59. package/dist/utils/path-utils.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/agents/context-schema.ts +5 -2
  62. package/src/agents/react-agent.ts +667 -641
  63. package/src/agents/tool-runtime.ts +4 -4
  64. package/src/cli.ts +95 -57
  65. package/src/config.ts +192 -90
  66. package/src/index.ts +402 -180
  67. package/src/tools/file-finding.tool.ts +198 -310
  68. package/src/tools/file-listing.tool.ts +245 -202
  69. package/src/tools/file-reading.tool.ts +225 -138
  70. package/src/tools/grep-content.tool.ts +387 -307
  71. package/src/utils/error-utils.ts +95 -0
  72. package/src/utils/file-utils.ts +104 -19
  73. package/src/utils/format-utils.ts +190 -0
  74. package/src/utils/gitignore-service.ts +123 -0
  75. package/src/utils/logger.ts +112 -77
  76. package/src/utils/path-utils.ts +3 -3
@@ -1,212 +1,255 @@
1
- import { tool } from "langchain";
2
- import { z } from "zod";
3
1
  import fs from "node:fs/promises";
4
2
  import path from "node:path";
3
+ import { tool } from "langchain";
4
+ import { z } from "zod";
5
+ import { formatToolError, getToolSuggestion } from "../utils/error-utils.js";
6
+ import { getFileLineCount } from "../utils/file-utils.js";
7
+ import {
8
+ type FileSystemEntry,
9
+ formatDirectoryTree,
10
+ } from "../utils/format-utils.js";
11
+ import { GitIgnoreService } from "../utils/gitignore-service.js";
5
12
  import { logger } from "../utils/logger.js";
6
13
 
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
14
  /**
60
- * List directory contents with metadata
15
+ * Improved listDirectory that sorts siblings before recursing to maintain DFS tree order.
16
+ * Processes line counts in parallel for performance.
61
17
  */
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
- };
18
+ async function listDirectoryDFS(
19
+ dirPath: string,
20
+ includeHidden = false,
21
+ recursive = false,
22
+ maxDepth = 1,
23
+ currentDepth = 0,
24
+ gitignore?: GitIgnoreService
25
+ ): Promise<FileSystemEntry[]> {
26
+ // At maxDepth=1:
27
+ // - currentDepth=0: process entries, DON'T recurse (0 + 1 < 1 is FALSE)
28
+ // - currentDepth=1: return early (stop recursion)
29
+ if (currentDepth >= maxDepth) {
30
+ return [];
31
+ }
32
+
33
+ const entries = await fs.readdir(dirPath, { withFileTypes: true });
34
+
35
+ // Filter entries
36
+ const filteredEntries: import("node:fs").Dirent[] = [];
37
+ for (const entry of entries) {
38
+ const fullPath = path.join(dirPath, entry.name);
39
+
40
+ // 1. Check hidden
41
+ if (!includeHidden && entry.name.startsWith(".")) {
42
+ continue;
43
+ }
44
+
45
+ // 2. Check gitignore
46
+ if (gitignore?.shouldIgnore(fullPath, entry.isDirectory())) {
47
+ continue;
48
+ }
49
+
50
+ filteredEntries.push(entry);
51
+ }
52
+
53
+ // Sort: directories first, then by name
54
+ filteredEntries.sort((a, b) => {
55
+ if (a.isDirectory() !== b.isDirectory()) {
56
+ return a.isDirectory() ? -1 : 1;
57
+ }
58
+ return a.name.localeCompare(b.name);
59
+ });
60
+
61
+ const fileEntries: FileSystemEntry[] = [];
62
+
63
+ for (const entry of filteredEntries) {
64
+ const fullPath = path.join(dirPath, entry.name);
65
+ try {
66
+ const stats = await fs.stat(fullPath);
67
+ const metadata: FileSystemEntry = {
68
+ name: entry.name,
69
+ path: fullPath,
70
+ isDirectory: stats.isDirectory(),
71
+ ...(stats.isFile() && { size: stats.size }),
72
+ depth: currentDepth,
73
+ };
74
+
75
+ fileEntries.push(metadata);
76
+ } catch {
77
+ // Skip files that can't be accessed
78
+ }
79
+ }
80
+
81
+ // Calculate line counts in parallel for all files at this level, with throttling to avoid EMFILE
82
+ const CONCURRENCY_LIMIT = 50;
83
+ const files = fileEntries.filter((e) => !e.isDirectory);
84
+
85
+ for (let i = 0; i < files.length; i += CONCURRENCY_LIMIT) {
86
+ const batch = files.slice(i, i + CONCURRENCY_LIMIT);
87
+
88
+ // Process batch with EMFILE retry logic
89
+ let retries = 3;
90
+ while (retries > 0) {
91
+ try {
92
+ await Promise.all(
93
+ batch.map(async (e) => {
94
+ e.lineCount = await getFileLineCount(e.path);
95
+ })
96
+ );
97
+ break; // Success, exit retry loop
98
+ } catch (error) {
99
+ if ((error as NodeJS.ErrnoException).code === "EMFILE" && retries > 0) {
100
+ retries--;
101
+ await new Promise((resolve) => setTimeout(resolve, 100));
102
+ } else {
103
+ throw error;
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ // Recursively process subdirectories
110
+ const resultEntries: FileSystemEntry[] = [];
111
+ for (const entry of fileEntries) {
112
+ // Determine if we should recurse into this entry
113
+ // Only recurse if: it's a directory, recursive is true, and next depth is within maxDepth
114
+ const willRecurse =
115
+ entry.isDirectory && recursive && currentDepth + 1 < maxDepth;
116
+
117
+ // Add the entry if:
118
+ // 1. It's a file (files are always included)
119
+ // 2. It's a directory AND (recursive is false OR we will recurse into it)
120
+ // 3. It's a directory AND we're at currentDepth=0 (show top-level directories but not their contents)
121
+ if (
122
+ !(entry.isDirectory && recursive) ||
123
+ willRecurse ||
124
+ (entry.isDirectory && currentDepth === 0)
125
+ ) {
126
+ resultEntries.push(entry);
127
+ }
128
+
129
+ // Recurse if allowed
130
+ if (willRecurse) {
131
+ const subEntries = await listDirectoryDFS(
132
+ entry.path,
133
+ includeHidden,
134
+ recursive,
135
+ maxDepth,
136
+ currentDepth + 1,
137
+ gitignore
138
+ );
139
+ resultEntries.push(...subEntries);
140
+ }
141
+ }
142
+
143
+ return resultEntries;
118
144
  }
119
145
 
120
146
  // 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
- },
147
+ export const listTool = tool(
148
+ async (
149
+ {
150
+ directoryPath = ".",
151
+ includeHidden = false,
152
+ recursive = false,
153
+ maxDepth = 1,
154
+ },
155
+ config
156
+ ) => {
157
+ const timingId = logger.timingStart("list");
158
+
159
+ logger.info("TOOL", "list called", {
160
+ directoryPath,
161
+ includeHidden,
162
+ recursive,
163
+ maxDepth,
164
+ });
165
+
166
+ try {
167
+ // Get working directory from config context - required for security
168
+ const workingDir = config?.context?.workingDir;
169
+ if (!workingDir) {
170
+ throw new Error(
171
+ "Context with workingDir is required for file operations"
172
+ );
173
+ }
174
+
175
+ // Validate path to prevent directory traversal
176
+ const resolvedPath = path.resolve(workingDir, directoryPath);
177
+ const resolvedWorkingDir = path.resolve(workingDir);
178
+ const relativePath = path.relative(resolvedWorkingDir, resolvedPath);
179
+
180
+ // Check if resolved path escapes working directory
181
+ if (relativePath.startsWith("..")) {
182
+ throw new Error(
183
+ `Directory path "${directoryPath}" attempts to escape working directory sandbox`
184
+ );
185
+ }
186
+
187
+ // Initialize GitIgnoreService
188
+ const gitignore = new GitIgnoreService(workingDir);
189
+ await gitignore.initialize();
190
+
191
+ // List the directory using DFS to maintain tree order
192
+ const entries = await listDirectoryDFS(
193
+ resolvedPath,
194
+ includeHidden,
195
+ recursive,
196
+ maxDepth,
197
+ 0,
198
+ gitignore
199
+ );
200
+
201
+ // Format the result for the AI
202
+ const treeOutput = formatDirectoryTree(entries);
203
+
204
+ // Use relative path for display
205
+ const displayPath = relativePath === "" ? "." : relativePath;
206
+ let result = `Contents of directory: ${displayPath}\n\n`;
207
+ result += `Total entries: ${entries.length}\n\n`;
208
+ result += treeOutput;
209
+
210
+ logger.timingEnd(timingId, "TOOL", "list completed");
211
+ return result;
212
+ } catch (error) {
213
+ logger.error(
214
+ "TOOL",
215
+ "list failed",
216
+ error instanceof Error ? error : new Error(String(error)),
217
+ { directoryPath }
218
+ );
219
+
220
+ return formatToolError({
221
+ operation: "list",
222
+ path: directoryPath,
223
+ cause: error,
224
+ suggestion: getToolSuggestion("list", directoryPath),
225
+ });
226
+ }
227
+ },
228
+ {
229
+ name: "list",
230
+ description:
231
+ "List the contents of a directory with metadata. Use this to understand the structure of a repository or directory.",
232
+ schema: z.object({
233
+ directoryPath: z.string().describe("The directory path to list"),
234
+ includeHidden: z
235
+ .boolean()
236
+ .optional()
237
+ .default(false)
238
+ .describe(
239
+ "Whether to include hidden files and directories. Defaults to `false`"
240
+ ),
241
+ recursive: z
242
+ .boolean()
243
+ .optional()
244
+ .default(false)
245
+ .describe(
246
+ "Whether to list subdirectories recursively. Defaults to `false`"
247
+ ),
248
+ maxDepth: z
249
+ .number()
250
+ .optional()
251
+ .default(1)
252
+ .describe("Maximum depth for recursive listing. Defaults to 1"),
253
+ }),
254
+ }
212
255
  );