@skroyc/librarian 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +63 -170
  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 +694 -784
  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
@@ -0,0 +1,95 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Standardized error handling for Librarian tools.
5
+ * Aligned with file-editor's professional and objective tone.
6
+ */
7
+
8
+ export interface ToolErrorOptions {
9
+ operation: string;
10
+ path?: string;
11
+ suggestion?: string | undefined;
12
+ cause?: unknown;
13
+ }
14
+
15
+ /**
16
+ * Formats a system error into a professional, actionable message.
17
+ */
18
+ export function formatToolError(options: ToolErrorOptions): string {
19
+ const { operation, path: targetPath, suggestion, cause } = options;
20
+
21
+ let message = `${operation} failed`;
22
+ if (targetPath) {
23
+ message += `: ${targetPath}`;
24
+ }
25
+
26
+ if (cause && typeof cause === "object" && "code" in cause) {
27
+ const code = (cause as { code: string }).code;
28
+
29
+ switch (code) {
30
+ case "ENOENT":
31
+ message = `Path not found: ${targetPath || "unknown"}`;
32
+ break;
33
+ case "EACCES":
34
+ case "EPERM":
35
+ message = `Permission denied: ${targetPath || "unknown"}`;
36
+ break;
37
+ case "ENOTDIR":
38
+ message = `Path is not a directory: ${targetPath || "unknown"}`;
39
+ break;
40
+ case "EISDIR":
41
+ message = `Path is a directory, not a file: ${targetPath || "unknown"}`;
42
+ break;
43
+ default:
44
+ if ("message" in cause) {
45
+ message += `. ${cause.message}`;
46
+ }
47
+ }
48
+ } else if (cause instanceof Error) {
49
+ message += `. ${cause.message}`;
50
+ }
51
+
52
+ if (suggestion) {
53
+ message += `\n\nSuggestion: ${suggestion}`;
54
+ }
55
+
56
+ return message;
57
+ }
58
+
59
+ /**
60
+ * Creates a helpful suggestion based on common failure scenarios.
61
+ */
62
+ export function getToolSuggestion(
63
+ operation: string,
64
+ targetPath?: string
65
+ ): string | undefined {
66
+ if (operation === "view" && targetPath) {
67
+ const ext = path.extname(targetPath).toLowerCase();
68
+ if ([".png", ".jpg", ".pdf"].includes(ext)) {
69
+ return "This file appears to be a binary or media file. Only text files can be read.";
70
+ }
71
+ return "Ensure the file path is correct and the file is a text file.";
72
+ }
73
+
74
+ if (operation === "list") {
75
+ return "Check if the directory exists and you have permissions to read it.";
76
+ }
77
+
78
+ if (operation === "grep") {
79
+ return "Try a simpler search pattern or verify the search path.";
80
+ }
81
+
82
+ if (operation === "find") {
83
+ return "Check if the search path exists and the patterns are valid glob patterns.";
84
+ }
85
+
86
+ // Generic permission suggestion for any operation
87
+ if (
88
+ targetPath &&
89
+ (operation === "view" || operation === "list" || operation === "find")
90
+ ) {
91
+ return "Check file permissions and ensure you have read access to the path.";
92
+ }
93
+
94
+ return undefined;
95
+ }
@@ -3,7 +3,7 @@
3
3
  * Shared utilities for file type detection and file operations
4
4
  */
5
5
 
6
- import path from 'node:path';
6
+ import path from "node:path";
7
7
 
8
8
  /**
9
9
  * Check if a file is text-based
@@ -11,11 +11,48 @@ import path from 'node:path';
11
11
  */
12
12
  export async function isTextFile(filePath: string): Promise<boolean> {
13
13
  const textExtensions = new Set([
14
- '.txt', '.js', '.ts', '.jsx', '.tsx', '.json', '.yaml', '.yml', '.md',
15
- '.html', '.htm', '.css', '.scss', '.sass', '.less', '.py', '.rb', '.java',
16
- '.cpp', '.c', '.h', '.hpp', '.go', '.rs', '.php', '.sql', '.xml', '.csv',
17
- '.toml', '.lock', '.sh', '.bash', '.zsh', '.env', '.dockerfile', 'dockerfile',
18
- '.gitignore', '.npmrc', '.prettierrc', '.eslintrc', '.editorconfig', '.jsonc'
14
+ ".txt",
15
+ ".js",
16
+ ".ts",
17
+ ".jsx",
18
+ ".tsx",
19
+ ".json",
20
+ ".yaml",
21
+ ".yml",
22
+ ".md",
23
+ ".html",
24
+ ".htm",
25
+ ".css",
26
+ ".scss",
27
+ ".sass",
28
+ ".less",
29
+ ".py",
30
+ ".rb",
31
+ ".java",
32
+ ".cpp",
33
+ ".c",
34
+ ".h",
35
+ ".hpp",
36
+ ".go",
37
+ ".rs",
38
+ ".php",
39
+ ".sql",
40
+ ".xml",
41
+ ".csv",
42
+ ".toml",
43
+ ".lock",
44
+ ".sh",
45
+ ".bash",
46
+ ".zsh",
47
+ ".env",
48
+ ".dockerfile",
49
+ "dockerfile",
50
+ ".gitignore",
51
+ ".npmrc",
52
+ ".prettierrc",
53
+ ".eslintrc",
54
+ ".editorconfig",
55
+ ".jsonc",
19
56
  ]);
20
57
 
21
58
  const ext = path.extname(filePath).toLowerCase();
@@ -23,17 +60,65 @@ export async function isTextFile(filePath: string): Promise<boolean> {
23
60
  return true;
24
61
  }
25
62
 
26
- // For files without extensions or unknown extensions, check for null bytes
27
- try {
28
- const buffer = await Bun.file(filePath).arrayBuffer();
29
- const uint8Array = new Uint8Array(buffer);
30
- for (let i = 0; i < Math.min(512, uint8Array.length); i++) {
31
- if (uint8Array[i] === 0) {
32
- return false;
33
- }
34
- }
35
- return true;
36
- } catch {
37
- return false;
38
- }
63
+ // For files without extensions or unknown extensions, check for null bytes
64
+ try {
65
+ const file = Bun.file(filePath);
66
+ if (file.size === 0) {
67
+ return true;
68
+ }
69
+
70
+ // Read only the first 512 bytes for binary check
71
+ const buffer = await file.slice(0, 512).arrayBuffer();
72
+ const uint8Array = new Uint8Array(buffer);
73
+ for (const byte of uint8Array) {
74
+ if (byte === 0) {
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Counts the number of lines in a file using a memory-efficient streaming approach.
86
+ * Handles empty files and trailing newlines correctly.
87
+ */
88
+ export async function getFileLineCount(filePath: string): Promise<number> {
89
+ try {
90
+ const file = Bun.file(filePath);
91
+ if (file.size === 0) {
92
+ return 0;
93
+ }
94
+
95
+ const stream = file.stream();
96
+ const reader = stream.getReader();
97
+ let count = 0;
98
+ let lastByte = -1;
99
+
100
+ while (true) {
101
+ const { done, value } = await reader.read();
102
+ if (done) {
103
+ break;
104
+ }
105
+
106
+ for (const byte of value) {
107
+ lastByte = byte;
108
+ if (lastByte === 10) {
109
+ // '\n'
110
+ count++;
111
+ }
112
+ }
113
+ }
114
+
115
+ // If the file doesn't end with a newline, count the last line
116
+ if (lastByte !== 10 && lastByte !== -1) {
117
+ count++;
118
+ }
119
+
120
+ return count;
121
+ } catch {
122
+ return 0;
123
+ }
39
124
  }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Formatting utility functions for tool outputs
3
+ */
4
+
5
+ export interface FileSystemEntry {
6
+ name: string;
7
+ path: string;
8
+ isDirectory: boolean;
9
+ size?: number;
10
+ lineCount?: number;
11
+ depth?: number;
12
+ }
13
+
14
+ export interface SearchMatch {
15
+ line: number;
16
+ column: number;
17
+ text: string;
18
+ context?: {
19
+ before: string[];
20
+ after: string[];
21
+ };
22
+ }
23
+
24
+ const MIN_LINE_NUM_WIDTH = 3;
25
+ const FILE_SEPARATOR = "--";
26
+
27
+ /**
28
+ * Add line numbers to an array of lines for display
29
+ */
30
+ export function withLineNumbers(lines: string[], startLine = 1): string {
31
+ if (!lines || lines.length === 0) {
32
+ return "";
33
+ }
34
+
35
+ const maxLineNumWidth = Math.max(
36
+ MIN_LINE_NUM_WIDTH,
37
+ String(startLine + lines.length - 1).length
38
+ );
39
+
40
+ return lines
41
+ .map((line, index) => {
42
+ const lineNum = startLine + index;
43
+ const paddedNum = String(lineNum).padStart(maxLineNumWidth);
44
+ return `${paddedNum}→${line}`;
45
+ })
46
+ .join("\n");
47
+ }
48
+
49
+ /**
50
+ * Format content with optional line range using line arrays for efficiency
51
+ */
52
+ export function formatLinesWithRange(
53
+ lines: string[],
54
+ viewRange?: [number, number]
55
+ ): string {
56
+ if (!lines || lines.length === 0) {
57
+ return "[File is empty]";
58
+ }
59
+
60
+ if (!viewRange) {
61
+ return withLineNumbers(lines);
62
+ }
63
+
64
+ const [start, end] = viewRange;
65
+ const startIndex = Math.max(0, start - 1);
66
+ const endIndex = end === -1 ? lines.length : Math.min(lines.length, end);
67
+
68
+ const selectedLines = lines.slice(startIndex, endIndex);
69
+ return withLineNumbers(selectedLines, start);
70
+ }
71
+
72
+ /**
73
+ * Legacy wrapper for formatLinesWithRange that accepts a string
74
+ * @deprecated Use formatLinesWithRange with pre-split lines for better performance
75
+ */
76
+ function _formatContentWithRange(
77
+ content: string,
78
+ viewRange?: [number, number]
79
+ ): string {
80
+ if (!content || content.trim() === "") {
81
+ return "[File is empty]";
82
+ }
83
+ return formatLinesWithRange(content.split("\n"), viewRange);
84
+ }
85
+
86
+ /**
87
+ * Format directory listing in tree-like format with indentation
88
+ */
89
+ export function formatDirectoryTree(entries: FileSystemEntry[]): string {
90
+ let output = "";
91
+
92
+ for (const entry of entries) {
93
+ const indent = " ".repeat(entry.depth || 0);
94
+ let line = `${indent}${entry.isDirectory ? `${entry.name}/` : entry.name}`;
95
+
96
+ // For files, show line count if available
97
+ if (!entry.isDirectory && entry.lineCount !== undefined) {
98
+ line += ` (${entry.lineCount} lines)`;
99
+ } else if (!entry.isDirectory && entry.size !== undefined) {
100
+ line += ` (${entry.size} bytes)`;
101
+ }
102
+
103
+ output += `${line}\n`;
104
+ }
105
+
106
+ return output.trimEnd();
107
+ }
108
+
109
+ /**
110
+ * Format search results with context
111
+ */
112
+ export function formatSearchResults(
113
+ results: { path: string; matches: SearchMatch[] }[]
114
+ ): string {
115
+ let output = "";
116
+ const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
117
+
118
+ output += `Found ${totalMatches} matches in ${results.length} files:\n\n`;
119
+
120
+ let fileIndex = 0;
121
+ for (const result of results) {
122
+ output += `${result.path}\n`;
123
+
124
+ const sortedMatches = [...result.matches].sort((a, b) => a.line - b.line);
125
+ const displayedLines = new Set<number>();
126
+ let lastPrintedLine = -1;
127
+
128
+ // Calculate max line num width for this file
129
+ let maxLineNum = 0;
130
+ for (const match of sortedMatches) {
131
+ const lastContextLine = match.line + (match.context?.after.length || 0);
132
+ maxLineNum = Math.max(maxLineNum, lastContextLine);
133
+ }
134
+ const maxLineNumWidth = Math.max(
135
+ MIN_LINE_NUM_WIDTH,
136
+ String(maxLineNum).length
137
+ );
138
+
139
+ for (const match of sortedMatches) {
140
+ // Before context
141
+ if (match.context?.before) {
142
+ const startLine = Math.max(1, match.line - match.context.before.length);
143
+ for (let j = 0; j < match.context.before.length; j++) {
144
+ const lineNum = startLine + j;
145
+ if (!displayedLines.has(lineNum)) {
146
+ if (lastPrintedLine !== -1 && lineNum > lastPrintedLine + 1) {
147
+ output += `${" ".repeat(maxLineNumWidth)}⁝\n`;
148
+ }
149
+ output += `${String(lineNum).padStart(maxLineNumWidth)}→${match.context.before[j]}\n`;
150
+ displayedLines.add(lineNum);
151
+ lastPrintedLine = lineNum;
152
+ }
153
+ }
154
+ }
155
+
156
+ // Match line
157
+ if (!displayedLines.has(match.line)) {
158
+ if (lastPrintedLine !== -1 && match.line > lastPrintedLine + 1) {
159
+ output += `${" ".repeat(maxLineNumWidth)}⁝\n`;
160
+ }
161
+ output += `${String(match.line).padStart(maxLineNumWidth)}→${match.text}\n`;
162
+ displayedLines.add(match.line);
163
+ lastPrintedLine = match.line;
164
+ }
165
+
166
+ // After context
167
+ if (match.context?.after) {
168
+ const startLine = match.line + 1;
169
+ for (let j = 0; j < match.context.after.length; j++) {
170
+ const lineNum = startLine + j;
171
+ if (!displayedLines.has(lineNum)) {
172
+ if (lastPrintedLine !== -1 && lineNum > lastPrintedLine + 1) {
173
+ output += `${" ".repeat(maxLineNumWidth)}⁝\n`;
174
+ }
175
+ output += `${String(lineNum).padStart(maxLineNumWidth)}→${match.context.after[j]}\n`;
176
+ displayedLines.add(lineNum);
177
+ lastPrintedLine = lineNum;
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ if (fileIndex < results.length - 1) {
184
+ output += `${FILE_SEPARATOR}\n`;
185
+ }
186
+ fileIndex++;
187
+ }
188
+
189
+ return output;
190
+ }
@@ -0,0 +1,123 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Glob } from "bun";
4
+
5
+ interface GitIgnorePattern {
6
+ pattern: string;
7
+ isNegation: boolean;
8
+ isDirectoryOnly: boolean;
9
+ glob: Glob;
10
+ }
11
+
12
+ // Regex for parsing gitignore line endings (moved to module level for performance)
13
+ const LINE_ENDING_REGEX = /\r?\n/;
14
+
15
+ /**
16
+ * Service to handle .gitignore patterns and filtering.
17
+ * Uses Bun's native Glob for high performance.
18
+ */
19
+ export class GitIgnoreService {
20
+ private readonly rootDir: string;
21
+ private patterns: GitIgnorePattern[] = [];
22
+ private initialized = false;
23
+
24
+ constructor(rootDir: string) {
25
+ this.rootDir = path.resolve(rootDir);
26
+ }
27
+
28
+ async initialize(): Promise<void> {
29
+ if (this.initialized) {
30
+ return;
31
+ }
32
+ try {
33
+ const gitignorePath = path.join(this.rootDir, ".gitignore");
34
+ const content = await readFile(gitignorePath, "utf-8");
35
+ this.patterns = this.parseGitIgnore(content);
36
+ } catch {
37
+ this.patterns = [];
38
+ }
39
+ this.initialized = true;
40
+ }
41
+
42
+ shouldIgnore(filePath: string, isDirectory = false): boolean {
43
+ if (!this.initialized) {
44
+ return false;
45
+ }
46
+
47
+ const relativePath = path.relative(this.rootDir, filePath);
48
+ if (relativePath === "" || relativePath === ".") {
49
+ return false;
50
+ }
51
+
52
+ let ignored = false;
53
+ let hasNegationMatch = false;
54
+ for (const p of this.patterns) {
55
+ if (p.glob.match(relativePath)) {
56
+ // If it's a directory-only pattern like "dist/", it should only match
57
+ // directories or files INSIDE a directory named "dist".
58
+ // Glob patterns like "**/dist{,/**}" will match the file "dist" too.
59
+ if (
60
+ p.isDirectoryOnly &&
61
+ !isDirectory &&
62
+ !relativePath.includes("/") &&
63
+ p.glob.match(relativePath)
64
+ ) {
65
+ // Naive check: if it's a directory-only pattern but the target is a file
66
+ // in the root (no slashes in relativePath), we skip it if it's an exact match.
67
+ // This is not perfect for nested files but good enough for common cases.
68
+ continue;
69
+ }
70
+ if (p.isNegation) {
71
+ ignored = false;
72
+ hasNegationMatch = true;
73
+ } else if (!hasNegationMatch) {
74
+ // Only set ignored to true if we haven't seen a negation pattern yet
75
+ ignored = true;
76
+ }
77
+ }
78
+ }
79
+ return ignored;
80
+ }
81
+
82
+ private parseGitIgnore(content: string): GitIgnorePattern[] {
83
+ const lines = content.split(LINE_ENDING_REGEX);
84
+ const patterns: GitIgnorePattern[] = [];
85
+
86
+ for (let line of lines) {
87
+ line = line.trim();
88
+ if (!line || line.startsWith("#")) {
89
+ continue;
90
+ }
91
+
92
+ let isNegation = false;
93
+ if (line.startsWith("!")) {
94
+ isNegation = true;
95
+ line = line.slice(1).trim();
96
+ }
97
+
98
+ const isDirectoryOnly = line.endsWith("/");
99
+ let cleanPattern = isDirectoryOnly ? line.slice(0, -1) : line;
100
+
101
+ if (cleanPattern.startsWith("/")) {
102
+ cleanPattern = cleanPattern.slice(1);
103
+ } else if (!cleanPattern.includes("/")) {
104
+ cleanPattern = `**/${cleanPattern}`;
105
+ }
106
+
107
+ // Pattern to match the item and everything beneath it
108
+ const finalPattern = `${cleanPattern}{,/**}`;
109
+
110
+ try {
111
+ patterns.push({
112
+ pattern: line,
113
+ isNegation,
114
+ isDirectoryOnly,
115
+ glob: new Glob(finalPattern),
116
+ });
117
+ } catch {
118
+ // Skip invalid
119
+ }
120
+ }
121
+ return patterns;
122
+ }
123
+ }