@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.
- package/CHANGELOG.md +176 -0
- package/LICENSE +210 -0
- package/README.md +614 -0
- package/biome.jsonc +9 -0
- package/dist/agents/context-schema.d.ts +17 -0
- package/dist/agents/context-schema.d.ts.map +1 -0
- package/dist/agents/context-schema.js +16 -0
- package/dist/agents/context-schema.js.map +1 -0
- package/dist/agents/react-agent.d.ts +38 -0
- package/dist/agents/react-agent.d.ts.map +1 -0
- package/dist/agents/react-agent.js +719 -0
- package/dist/agents/react-agent.js.map +1 -0
- package/dist/agents/tool-runtime.d.ts +7 -0
- package/dist/agents/tool-runtime.d.ts.map +1 -0
- package/dist/agents/tool-runtime.js +2 -0
- package/dist/agents/tool-runtime.js.map +1 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +172 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +243 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +470 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/file-finding.tool.d.ts +24 -0
- package/dist/tools/file-finding.tool.d.ts.map +1 -0
- package/dist/tools/file-finding.tool.js +198 -0
- package/dist/tools/file-finding.tool.js.map +1 -0
- package/dist/tools/file-listing.tool.d.ts +12 -0
- package/dist/tools/file-listing.tool.d.ts.map +1 -0
- package/dist/tools/file-listing.tool.js +132 -0
- package/dist/tools/file-listing.tool.js.map +1 -0
- package/dist/tools/file-reading.tool.d.ts +9 -0
- package/dist/tools/file-reading.tool.d.ts.map +1 -0
- package/dist/tools/file-reading.tool.js +112 -0
- package/dist/tools/file-reading.tool.js.map +1 -0
- package/dist/tools/grep-content.tool.d.ts +27 -0
- package/dist/tools/grep-content.tool.d.ts.map +1 -0
- package/dist/tools/grep-content.tool.js +229 -0
- package/dist/tools/grep-content.tool.js.map +1 -0
- package/dist/utils/file-utils.d.ts +2 -0
- package/dist/utils/file-utils.d.ts.map +1 -0
- package/dist/utils/file-utils.js +28 -0
- package/dist/utils/file-utils.js.map +1 -0
- package/dist/utils/logger.d.ts +32 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +177 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/path-utils.d.ts +2 -0
- package/dist/utils/path-utils.d.ts.map +1 -0
- package/dist/utils/path-utils.js +9 -0
- package/dist/utils/path-utils.js.map +1 -0
- package/package.json +84 -0
- package/src/agents/context-schema.ts +61 -0
- package/src/agents/react-agent.ts +928 -0
- package/src/agents/tool-runtime.ts +21 -0
- package/src/cli.ts +206 -0
- package/src/config.ts +309 -0
- package/src/index.ts +628 -0
- package/src/tools/file-finding.tool.ts +324 -0
- package/src/tools/file-listing.tool.ts +212 -0
- package/src/tools/file-reading.tool.ts +154 -0
- package/src/tools/grep-content.tool.ts +325 -0
- package/src/utils/file-utils.ts +39 -0
- package/src/utils/logger.ts +295 -0
- package/src/utils/path-utils.ts +17 -0
- package/tsconfig.json +37 -0
- package/tsconfig.test.json +17 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { tool } from "langchain";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readdir, stat } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { isTextFile } from "../utils/file-utils.js";
|
|
7
|
+
|
|
8
|
+
// Safe file read with encoding detection
|
|
9
|
+
async function readFileContent(filePath: string): Promise<string> {
|
|
10
|
+
try {
|
|
11
|
+
return await Bun.file(filePath).text();
|
|
12
|
+
} catch (error: unknown) {
|
|
13
|
+
const errorMessage =
|
|
14
|
+
error instanceof Error ? error.message : "Unknown error";
|
|
15
|
+
throw new Error(`Failed to read file: ${errorMessage}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Search for content in a single file
|
|
20
|
+
function searchFileContent(content: string, regex: RegExp) {
|
|
21
|
+
const lines = content.split("\n");
|
|
22
|
+
const matches: Array<{ line: number; text: string; column: number; match: RegExpExecArray | null }> = [];
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const line = lines[i];
|
|
26
|
+
if (line === undefined) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const match = regex.exec(line);
|
|
31
|
+
|
|
32
|
+
if (match) {
|
|
33
|
+
matches.push({
|
|
34
|
+
line: i + 1,
|
|
35
|
+
column: match.index + 1,
|
|
36
|
+
text: line,
|
|
37
|
+
match,
|
|
38
|
+
});
|
|
39
|
+
regex.lastIndex = 0;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return matches;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Find files matching a pattern in a directory
|
|
47
|
+
async function findFiles(
|
|
48
|
+
dirPath: string,
|
|
49
|
+
pattern: string,
|
|
50
|
+
recursive = true,
|
|
51
|
+
): Promise<string[]> {
|
|
52
|
+
const foundFiles: string[] = [];
|
|
53
|
+
|
|
54
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
55
|
+
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
58
|
+
|
|
59
|
+
if (entry.isDirectory()) {
|
|
60
|
+
if (recursive) {
|
|
61
|
+
const subDirFiles = await findFiles(fullPath, pattern, recursive);
|
|
62
|
+
foundFiles.push(...subDirFiles);
|
|
63
|
+
}
|
|
64
|
+
} else if (entry.isFile() && (pattern === "*" || entry.name.includes(pattern.replace(/\*/g, "")))) {
|
|
65
|
+
foundFiles.push(fullPath);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return foundFiles;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function validateAndResolvePath(workingDir: string, searchPath: string): Promise<string> {
|
|
73
|
+
const resolvedPath = path.resolve(workingDir, searchPath);
|
|
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
|
+
"Search path escapes working directory sandbox",
|
|
88
|
+
undefined,
|
|
89
|
+
{ searchPath, relativePath },
|
|
90
|
+
);
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Search path "${searchPath}" attempts to escape the working directory sandbox`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const stats = await stat(resolvedPath);
|
|
97
|
+
if (!stats.isDirectory()) {
|
|
98
|
+
logger.error("TOOL", "Search path is not a directory", undefined, {
|
|
99
|
+
searchPath,
|
|
100
|
+
});
|
|
101
|
+
throw new Error(`Search path "${searchPath}" is not a directory`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return resolvedPath;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function findFilesToSearch(
|
|
108
|
+
resolvedPath: string,
|
|
109
|
+
patterns: string[],
|
|
110
|
+
recursive: boolean,
|
|
111
|
+
): Promise<string[]> {
|
|
112
|
+
let filesToSearch: string[] = [];
|
|
113
|
+
for (const pattern of patterns) {
|
|
114
|
+
const foundFiles = await findFiles(resolvedPath, pattern, recursive);
|
|
115
|
+
filesToSearch = [...filesToSearch, ...foundFiles];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
logger.debug("TOOL", "Files to search", {
|
|
119
|
+
count: filesToSearch.length,
|
|
120
|
+
patterns,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return filesToSearch;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function compileSearchRegex(query: string, regex: boolean, caseSensitive: boolean): RegExp {
|
|
127
|
+
const flags = caseSensitive ? "gm" : "gim";
|
|
128
|
+
|
|
129
|
+
if (regex) {
|
|
130
|
+
try {
|
|
131
|
+
const searchRegex = new RegExp(query, flags);
|
|
132
|
+
logger.debug("TOOL", "Regex pattern compiled", { query, flags });
|
|
133
|
+
return searchRegex;
|
|
134
|
+
} catch (e) {
|
|
135
|
+
logger.error(
|
|
136
|
+
"TOOL",
|
|
137
|
+
"Invalid regex pattern",
|
|
138
|
+
e instanceof Error ? e : new Error(String(e)),
|
|
139
|
+
{ query },
|
|
140
|
+
);
|
|
141
|
+
throw new Error(`Invalid regex pattern: ${(e as Error).message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
146
|
+
const searchRegex = new RegExp(escapedQuery, flags);
|
|
147
|
+
logger.debug("TOOL", "Escaped query compiled to regex", {
|
|
148
|
+
originalQuery: query,
|
|
149
|
+
flags,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return searchRegex;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function performGrepSearch(
|
|
156
|
+
filesToSearch: string[],
|
|
157
|
+
searchRegex: RegExp,
|
|
158
|
+
maxResults: number,
|
|
159
|
+
): Promise<Array<{ path: string; matches: Array<{ line: number; text: string; column: number; match: RegExpExecArray | null }> }>> {
|
|
160
|
+
const results: Array<{ path: string; matches: Array<{ line: number; text: string; column: number; match: RegExpExecArray | null }> }> = [];
|
|
161
|
+
let totalMatches = 0;
|
|
162
|
+
|
|
163
|
+
for (const file of filesToSearch) {
|
|
164
|
+
if (totalMatches >= maxResults) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
if (await isTextFile(file)) {
|
|
168
|
+
try {
|
|
169
|
+
const content = await readFileContent(file);
|
|
170
|
+
const fileMatches = searchFileContent(content, searchRegex);
|
|
171
|
+
if (fileMatches.length > 0) {
|
|
172
|
+
const limitedMatches = fileMatches.slice(
|
|
173
|
+
0,
|
|
174
|
+
maxResults - totalMatches,
|
|
175
|
+
);
|
|
176
|
+
results.push({ path: file, matches: limitedMatches });
|
|
177
|
+
totalMatches += limitedMatches.length;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Silent fail for unreadable files
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatGrepResults(
|
|
189
|
+
results: Array<{ path: string; matches: Array<{ line: number; text: string; column: number; match: RegExpExecArray | null }> }>,
|
|
190
|
+
query: string,
|
|
191
|
+
): string {
|
|
192
|
+
if (results.length === 0) {
|
|
193
|
+
return `No matches found for query "${query}" in the searched files`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0);
|
|
197
|
+
let output = `Found ${totalMatches} matches for query "${query}" in ${results.length} files:\n\n`;
|
|
198
|
+
for (const result of results) {
|
|
199
|
+
output += `File: ${result.path}\n`;
|
|
200
|
+
for (const match of result.matches) {
|
|
201
|
+
output += ` Line ${match.line}, Col ${match.column}: ${match.text}\n`;
|
|
202
|
+
}
|
|
203
|
+
output += "\n";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return output;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Create the modernized tool using the tool() function
|
|
210
|
+
export const grepContentTool = tool(
|
|
211
|
+
async (
|
|
212
|
+
{
|
|
213
|
+
searchPath = ".",
|
|
214
|
+
query,
|
|
215
|
+
patterns = ["*"],
|
|
216
|
+
caseSensitive = false,
|
|
217
|
+
regex = false,
|
|
218
|
+
recursive = true,
|
|
219
|
+
maxResults = 100,
|
|
220
|
+
},
|
|
221
|
+
config,
|
|
222
|
+
) => {
|
|
223
|
+
const timingId = logger.timingStart("grepContent");
|
|
224
|
+
|
|
225
|
+
logger.info("TOOL", "grep_content called", {
|
|
226
|
+
searchPath,
|
|
227
|
+
queryLength: query.length,
|
|
228
|
+
patterns,
|
|
229
|
+
caseSensitive,
|
|
230
|
+
regex,
|
|
231
|
+
recursive,
|
|
232
|
+
maxResults,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const workingDir = config?.context?.workingDir;
|
|
237
|
+
if (!workingDir) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
"Context with workingDir is required for file operations",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
logger.debug("TOOL", "Working directory", {
|
|
243
|
+
workingDir: workingDir.replace(Bun.env.HOME || "", "~"),
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
if (!query) {
|
|
247
|
+
logger.error("TOOL", "Query parameter missing", undefined, {});
|
|
248
|
+
throw new Error('The "query" parameter is required');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const resolvedPath = await validateAndResolvePath(workingDir, searchPath);
|
|
252
|
+
const filesToSearch = await findFilesToSearch(resolvedPath, patterns, recursive);
|
|
253
|
+
|
|
254
|
+
const searchRegex = compileSearchRegex(query, regex, caseSensitive);
|
|
255
|
+
const results = await performGrepSearch(filesToSearch, searchRegex, maxResults);
|
|
256
|
+
|
|
257
|
+
logger.timingEnd(timingId, "TOOL", "grep_content completed");
|
|
258
|
+
logger.debug("TOOL", "Search completed", {
|
|
259
|
+
filesSearched: filesToSearch.length,
|
|
260
|
+
filesWithMatches: results.length,
|
|
261
|
+
totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
return formatGrepResults(results, query);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
logger.error(
|
|
267
|
+
"TOOL",
|
|
268
|
+
"grep_content failed",
|
|
269
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
270
|
+
{ searchPath, query },
|
|
271
|
+
);
|
|
272
|
+
return `Error searching content: ${(error as Error).message}`;
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
name: "grep_content",
|
|
277
|
+
description: `A powerful search tool built on ripgrep
|
|
278
|
+
|
|
279
|
+
Usage:
|
|
280
|
+
- ALWAYS use grep_content for search tasks.
|
|
281
|
+
- Supports full regex syntax (e.g., "log.*Error", "function\\s+\\w+")
|
|
282
|
+
- Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust")
|
|
283
|
+
- Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use \`interface{}\` to find \`interface{}\` in Go code)
|
|
284
|
+
`,
|
|
285
|
+
schema: z.object({
|
|
286
|
+
searchPath: z.string().describe("The directory path to search in"),
|
|
287
|
+
query: z
|
|
288
|
+
.string()
|
|
289
|
+
.describe(
|
|
290
|
+
"The search query - the text or pattern to look for in files",
|
|
291
|
+
),
|
|
292
|
+
patterns: z
|
|
293
|
+
.array(z.string())
|
|
294
|
+
.optional()
|
|
295
|
+
.default(["*"])
|
|
296
|
+
.describe("File patterns to search in (e.g., ['*.js', '*.ts'])"),
|
|
297
|
+
caseSensitive: z
|
|
298
|
+
.boolean()
|
|
299
|
+
.optional()
|
|
300
|
+
.default(false)
|
|
301
|
+
.describe(
|
|
302
|
+
"Whether the search should be case-sensitive. Defaults to `false`",
|
|
303
|
+
),
|
|
304
|
+
regex: z
|
|
305
|
+
.boolean()
|
|
306
|
+
.optional()
|
|
307
|
+
.default(false)
|
|
308
|
+
.describe(
|
|
309
|
+
"Whether the query should be treated as a regular expression. Defaults to `false`",
|
|
310
|
+
),
|
|
311
|
+
recursive: z
|
|
312
|
+
.boolean()
|
|
313
|
+
.optional()
|
|
314
|
+
.default(true)
|
|
315
|
+
.describe(
|
|
316
|
+
"Whether to search recursively in subdirectories. Defaults to `true`",
|
|
317
|
+
),
|
|
318
|
+
maxResults: z
|
|
319
|
+
.number()
|
|
320
|
+
.optional()
|
|
321
|
+
.default(100)
|
|
322
|
+
.describe("Maximum number of matches to return. Defaults to 100"),
|
|
323
|
+
}),
|
|
324
|
+
},
|
|
325
|
+
);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File utility functions
|
|
3
|
+
* Shared utilities for file type detection and file operations
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check if a file is text-based
|
|
10
|
+
* Uses extension detection and binary content detection
|
|
11
|
+
*/
|
|
12
|
+
export async function isTextFile(filePath: string): Promise<boolean> {
|
|
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'
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
+
if (textExtensions.has(ext)) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
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
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger utility for librarian CLI
|
|
3
|
+
* Writes all logs to timestamped files in ~/.config/librarian/logs/
|
|
4
|
+
* Silent failure on write errors
|
|
5
|
+
* Metadata-only logging (no sensitive data)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import type { FileSink } from "bun";
|
|
12
|
+
|
|
13
|
+
export type LogLevel = 'INFO' | 'DEBUG' | 'WARN' | 'ERROR';
|
|
14
|
+
export type LogComponent = 'CLI' | 'CONFIG' | 'LIBRARIAN' | 'AGENT' | 'TOOL' | 'LLM' | 'GIT' | 'PATH' | 'LOGGER' | 'TIMING';
|
|
15
|
+
|
|
16
|
+
interface LogMetadata {
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface TimingOperation {
|
|
21
|
+
operation: string;
|
|
22
|
+
startTime: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class Logger {
|
|
26
|
+
private static instance: Logger;
|
|
27
|
+
private writer: FileSink | null = null;
|
|
28
|
+
private debugMode = false;
|
|
29
|
+
private readonly timingOperations: Map<string, TimingOperation> = new Map();
|
|
30
|
+
|
|
31
|
+
private constructor() {
|
|
32
|
+
this.initializeLogFile().catch(() => {
|
|
33
|
+
// Silent - do nothing on initialization errors
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static getInstance(): Logger {
|
|
38
|
+
if (!Logger.instance) {
|
|
39
|
+
Logger.instance = new Logger();
|
|
40
|
+
}
|
|
41
|
+
return Logger.instance;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setDebugMode(enabled: boolean): void {
|
|
45
|
+
this.debugMode = enabled;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize the log file with timestamp
|
|
50
|
+
* Format: ~/.config/librarian/logs/YYYY-MM-DD_HH-MM-SS_mmm-librarian.log
|
|
51
|
+
*/
|
|
52
|
+
private async initializeLogFile(): Promise<void> {
|
|
53
|
+
try {
|
|
54
|
+
// Create timestamp for filename
|
|
55
|
+
const now = new Date();
|
|
56
|
+
const isoString = now.toISOString();
|
|
57
|
+
|
|
58
|
+
if (!isoString) {
|
|
59
|
+
throw new Error('Failed to generate timestamp');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Create timestamp: YYYY-MM-DD_HH-MM-SS
|
|
63
|
+
const year = now.getFullYear();
|
|
64
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
65
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
66
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
67
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
68
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
69
|
+
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
70
|
+
|
|
71
|
+
const timestamp = `${year}-${month}-${day}_${hours}-${minutes}-${seconds}_${ms}`;
|
|
72
|
+
|
|
73
|
+
// Log directory
|
|
74
|
+
const logDir = path.join(os.homedir(), '.config', 'librarian', 'logs');
|
|
75
|
+
|
|
76
|
+
// Ensure directory exists
|
|
77
|
+
if (!(await Bun.file(logDir).exists())) {
|
|
78
|
+
await mkdir(logDir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Log filename
|
|
82
|
+
const logFilename = `${timestamp}-librarian.log`;
|
|
83
|
+
const logPath = path.join(logDir, logFilename);
|
|
84
|
+
|
|
85
|
+
// Create write stream
|
|
86
|
+
const logFile = Bun.file(logPath);
|
|
87
|
+
this.writer = logFile.writer();
|
|
88
|
+
|
|
89
|
+
// Log initialization
|
|
90
|
+
this.info('LOGGER', `Logging initialized: ${logPath}`);
|
|
91
|
+
|
|
92
|
+
} catch {
|
|
93
|
+
// Silent - do nothing on initialization errors
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Format timestamp: YYYY-MM-DD HH:MM:SS.mmm
|
|
99
|
+
*/
|
|
100
|
+
private formatTimestamp(): string {
|
|
101
|
+
const now = new Date();
|
|
102
|
+
const year = now.getFullYear();
|
|
103
|
+
const month = String(now.getMonth() + 1).padStart(2, '0');
|
|
104
|
+
const day = String(now.getDate()).padStart(2, '0');
|
|
105
|
+
const hours = String(now.getHours()).padStart(2, '0');
|
|
106
|
+
const minutes = String(now.getMinutes()).padStart(2, '0');
|
|
107
|
+
const seconds = String(now.getSeconds()).padStart(2, '0');
|
|
108
|
+
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
109
|
+
|
|
110
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Redact sensitive data from metadata
|
|
115
|
+
*/
|
|
116
|
+
private redactMetadata(metadata: LogMetadata): LogMetadata {
|
|
117
|
+
const redacted: LogMetadata = {};
|
|
118
|
+
|
|
119
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
120
|
+
redacted[key] = this.getRedactedValue(key, value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return redacted;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private getRedactedValue(key: string, value: unknown): unknown {
|
|
127
|
+
const lowerKey = key.toLowerCase();
|
|
128
|
+
|
|
129
|
+
if (['apiKey', 'token', 'secret', 'password'].includes(lowerKey)) {
|
|
130
|
+
return '[REDACTED]';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if ((key === 'query' || key === 'content') && typeof value === 'string') {
|
|
134
|
+
return value.length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if ((key === 'repoUrl' || key === 'baseURL') && typeof value === 'string') {
|
|
138
|
+
return this.redactUrl(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (key === 'workingDir' && typeof value === 'string') {
|
|
142
|
+
return value.replace(os.homedir(), '~');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (typeof value === 'string' && (value.includes(os.homedir()) || value.includes('/home/'))) {
|
|
146
|
+
return value.replace(os.homedir(), '~');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private redactUrl(value: string): string {
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(value);
|
|
155
|
+
return url.hostname;
|
|
156
|
+
} catch {
|
|
157
|
+
return '[INVALID_URL]';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Format log entry
|
|
163
|
+
*/
|
|
164
|
+
private formatLogEntry(level: LogLevel, component: LogComponent, message: string, metadata?: LogMetadata): string {
|
|
165
|
+
const timestamp = this.formatTimestamp();
|
|
166
|
+
let entry = `[${timestamp}] [${level}] [${component}] ${message}`;
|
|
167
|
+
|
|
168
|
+
if (metadata && Object.keys(metadata).length > 0) {
|
|
169
|
+
const redactedMetadata = this.redactMetadata(metadata);
|
|
170
|
+
entry += ` | ${JSON.stringify(redactedMetadata)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return entry;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Write log entry to file
|
|
178
|
+
*/
|
|
179
|
+
private writeLog(entry: string): void {
|
|
180
|
+
try {
|
|
181
|
+
if (this.writer) {
|
|
182
|
+
this.writer.write(`${entry}\n`);
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
// Silent - do nothing on write errors
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Internal logging method
|
|
191
|
+
*/
|
|
192
|
+
private log(level: LogLevel, component: LogComponent, message: string, metadata?: LogMetadata): void {
|
|
193
|
+
try {
|
|
194
|
+
const entry = this.formatLogEntry(level, component, message, metadata);
|
|
195
|
+
this.writeLog(entry);
|
|
196
|
+
} catch {
|
|
197
|
+
// Silent - do nothing on log errors
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* INFO level - Always logged
|
|
203
|
+
*/
|
|
204
|
+
info(component: LogComponent, message: string, metadata?: LogMetadata): void {
|
|
205
|
+
this.log('INFO', component, message, metadata);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* DEBUG level - Only logged when debug mode is enabled
|
|
210
|
+
*/
|
|
211
|
+
debug(component: LogComponent, message: string, metadata?: LogMetadata): void {
|
|
212
|
+
if (this.debugMode) {
|
|
213
|
+
this.log('DEBUG', component, message, metadata);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* WARN level - Always logged
|
|
219
|
+
*/
|
|
220
|
+
warn(component: LogComponent, message: string, metadata?: LogMetadata): void {
|
|
221
|
+
this.log('WARN', component, message, metadata);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* ERROR level - Always logged with stack trace
|
|
226
|
+
*/
|
|
227
|
+
error(component: LogComponent, message: string, error?: Error, metadata?: LogMetadata): void {
|
|
228
|
+
const errorMetadata: LogMetadata = metadata ? { ...metadata } : {};
|
|
229
|
+
|
|
230
|
+
if (error) {
|
|
231
|
+
errorMetadata.errorName = error.name;
|
|
232
|
+
errorMetadata.errorMessage = error.message;
|
|
233
|
+
|
|
234
|
+
// Include stack trace in metadata
|
|
235
|
+
if (error.stack) {
|
|
236
|
+
errorMetadata.stack = error.stack;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
this.log('ERROR', component, message, errorMetadata);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Start timing an operation
|
|
245
|
+
* Returns an operation ID for later timingEnd call
|
|
246
|
+
*/
|
|
247
|
+
timingStart(operation: string): string {
|
|
248
|
+
const operationId = `${operation}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
249
|
+
this.timingOperations.set(operationId, {
|
|
250
|
+
operation,
|
|
251
|
+
startTime: performance.now()
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
this.debug('TIMING', `Started: ${operation}`);
|
|
255
|
+
|
|
256
|
+
return operationId;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* End timing an operation
|
|
261
|
+
*/
|
|
262
|
+
timingEnd(operationId: string, component: LogComponent, message?: string): void {
|
|
263
|
+
const timing = this.timingOperations.get(operationId);
|
|
264
|
+
|
|
265
|
+
if (timing) {
|
|
266
|
+
const duration = performance.now() - timing.startTime;
|
|
267
|
+
const durationMs = Math.round(duration);
|
|
268
|
+
|
|
269
|
+
this.timingOperations.delete(operationId);
|
|
270
|
+
|
|
271
|
+
const logMessage = message || `Completed: ${timing.operation}`;
|
|
272
|
+
this.info(component, logMessage, { duration: `${durationMs}ms` });
|
|
273
|
+
|
|
274
|
+
if (this.debugMode) {
|
|
275
|
+
this.debug('TIMING', `Ended: ${timing.operation}`, { durationMs });
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clean up resources
|
|
282
|
+
*/
|
|
283
|
+
close(): void {
|
|
284
|
+
try {
|
|
285
|
+
if (this.writer) {
|
|
286
|
+
this.writer.end();
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
// Silent
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Export singleton instance
|
|
295
|
+
export const logger = Logger.getInstance();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path utility functions
|
|
3
|
+
* Shared utilities for path manipulation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Expand tilde (~) in file paths to home directory
|
|
11
|
+
*/
|
|
12
|
+
export function expandTilde(filePath: string): string {
|
|
13
|
+
if (filePath.startsWith('~/')) {
|
|
14
|
+
return path.join(os.homedir(), filePath.slice(2));
|
|
15
|
+
}
|
|
16
|
+
return filePath;
|
|
17
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "node",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"allowSyntheticDefaultImports": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"forceConsistentCasingInFileNames": true,
|
|
11
|
+
"outDir": "./dist",
|
|
12
|
+
"rootDir": "./src",
|
|
13
|
+
"declaration": true,
|
|
14
|
+
"declarationMap": true,
|
|
15
|
+
"sourceMap": true,
|
|
16
|
+
"removeComments": true,
|
|
17
|
+
"noImplicitAny": true,
|
|
18
|
+
"strictNullChecks": true,
|
|
19
|
+
"strictFunctionTypes": true,
|
|
20
|
+
"noImplicitThis": true,
|
|
21
|
+
"noImplicitReturns": true,
|
|
22
|
+
"noFallthroughCasesInSwitch": true,
|
|
23
|
+
"noUncheckedIndexedAccess": true,
|
|
24
|
+
"noImplicitOverride": true,
|
|
25
|
+
"allowUnreachableCode": false,
|
|
26
|
+
"allowUnusedLabels": false,
|
|
27
|
+
"exactOptionalPropertyTypes": true
|
|
28
|
+
},
|
|
29
|
+
"include": [
|
|
30
|
+
"src/**/*"
|
|
31
|
+
],
|
|
32
|
+
"exclude": [
|
|
33
|
+
"node_modules",
|
|
34
|
+
"dist",
|
|
35
|
+
"tests"
|
|
36
|
+
]
|
|
37
|
+
}
|