@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
package/src/index.ts ADDED
@@ -0,0 +1,628 @@
1
+ /**
2
+ * Librarian CLI - Technology Research Agent
3
+ * Main entry point for the application
4
+ */
5
+
6
+ import { clone, fetch, checkout } from 'isomorphic-git';
7
+ import http from 'isomorphic-git/http/node';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ import { ReactAgent } from './agents/react-agent.js';
12
+ import type { AgentContext } from './agents/context-schema.js';
13
+ import { logger } from './utils/logger.js';
14
+ import os from 'node:os';
15
+
16
+ export interface LibrarianConfig {
17
+ technologies: {
18
+ [group: string]: {
19
+ [tech: string]: {
20
+ repo: string;
21
+ branch?: string;
22
+ description?: string;
23
+ };
24
+ };
25
+ };
26
+ aiProvider: {
27
+ type: 'openai' | 'anthropic' | 'google' | 'openai-compatible' | 'anthropic-compatible' | 'claude-code' | 'gemini-cli';
28
+ apiKey: string;
29
+ model?: string;
30
+ baseURL?: string;
31
+ };
32
+ workingDir: string;
33
+ repos_path?: string;
34
+ }
35
+
36
+ export class Librarian {
37
+ private readonly config: LibrarianConfig;
38
+
39
+ constructor(config: LibrarianConfig) {
40
+ // Validate AI provider type
41
+ const validProviderTypes = [
42
+ 'openai',
43
+ 'anthropic',
44
+ 'google',
45
+ 'openai-compatible',
46
+ 'anthropic-compatible',
47
+ 'claude-code',
48
+ 'gemini-cli',
49
+ ] as const;
50
+ type ValidProviderType = typeof validProviderTypes[number];
51
+
52
+ if (!validProviderTypes.includes(config.aiProvider.type as ValidProviderType)) {
53
+ throw new Error(`Unsupported AI provider type: ${config.aiProvider.type}`);
54
+ }
55
+
56
+ this.config = config;
57
+
58
+ logger.info('LIBRARIAN', 'Initializing librarian', {
59
+ aiProviderType: config.aiProvider.type,
60
+ model: config.aiProvider.model,
61
+ workingDir: config.workingDir.replace(os.homedir(), '~'),
62
+ reposPath: config.repos_path ? config.repos_path.replace(os.homedir(), '~') : 'workingDir'
63
+ });
64
+ }
65
+
66
+ async initialize(): Promise<void> {
67
+ // Check if Claude CLI is available if using claude-code provider
68
+ if (this.config.aiProvider.type === 'claude-code') {
69
+ try {
70
+ const { execSync } = await import('node:child_process');
71
+ execSync('claude --version', { stdio: 'ignore' });
72
+ logger.info('LIBRARIAN', 'Claude CLI verified');
73
+ } catch {
74
+ logger.error('LIBRARIAN', 'Claude CLI not found in PATH', undefined, { type: 'claude-code' });
75
+ console.error('Error: "claude" CLI not found. Please install it to use the "claude-code" provider.');
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ // Check if Gemini CLI is available if using gemini-cli provider
81
+ if (this.config.aiProvider.type === 'gemini-cli') {
82
+ try {
83
+ const { execSync } = await import('node:child_process');
84
+ execSync('gemini --version', { stdio: 'ignore' });
85
+ logger.info('LIBRARIAN', 'Gemini CLI verified');
86
+ } catch {
87
+ logger.error('LIBRARIAN', 'Gemini CLI not found in PATH', undefined, { type: 'gemini-cli' });
88
+ console.error('Error: "gemini" CLI not found. Please install it to use the "gemini-cli" provider.');
89
+ process.exit(1);
90
+ }
91
+ }
92
+
93
+ // Create working directory if it doesn't exist
94
+ const workDir = this.config.repos_path || this.config.workingDir;
95
+ if (fs.existsSync(workDir)) {
96
+ logger.debug('LIBRARIAN', 'Working directory already exists', { path: workDir.replace(os.homedir(), '~') });
97
+ } else {
98
+ logger.info('LIBRARIAN', 'Creating working directory', { path: workDir.replace(os.homedir(), '~') });
99
+ fs.mkdirSync(workDir, { recursive: true });
100
+ }
101
+
102
+ logger.info('LIBRARIAN', 'Initialization complete');
103
+ }
104
+
105
+ resolveTechnology(qualifiedName: string): { name: string, group: string, repo: string, branch: string } | undefined {
106
+ logger.debug('LIBRARIAN', 'Resolving technology', { qualifiedName });
107
+
108
+ let group: string | undefined;
109
+ let name: string;
110
+
111
+ if (qualifiedName.includes(':')) {
112
+ const parts = qualifiedName.split(':');
113
+ group = parts[0];
114
+ name = parts[1] || '';
115
+ } else {
116
+ name = qualifiedName;
117
+ }
118
+
119
+ if (group) {
120
+ const groupTechs = this.config.technologies[group];
121
+ const tech = groupTechs ? groupTechs[name] : undefined;
122
+ if (tech) {
123
+ const result = { name, group, repo: tech.repo, branch: tech.branch || 'main' };
124
+ logger.debug('LIBRARIAN', 'Technology resolved with explicit group', { name, group, repoHost: tech.repo.split('/')[2] || 'unknown' });
125
+ return result;
126
+ }
127
+ } else {
128
+ for (const [groupName, techs] of Object.entries(this.config.technologies)) {
129
+ const tech = techs[name];
130
+ if (tech) {
131
+ const result = { name, group: groupName, repo: tech.repo, branch: tech.branch || 'main' };
132
+ logger.debug('LIBRARIAN', 'Technology resolved by search', { name, group: groupName, repoHost: tech.repo.split('/')[2] || 'unknown' });
133
+ return result;
134
+ }
135
+ }
136
+ }
137
+ logger.debug('LIBRARIAN', 'Technology not found in configuration', { qualifiedName });
138
+ return undefined;
139
+ }
140
+
141
+ private getSecureGroupPath(groupName: string): string {
142
+ logger.debug('PATH', 'Validating group path', { groupName });
143
+
144
+ if (groupName.includes('../') || groupName.includes('..\\') || groupName.startsWith('..')) {
145
+ logger.error('PATH', 'Group name contains path traversal characters', undefined, { groupName });
146
+ throw new Error(`Group name "${groupName}" contains invalid path characters`);
147
+ }
148
+
149
+ const sanitizedGroupName = path.basename(groupName);
150
+ const workDir = this.config.repos_path || this.config.workingDir;
151
+ const groupPath = path.join(workDir, sanitizedGroupName);
152
+
153
+ const resolvedWorkingDir = path.resolve(workDir);
154
+ const resolvedGroupPath = path.resolve(groupPath);
155
+
156
+ if (!resolvedGroupPath.startsWith(resolvedWorkingDir)) {
157
+ logger.error('PATH', 'Group path escapes working directory sandbox', undefined, { groupName });
158
+ throw new Error(`Group name "${groupName}" attempts to escape the working directory sandbox`);
159
+ }
160
+
161
+ logger.debug('PATH', 'Group path validated', { groupName, path: groupPath.replace(os.homedir(), '~') });
162
+ return groupPath;
163
+ }
164
+
165
+ private getSecureRepoPath(repoName: string, groupName = 'default'): string {
166
+ logger.debug('PATH', 'Validating repo path', { repoName, groupName });
167
+
168
+ // Check for path traversal attempts in the repoName before sanitizing
169
+ if (repoName.includes('../') || repoName.includes('..\\') || repoName.startsWith('..')) {
170
+ logger.error('PATH', 'Repo name contains path traversal characters', undefined, { repoName, groupName });
171
+ throw new Error(`Repository name "${repoName}" contains invalid path characters`);
172
+ }
173
+
174
+ // Sanitize the names
175
+ const sanitizedRepoName = path.basename(repoName);
176
+ const groupPath = this.getSecureGroupPath(groupName);
177
+ const repoPath = path.join(groupPath, sanitizedRepoName);
178
+
179
+ // Verify that the resulting path is within the group directory (which is already sandboxed)
180
+ const resolvedGroupDir = path.resolve(groupPath);
181
+ const resolvedRepoPath = path.resolve(repoPath);
182
+
183
+ if (!resolvedRepoPath.startsWith(resolvedGroupDir)) {
184
+ logger.error('PATH', 'Repo path escapes group sandbox', undefined, { repoName, groupName });
185
+ throw new Error(`Repository name "${repoName}" attempts to escape the group sandbox`);
186
+ }
187
+
188
+ logger.debug('PATH', 'Repo path validated', { repoName, groupName, path: repoPath.replace(os.homedir(), '~') });
189
+ return repoPath;
190
+ }
191
+
192
+ async updateRepository(repoName: string, groupName = 'default'): Promise<void> {
193
+ logger.info('GIT', 'Updating repository', { repoName, groupName });
194
+
195
+ const timingId = logger.timingStart('updateRepository');
196
+
197
+ const repoPath = this.getSecureRepoPath(repoName, groupName);
198
+ const gitPath = path.join(repoPath, '.git');
199
+
200
+ if (!fs.existsSync(repoPath)) {
201
+ logger.error('GIT', 'Repository path does not exist', undefined, { repoName, repoPath: repoPath.replace(os.homedir(), '~') });
202
+ throw new Error(`Repository ${repoName} does not exist at ${repoPath}. Cannot update.`);
203
+ }
204
+
205
+ if (!fs.existsSync(gitPath)) {
206
+ logger.error('GIT', 'Directory is not a git repository', undefined, { repoName, repoPath: repoPath.replace(os.homedir(), '~') });
207
+ throw new Error(`Directory ${repoName} exists at ${repoPath} but is not a git repository. Cannot update.`);
208
+ }
209
+
210
+ // Fetch updates from the remote
211
+ logger.debug('GIT', 'Fetching updates from remote');
212
+ await fetch({
213
+ fs,
214
+ http,
215
+ dir: repoPath,
216
+ singleBranch: true,
217
+ });
218
+
219
+ const tech = this.resolveTechnology(`${groupName}:${repoName}`) || this.resolveTechnology(repoName);
220
+ const branch = tech?.branch || 'main';
221
+
222
+ // Checkout the latest version
223
+ logger.debug('GIT', 'Checking out branch', { branch });
224
+ await checkout({
225
+ fs,
226
+ dir: repoPath,
227
+ ref: `origin/${branch}`,
228
+ });
229
+
230
+ logger.timingEnd(timingId, 'GIT', `Repository updated: ${repoName}`);
231
+ }
232
+
233
+ async syncRepository(repoName: string): Promise<string> {
234
+ logger.info('GIT', 'Syncing repository', { repoName });
235
+
236
+ const tech = this.resolveTechnology(repoName);
237
+
238
+ if (!tech) {
239
+ logger.error('GIT', 'Technology not found in configuration', undefined, { repoName });
240
+ throw new Error(`Repository ${repoName} not found in configuration`);
241
+ }
242
+
243
+ const repoPath = this.getSecureRepoPath(tech.name, tech.group);
244
+
245
+ // Check if this is a local path (not a remote URL)
246
+ const isLocalRepo = !(tech.repo.startsWith('http://') || tech.repo.startsWith('https://'));
247
+
248
+ if (fs.existsSync(repoPath)) {
249
+ // Repository exists
250
+ if (isLocalRepo) {
251
+ // For local repos, skip git operations - just use existing files
252
+ logger.debug('GIT', 'Local repository exists, skipping git operations');
253
+ return repoPath;
254
+ }
255
+ // Remote repository, update it (no-op for local repos to avoid isomorphic-git issues with local paths)
256
+ if (!isLocalRepo) {
257
+ logger.debug('GIT', 'Repository exists, performing update');
258
+ await this.updateRepository(tech.name, tech.group);
259
+ }
260
+ } else {
261
+ // Repository doesn't exist
262
+ if (isLocalRepo) {
263
+ // Local repo doesn't exist - this is an error in test setup
264
+ logger.error('GIT', 'Local repository path does not exist', undefined, { repoName, repoPath });
265
+ throw new Error(`Local repository ${repoName} does not exist at ${repoPath}`);
266
+ }
267
+ // Remote repository, clone it
268
+ logger.debug('GIT', 'Repository does not exist, performing clone');
269
+ return await this.cloneRepository(tech.name, tech.repo, tech.group, tech.branch);
270
+ }
271
+
272
+ return repoPath;
273
+ }
274
+
275
+ async cloneRepository(repoName: string, repoUrl: string, groupName = 'default', branch = 'main'): Promise<string> {
276
+ logger.info('GIT', 'Cloning repository', { repoName, repoHost: repoUrl.split('/')[2] || 'unknown', branch, groupName });
277
+
278
+ const timingId = logger.timingStart('cloneRepository');
279
+
280
+ const repoPath = this.getSecureRepoPath(repoName, groupName);
281
+
282
+ // Check if repository already exists
283
+ if (fs.existsSync(repoPath)) {
284
+ // Check if it's a git repository by checking for .git folder
285
+ const gitPath = path.join(repoPath, '.git');
286
+ if (fs.existsSync(gitPath)) {
287
+ logger.debug('GIT', 'Repository already exists as git repo, updating instead');
288
+ await this.updateRepository(repoName, groupName);
289
+ return repoPath;
290
+ }
291
+ }
292
+
293
+ // Ensure group directory exists
294
+ const groupPath = this.getSecureGroupPath(groupName);
295
+ if (!fs.existsSync(groupPath)) {
296
+ logger.debug('GIT', 'Creating group directory', { groupName, path: groupPath.replace(os.homedir(), '~') });
297
+ fs.mkdirSync(groupPath, { recursive: true });
298
+ }
299
+
300
+ logger.debug('GIT', 'Starting shallow clone', { depth: 1 });
301
+ await clone({
302
+ fs,
303
+ http,
304
+ dir: repoPath,
305
+ url: repoUrl,
306
+ ref: branch,
307
+ singleBranch: true,
308
+ depth: 1, // Shallow clone for faster operation
309
+ });
310
+
311
+ // Count files cloned
312
+ let fileCount = 0;
313
+ const countFiles = (dir: string) => {
314
+ try {
315
+ const files = fs.readdirSync(dir, { withFileTypes: true });
316
+ for (const file of files) {
317
+ if (file.name === '.git') {
318
+ continue;
319
+ }
320
+ if (file.isDirectory()) {
321
+ countFiles(path.join(dir, file.name));
322
+ } else {
323
+ fileCount++;
324
+ }
325
+ }
326
+ } catch {
327
+ // Ignore errors
328
+ }
329
+ };
330
+ countFiles(repoPath);
331
+
332
+ logger.debug('GIT', 'Clone completed', { fileCount });
333
+
334
+ logger.timingEnd(timingId, 'GIT', `Repository cloned: ${repoName}`);
335
+ return repoPath;
336
+ }
337
+
338
+ async queryRepository(repoName: string, query: string): Promise<string> {
339
+ logger.info('LIBRARIAN', 'Querying repository', { repoName, queryLength: query.length });
340
+
341
+ const timingId = logger.timingStart('queryRepository');
342
+
343
+ const tech = this.resolveTechnology(repoName);
344
+
345
+ if (!tech) {
346
+ logger.error('LIBRARIAN', 'Technology not found in configuration', undefined, { repoName });
347
+ throw new Error(`Repository ${repoName} not found in configuration`);
348
+ }
349
+
350
+ // Clone or sync the repository first
351
+ const repoPath = await this.syncRepository(repoName);
352
+
353
+ // Construct context object with working directory
354
+ const context: AgentContext = {
355
+ workingDir: repoPath,
356
+ group: tech.group,
357
+ technology: tech.name,
358
+ };
359
+
360
+ logger.debug('LIBRARIAN', 'Initializing agent for query with context', {
361
+ workingDir: context.workingDir.replace(os.homedir(), '~'),
362
+ group: context.group,
363
+ technology: context.technology
364
+ });
365
+
366
+ // Initialize the agent
367
+ const agent = new ReactAgent({
368
+ aiProvider: this.config.aiProvider,
369
+ workingDir: repoPath,
370
+ technology: {
371
+ name: tech.name,
372
+ repository: tech.repo,
373
+ branch: tech.branch
374
+ }
375
+ });
376
+ await agent.initialize();
377
+
378
+ // Execute the query using the agent with context
379
+ const result = await agent.queryRepository(repoPath, query, context);
380
+
381
+ logger.timingEnd(timingId, 'LIBRARIAN', `Query completed: ${repoName}`);
382
+ logger.info('LIBRARIAN', 'Query result received', { repoName, responseLength: result.length });
383
+
384
+ return result;
385
+ }
386
+
387
+ async *streamRepository(repoName: string, query: string): AsyncGenerator<string, void, unknown> {
388
+ logger.info('LIBRARIAN', 'Streaming repository query', { repoName, queryLength: query.length });
389
+
390
+ const timingId = logger.timingStart('streamRepository');
391
+
392
+ const tech = this.resolveTechnology(repoName);
393
+
394
+ if (!tech) {
395
+ logger.error('LIBRARIAN', 'Technology not found in configuration', undefined, { repoName });
396
+ throw new Error(`Repository ${repoName} not found in configuration`);
397
+ }
398
+
399
+ // Set up interruption handling at Librarian level
400
+ let isInterrupted = false;
401
+ const cleanup = () => {
402
+ isInterrupted = true;
403
+ };
404
+
405
+ // Listen for interruption signals (Ctrl+C)
406
+ logger.debug('LIBRARIAN', 'Setting up interruption handlers');
407
+ process.on('SIGINT', cleanup);
408
+ process.on('SIGTERM', cleanup);
409
+
410
+ try {
411
+ // Clone or sync repository first
412
+ const repoPath = await this.syncRepository(repoName);
413
+
414
+ // Check for interruption after sync
415
+ if (isInterrupted) {
416
+ logger.warn('LIBRARIAN', 'Repository sync interrupted by user', { repoName });
417
+ yield '[Repository sync interrupted by user]';
418
+ return;
419
+ }
420
+
421
+ // Construct context object with working directory
422
+ const context: AgentContext = {
423
+ workingDir: repoPath,
424
+ group: tech.group,
425
+ technology: tech.name,
426
+ };
427
+
428
+ logger.debug('LIBRARIAN', 'Initializing agent for streaming query with context', {
429
+ workingDir: context.workingDir.replace(os.homedir(), '~'),
430
+ group: context.group,
431
+ technology: context.technology
432
+ });
433
+
434
+ // Initialize agent
435
+ const agent = new ReactAgent({
436
+ aiProvider: this.config.aiProvider,
437
+ workingDir: repoPath,
438
+ technology: {
439
+ name: tech.name,
440
+ repository: tech.repo,
441
+ branch: tech.branch
442
+ }
443
+ });
444
+ await agent.initialize();
445
+
446
+ // Check for interruption after initialization
447
+ if (isInterrupted) {
448
+ logger.warn('LIBRARIAN', 'Agent initialization interrupted by user', { repoName });
449
+ yield '[Agent initialization interrupted by user]';
450
+ return;
451
+ }
452
+
453
+ // Execute streaming query using agent with context
454
+ logger.debug('LIBRARIAN', 'Starting stream from agent');
455
+ yield* agent.streamRepository(repoPath, query, context);
456
+ } catch (error) {
457
+ // Handle repository-level errors
458
+ let errorMessage = 'Unknown error';
459
+
460
+ if (error instanceof Error) {
461
+ if (error.message.includes('not found in configuration')) {
462
+ errorMessage = error.message;
463
+ } else if (error.message.includes('git') || error.message.includes('clone')) {
464
+ errorMessage = `Repository operation failed: ${error.message}`;
465
+ } else if (error.message.includes('timeout')) {
466
+ errorMessage = 'Repository operation timed out';
467
+ } else {
468
+ errorMessage = `Repository error: ${error.message}`;
469
+ }
470
+ }
471
+
472
+ logger.error('LIBRARIAN', 'Stream error', error instanceof Error ? error : new Error(errorMessage), { repoName });
473
+ yield `\n[Error: ${errorMessage}]`;
474
+ throw error;
475
+ } finally {
476
+ // Clean up event listeners
477
+ process.removeListener('SIGINT', cleanup);
478
+ process.removeListener('SIGTERM', cleanup);
479
+ logger.timingEnd(timingId, 'LIBRARIAN', `Stream completed: ${repoName}`);
480
+ }
481
+ }
482
+
483
+ async queryGroup(groupName: string, query: string): Promise<string> {
484
+ logger.info('LIBRARIAN', 'Querying group', { groupName, queryLength: query.length });
485
+
486
+ const timingId = logger.timingStart('queryGroup');
487
+
488
+ // Validate group exists
489
+ if (!this.config.technologies[groupName]) {
490
+ logger.error('LIBRARIAN', 'Group not found in configuration', undefined, { groupName });
491
+ throw new Error(`Group ${groupName} not found in configuration`);
492
+ }
493
+
494
+ // Get the group directory path
495
+ const groupPath = this.getSecureGroupPath(groupName);
496
+
497
+ // Sync all technologies in the group first
498
+ const technologies = this.config.technologies[groupName];
499
+ if (technologies) {
500
+ const techNames = Object.keys(technologies);
501
+ logger.info('LIBRARIAN', 'Syncing all technologies in group', { groupName, techCount: techNames.length });
502
+ for (const techName of techNames) {
503
+ await this.syncRepository(techName);
504
+ }
505
+ }
506
+
507
+ // Construct context object for group-level query
508
+ const context: AgentContext = {
509
+ workingDir: groupPath,
510
+ group: groupName,
511
+ technology: '', // No specific technology for group-level queries
512
+ };
513
+
514
+ logger.debug('LIBRARIAN', 'Initializing agent for group query with context', {
515
+ workingDir: context.workingDir.replace(os.homedir(), '~'),
516
+ group: context.group
517
+ });
518
+
519
+ // Initialize the agent with the group directory as working directory
520
+ const agent = new ReactAgent({
521
+ aiProvider: this.config.aiProvider,
522
+ workingDir: groupPath
523
+ });
524
+ await agent.initialize();
525
+
526
+ // Execute the query using the agent with context
527
+ const result = await agent.queryRepository(groupPath, query, context);
528
+
529
+ logger.timingEnd(timingId, 'LIBRARIAN', `Group query completed: ${groupName}`);
530
+ logger.info('LIBRARIAN', 'Group query result received', { groupName, responseLength: result.length });
531
+
532
+ return result;
533
+ }
534
+
535
+ async *streamGroup(groupName: string, query: string): AsyncGenerator<string, void, unknown> {
536
+ logger.info('LIBRARIAN', 'Streaming group query', { groupName, queryLength: query.length });
537
+
538
+ const timingId = logger.timingStart('streamGroup');
539
+
540
+ if (!this.config.technologies[groupName]) {
541
+ logger.error('LIBRARIAN', 'Group not found in configuration', undefined, { groupName });
542
+ throw new Error(`Group ${groupName} not found in configuration`);
543
+ }
544
+
545
+ const groupPath = this.getSecureGroupPath(groupName);
546
+
547
+ let isInterrupted = false;
548
+ const cleanup = () => {
549
+ isInterrupted = true;
550
+ };
551
+
552
+ logger.debug('LIBRARIAN', 'Setting up interruption handlers for group');
553
+ process.on('SIGINT', cleanup);
554
+ process.on('SIGTERM', cleanup);
555
+
556
+ try {
557
+ const technologies = this.config.technologies[groupName];
558
+ if (technologies) {
559
+ const techNames = Object.keys(technologies);
560
+ logger.info('LIBRARIAN', 'Syncing all technologies in group for streaming', { groupName, techCount: techNames.length });
561
+ for (const techName of techNames) {
562
+ await this.syncRepository(techName);
563
+ }
564
+ }
565
+
566
+ if (isInterrupted) {
567
+ logger.warn('LIBRARIAN', 'Group sync interrupted by user', { groupName });
568
+ yield '[Repository sync interrupted by user]';
569
+ return;
570
+ }
571
+
572
+ const context: AgentContext = {
573
+ workingDir: groupPath,
574
+ group: groupName,
575
+ technology: '',
576
+ };
577
+
578
+ logger.debug('LIBRARIAN', 'Initializing agent for group streaming with context', {
579
+ workingDir: context.workingDir.replace(os.homedir(), '~'),
580
+ group: context.group
581
+ });
582
+
583
+ const agent = new ReactAgent({
584
+ aiProvider: this.config.aiProvider,
585
+ workingDir: groupPath
586
+ });
587
+ await agent.initialize();
588
+
589
+ if (isInterrupted) {
590
+ logger.warn('LIBRARIAN', 'Agent initialization interrupted by user for group', { groupName });
591
+ yield '[Agent initialization interrupted by user]';
592
+ return;
593
+ }
594
+
595
+ logger.debug('LIBRARIAN', 'Starting stream from agent for group');
596
+ yield* agent.streamRepository(groupPath, query, context);
597
+ } catch (error) {
598
+ const errorMessage = this.getGroupStreamErrorMessage(error);
599
+ logger.error('LIBRARIAN', 'Group stream error', error instanceof Error ? error : new Error(errorMessage), { groupName });
600
+ yield `\n[Error: ${errorMessage}]`;
601
+ throw error;
602
+ } finally {
603
+ process.removeListener('SIGINT', cleanup);
604
+ process.removeListener('SIGTERM', cleanup);
605
+ logger.timingEnd(timingId, 'LIBRARIAN', `Group stream completed: ${groupName}`);
606
+ }
607
+ }
608
+
609
+ private getGroupStreamErrorMessage(error: unknown): string {
610
+ if (!(error instanceof Error)) {
611
+ return 'Unknown error';
612
+ }
613
+
614
+ if (error.message.includes('not found in configuration')) {
615
+ return error.message;
616
+ }
617
+
618
+ if (error.message.includes('git') || error.message.includes('clone')) {
619
+ return `Repository operation failed: ${error.message}`;
620
+ }
621
+
622
+ if (error.message.includes('timeout')) {
623
+ return 'Repository operation timed out';
624
+ }
625
+
626
+ return `Group error: ${error.message}`;
627
+ }
628
+ }