@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,96 +1,98 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { Readable } from "node:stream";
6
+ import { ChatAnthropic } from "@langchain/anthropic";
7
+ import { HumanMessage } from "@langchain/core/messages";
8
+ import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
9
+ import { ChatOpenAI } from "@langchain/openai";
1
10
  import {
2
- createAgent,
3
11
  anthropicPromptCachingMiddleware,
12
+ createAgent,
13
+ type DynamicStructuredTool,
4
14
  todoListMiddleware,
5
- tool as createTool,
6
- type DynamicStructuredTool
7
15
  } from "langchain";
8
16
  import type { z } from "zod";
9
- import { fileListTool } from "../tools/file-listing.tool.js";
10
- import { fileReadTool } from "../tools/file-reading.tool.js";
11
- import { grepContentTool } from "../tools/grep-content.tool.js";
12
- import { fileFindTool } from "../tools/file-finding.tool.js";
13
- import { ChatOpenAI } from "@langchain/openai";
14
- import { ChatAnthropic } from "@langchain/anthropic";
15
- import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
16
- import { HumanMessage } from "@langchain/core/messages";
17
+ import { findTool } from "../tools/file-finding.tool.js";
18
+ import { listTool } from "../tools/file-listing.tool.js";
19
+ import { viewTool } from "../tools/file-reading.tool.js";
20
+ import { grepTool } from "../tools/grep-content.tool.js";
17
21
  import { logger } from "../utils/logger.js";
18
- import os from "node:os";
19
- import { mkdir, rm } from "node:fs/promises";
20
- import path from "node:path";
21
- import { spawn } from "node:child_process";
22
- import { Readable } from "node:stream";
23
22
  import type { AgentContext } from "./context-schema.js";
24
23
 
25
24
  /**
26
25
  * Configuration interface for ReactAgent
27
26
  */
28
27
  export interface ReactAgentConfig {
29
- /** AI provider configuration including type, API key, and optional model/base URL */
30
- aiProvider: {
31
- type:
32
- | "openai"
33
- | "anthropic"
34
- | "google"
35
- | "openai-compatible"
36
- | "anthropic-compatible"
37
- | "claude-code"
38
- | "gemini-cli";
39
- apiKey: string;
40
- model?: string;
41
- baseURL?: string;
42
- };
43
- /** Working directory where the agent operates */
44
- workingDir: string;
45
- /** Optional technology context for dynamic system prompt construction */
46
- technology?: {
47
- name: string;
48
- repository: string;
49
- branch: string;
50
- };
51
- /** Optional context schema for runtime context validation */
52
- contextSchema?: z.ZodType | undefined;
28
+ /** AI provider configuration including type, API key, and optional model/base URL */
29
+ aiProvider: {
30
+ type:
31
+ | "openai"
32
+ | "anthropic"
33
+ | "google"
34
+ | "openai-compatible"
35
+ | "anthropic-compatible"
36
+ | "claude-code"
37
+ | "gemini-cli";
38
+ apiKey: string;
39
+ model?: string;
40
+ baseURL?: string;
41
+ };
42
+ /** Working directory where the agent operates */
43
+ workingDir: string;
44
+ /** Optional technology context for dynamic system prompt construction */
45
+ technology?: {
46
+ name: string;
47
+ repository: string;
48
+ branch: string;
49
+ };
50
+ /** Optional context schema for runtime context validation */
51
+ contextSchema?: z.ZodType | undefined;
53
52
  }
54
53
 
55
54
  export class ReactAgent {
56
- private readonly aiModel?: ChatOpenAI | ChatAnthropic | ChatGoogleGenerativeAI;
57
- private readonly tools: DynamicStructuredTool[];
58
- private agent?: ReturnType<typeof createAgent>;
59
- private readonly config: ReactAgentConfig;
60
- private readonly contextSchema?: z.ZodType | undefined;
61
-
62
- constructor(config: ReactAgentConfig) {
63
- this.config = config;
64
- this.contextSchema = config.contextSchema;
65
-
66
- if (
67
- config.aiProvider.type !== "claude-code" &&
68
- config.aiProvider.type !== "gemini-cli"
69
- ) {
70
- this.aiModel = this.createAIModel(config.aiProvider);
71
- }
72
-
73
- // Initialize tools - modernized tool pattern
74
- this.tools = [fileListTool, fileReadTool, grepContentTool, fileFindTool];
75
-
76
- logger.info("AGENT", "Initializing ReactAgent", {
77
- aiProviderType: config.aiProvider.type,
78
- model: config.aiProvider.model,
79
- workingDir: config.workingDir.replace(os.homedir(), "~"),
80
- toolCount: this.tools.length,
81
- hasContextSchema: !!this.contextSchema,
82
- });
83
- }
84
-
85
- /**
86
- * Creates a dynamic system prompt based on current configuration and technology context
87
- * @returns A context-aware system prompt string
88
- */
89
- createDynamicSystemPrompt(): string {
90
- const { workingDir, technology } = this.config;
91
-
92
- // Dynamic system prompt generation code
93
- let prompt = `
55
+ private readonly aiModel?:
56
+ | ChatOpenAI
57
+ | ChatAnthropic
58
+ | ChatGoogleGenerativeAI;
59
+ private readonly tools: DynamicStructuredTool[];
60
+ private agent?: ReturnType<typeof createAgent>;
61
+ private readonly config: ReactAgentConfig;
62
+ private readonly contextSchema?: z.ZodType | undefined;
63
+
64
+ constructor(config: ReactAgentConfig) {
65
+ this.config = config;
66
+ this.contextSchema = config.contextSchema;
67
+
68
+ if (
69
+ config.aiProvider.type !== "claude-code" &&
70
+ config.aiProvider.type !== "gemini-cli"
71
+ ) {
72
+ this.aiModel = this.createAIModel(config.aiProvider);
73
+ }
74
+
75
+ // Initialize tools - modernized tool pattern
76
+ this.tools = [listTool, viewTool, grepTool, findTool];
77
+
78
+ logger.info("AGENT", "Initializing ReactAgent", {
79
+ aiProviderType: config.aiProvider.type,
80
+ model: config.aiProvider.model,
81
+ workingDir: config.workingDir.replace(os.homedir(), "~"),
82
+ toolCount: this.tools.length,
83
+ hasContextSchema: !!this.contextSchema,
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Creates a dynamic system prompt based on current configuration and technology context
89
+ * @returns A context-aware system prompt string
90
+ */
91
+ createDynamicSystemPrompt(): string {
92
+ const { workingDir, technology } = this.config;
93
+
94
+ // Dynamic system prompt generation code
95
+ let prompt = `
94
96
  You are a **Codebase Investigator** specializing in technology exploration and architectural analysis. Your core purpose is to provide deep technical insights grounded in actual source code evidence. You approach every question as an investigation, requiring verification before drawing conclusions.
95
97
 
96
98
  **Your Key Traits:**
@@ -348,581 +350,605 @@ Remember: ALL tool calls MUST be executed using absolute path in \`[WORKING_DIRE
348
350
  **Before responding to any user query, verify you have sufficient evidence to support your claims. When in doubt, read more files rather than speculate.**
349
351
  `;
350
352
 
351
- // Add technology context if available
352
- if (technology) {
353
- prompt = prompt.replace(
354
- "<context_block>",
355
- `You have been provided the **${technology.name}** repository.
353
+ // Add technology context if available
354
+ if (technology) {
355
+ prompt = prompt.replace(
356
+ "<context_block>",
357
+ `You have been provided the **${technology.name}** repository.
356
358
  Repository: ${technology.repository}
357
359
  Your Working Directory: ${workingDir}
358
360
 
359
- Remember that ALL tool calls MUST be executed using absolute path in \`${workingDir}\``,
360
- );
361
- prompt = prompt.replace("</context_block>", "");
362
- } else {
363
- prompt = prompt.replace(
364
- "<context_block>",
365
- `You have been provided several related repositories to work with grouped in the following working directory: ${workingDir}
366
-
367
- Remember that ALL tool calls MUST be executed using absolute path in \`${workingDir}\``,
368
- );
369
- prompt = prompt.replace("</context_block>", "");
370
- }
371
-
372
- logger.debug("AGENT", "Dynamic system prompt generated", {
373
- hasTechnologyContext: !!technology,
374
- promptLength: prompt.length,
375
- });
376
-
377
- return prompt;
378
- }
379
-
380
- private async createGeminiTempDir(): Promise<string> {
381
- const tempDir = path.join(os.tmpdir(), `librarian-gemini-${Date.now()}`);
382
- await mkdir(tempDir, { recursive: true });
383
- return tempDir;
384
- }
385
-
386
- private async setupGeminiConfig(
387
- tempDir: string,
388
- systemPrompt: string,
389
- _model: string,
390
- ): Promise<{ systemPromptPath: string; settingsPath: string }> {
391
- const systemPromptPath = path.join(tempDir, "system.md");
392
- const settingsPath = path.join(tempDir, "settings.json");
393
-
394
- await Bun.write(systemPromptPath, systemPrompt);
395
-
396
- const settings = {
397
- tools: {
398
- core: ["list_directory", "read_file", "glob", "search_file_content"],
399
- autoAccept: true,
400
- },
401
- mcpServers: {},
402
- mcp: {
403
- excluded: ["*"],
404
- },
405
- experimental: {
406
- enableAgents: false,
407
- },
408
- output: {
409
- format: "json",
410
- },
411
- };
412
- await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
413
-
414
- return { systemPromptPath, settingsPath };
415
- }
416
-
417
- private buildGeminiEnv(tempDir: string, model: string): Record<string, string | undefined> {
418
- const settingsPath = path.join(tempDir, "settings.json");
419
- const systemPromptPath = path.join(tempDir, "system.md");
420
-
421
- return {
422
- ...Bun.env,
423
- GEMINI_SYSTEM_MD: systemPromptPath,
424
- GEMINI_CLI_SYSTEM_DEFAULTS_PATH: settingsPath,
425
- GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath,
426
- GEMINI_MODEL: model,
427
- };
428
- }
429
-
430
- private async cleanupGeminiTempDir(tempDir: string): Promise<void> {
431
- try {
432
- await rm(tempDir, { recursive: true, force: true });
433
- } catch (err) {
434
- logger.warn("AGENT", "Failed to cleanup Gemini temp files", {
435
- error: err,
436
- });
437
- }
438
- }
439
-
440
- private async *streamClaudeCli(
441
- query: string,
442
- context?: AgentContext,
443
- ): AsyncGenerator<string, void, unknown> {
444
- const workingDir = context?.workingDir || this.config.workingDir;
445
- const systemPrompt = this.createDynamicSystemPrompt();
446
-
447
- const args = [
448
- "-p",
449
- query,
450
- "--system-prompt",
451
- systemPrompt,
452
- "--tools",
453
- "Read,Glob,Grep",
454
- "--dangerously-skip-permissions",
455
- "--output-format",
456
- "stream-json",
457
- ];
458
-
459
- const env = {
460
- ...Bun.env,
461
- CLAUDE_PROJECT_DIR: workingDir,
462
- ...(this.config.aiProvider.model && {
463
- ANTHROPIC_MODEL: this.config.aiProvider.model,
464
- }),
465
- };
466
-
467
- logger.debug("AGENT", "Spawning Claude CLI", {
468
- args: args.map((a) => (a.length > 100 ? a.substring(0, 100) + "..." : a)),
469
- workingDir,
470
- });
471
-
472
- const proc = spawn("claude", args, {
473
- cwd: workingDir,
474
- env,
475
- });
476
-
477
- let buffer = "";
478
-
479
- if (!proc.stdout) {
480
- throw new Error("Failed to capture Claude CLI output");
481
- }
482
-
483
- const readable = Readable.from(proc.stdout);
484
-
485
- for await (const chunk of readable) {
486
- buffer += chunk.toString();
487
- const lines = buffer.split("\n");
488
- buffer = lines.pop() || "";
489
-
490
- for (const line of lines) {
491
- if (!line.trim()) continue;
492
- try {
493
- const data = JSON.parse(line);
494
- // Filter for text content blocks in the stream
495
- if (data.type === "text" && data.content) {
496
- yield data.content;
497
- } else if (data.type === "content_block_delta" && data.delta?.text) {
498
- yield data.delta.text;
499
- } else if (data.type === "message" && data.content) {
500
- // Final message might come as a whole
501
- if (Array.isArray(data.content)) {
502
- for (const block of data.content) {
503
- if (block.type === "text" && block.text) {
504
- yield block.text;
505
- }
506
- }
507
- }
508
- }
509
- } catch {
510
- // Silent fail for non-JSON or partial lines
511
- }
512
- }
513
- }
514
-
515
- // Wait for process to exit
516
- await new Promise<void>((resolve, reject) => {
517
- proc.on("exit", (code) => {
518
- if (code === 0) resolve();
519
- else reject(new Error(`Claude CLI exited with code ${code}`));
520
- });
521
- proc.on("error", reject);
522
- });
523
- }
524
-
525
- private async *streamGeminiCli(
526
- query: string,
527
- context?: AgentContext,
528
- ): AsyncGenerator<string, void, unknown> {
529
- const workingDir = context?.workingDir || this.config.workingDir;
530
- const systemPrompt = this.createDynamicSystemPrompt();
531
-
532
- const tempDir = await this.createGeminiTempDir();
533
- const model = this.config.aiProvider.model || "gemini-2.5-flash";
534
-
535
- try {
536
- await this.setupGeminiConfig(tempDir, systemPrompt, model);
537
-
538
- const args = [
539
- "gemini",
540
- "-p",
541
- query,
542
- "--output-format",
543
- "stream-json",
544
- "--yolo",
545
- ];
546
-
547
- const env = this.buildGeminiEnv(tempDir, model);
548
-
549
- logger.debug("AGENT", "Spawning Gemini CLI", {
550
- args,
551
- workingDir,
552
- model,
553
- });
554
-
555
- const proc = Bun.spawn(args, {
556
- cwd: workingDir,
557
- env,
558
- stdout: "pipe",
559
- stderr: "pipe",
560
- });
561
-
562
- const reader = proc.stdout.getReader();
563
- let buffer = "";
564
-
565
- while (true) {
566
- const { done, value } = await reader.read();
567
- if (done) {
568
- break;
569
- }
570
-
571
- buffer += new TextDecoder().decode(value);
572
- const lines = buffer.split("\n");
573
- buffer = lines.pop() || "";
574
-
575
- for (const line of lines) {
576
- if (!line.trim()) {
577
- continue;
578
- }
579
- try {
580
- const data = JSON.parse(line);
581
- const text = this.parseGeminiStreamLine(data);
582
- if (text) {
583
- yield text;
584
- }
585
- } catch {
586
- // Silent fail for non-JSON or partial lines
587
- }
588
- }
589
- }
590
-
591
- const exitCode = await proc.exited;
592
- if (exitCode !== 0) {
593
- throw new Error(`Gemini CLI exited with code ${exitCode}`);
594
- }
595
- } finally {
596
- await this.cleanupGeminiTempDir(tempDir);
597
- }
598
- }
599
-
600
- private parseGeminiStreamLine(data: unknown): string | null {
601
- if (data && typeof data === "object" && "type" in data && "role" in data && "content" in data) {
602
- const typedData = data as { type: string; role: string; content: string };
603
- if (typedData.type === "message" && typedData.role === "assistant" && typedData.content) {
604
- return typedData.content;
605
- }
606
- }
607
- return null;
608
- }
609
-
610
- private createAIModel(
611
- aiProvider: ReactAgentConfig["aiProvider"],
612
- ): ChatOpenAI | ChatAnthropic | ChatGoogleGenerativeAI {
613
- const { type, apiKey, model, baseURL } = aiProvider;
614
-
615
- logger.debug("AGENT", "Creating AI model instance", {
616
- type,
617
- model,
618
- hasBaseURL: !!baseURL,
619
- });
620
-
621
- switch (type) {
622
- case "openai":
623
- return new ChatOpenAI({
624
- apiKey,
625
- modelName: model || "gpt-5.2",
626
- });
627
- case "openai-compatible":
628
- return new ChatOpenAI({
629
- apiKey,
630
- modelName: model || "gpt-5.2",
631
- configuration: {
632
- baseURL: baseURL || "https://api.openai.com/v1",
633
- },
634
- });
635
- case "anthropic":
636
- return new ChatAnthropic({
637
- apiKey,
638
- modelName: model || "claude-sonnet-4-5",
639
- });
640
- case "anthropic-compatible":
641
- if (!baseURL) {
642
- throw new Error(
643
- "baseURL is required for anthropic-compatible provider",
644
- );
645
- }
646
- if (!model) {
647
- throw new Error(
648
- "model is required for anthropic-compatible provider",
649
- );
650
- }
651
- return new ChatAnthropic({
652
- apiKey,
653
- modelName: model,
654
- anthropicApiUrl: baseURL,
655
- });
656
- case "google":
657
- return new ChatGoogleGenerativeAI({
658
- apiKey,
659
- model: model || "gemini-3-flash-preview",
660
- });
661
- default:
662
- logger.error(
663
- "AGENT",
664
- "Unsupported AI provider type",
665
- new Error(`Unsupported AI provider type: ${type}`),
666
- { type },
667
- );
668
- throw new Error(`Unsupported AI provider type: ${type}`);
669
- }
670
- }
671
-
672
- initialize(): Promise<void> {
673
- if (
674
- this.config.aiProvider.type === "claude-code" ||
675
- this.config.aiProvider.type === "gemini-cli"
676
- ) {
677
- logger.info(
678
- "AGENT",
679
- `${this.config.aiProvider.type} CLI mode initialized (skipping LangChain setup)`,
680
- );
681
- return Promise.resolve();
682
- }
683
-
684
- if (!this.aiModel) {
685
- throw new Error("AI model not created for non-CLI provider");
686
- }
687
-
688
- // Create the agent using LangChain's createAgent function with dynamic system prompt
689
- this.agent = createAgent({
690
- model: this.aiModel,
691
- tools: this.tools,
692
- systemPrompt: this.createDynamicSystemPrompt(),
693
- middleware: [
694
- todoListMiddleware(),
695
- ...(this.config.aiProvider.type === "anthropic" ||
696
- this.config.aiProvider.type === "anthropic-compatible"
697
- ? [anthropicPromptCachingMiddleware()]
698
- : []),
699
- ],
700
- });
701
-
702
- logger.info("AGENT", "Agent initialized successfully", {
703
- toolCount: this.tools.length,
704
- hasContextSchema: !!this.contextSchema,
705
- });
706
-
707
- return Promise.resolve();
708
- }
709
-
710
- /**
711
- * Query repository with a given query and optional context
712
- *
713
- * @param repoPath - The repository path (deprecated, for compatibility)
714
- * @param query - The query string
715
- * @param context - Optional context object containing working directory and metadata
716
- * @returns The agent's response as a string
717
- */
718
- async queryRepository(
719
- _repoPath: string,
720
- query: string,
721
- context?: AgentContext,
722
- ): Promise<string> {
723
- logger.info("AGENT", "Query started", {
724
- queryLength: query.length,
725
- hasContext: !!context,
726
- });
727
-
728
- if (this.config.aiProvider.type === "claude-code") {
729
- let fullContent = "";
730
- for await (const chunk of this.streamClaudeCli(query, context)) {
731
- fullContent += chunk;
732
- }
733
- return fullContent;
734
- }
735
-
736
- if (this.config.aiProvider.type === "gemini-cli") {
737
- let fullContent = "";
738
- for await (const chunk of this.streamGeminiCli(query, context)) {
739
- fullContent += chunk;
740
- }
741
- return fullContent;
742
- }
743
-
744
- const timingId = logger.timingStart("agentQuery");
745
-
746
- if (!this.agent) {
747
- logger.error(
748
- "AGENT",
749
- "Agent not initialized",
750
- new Error("Agent not initialized. Call initialize() first."),
751
- );
752
- throw new Error("Agent not initialized. Call initialize() first.");
753
- }
754
-
755
- // Prepare the messages for the agent - system prompt already set during initialization
756
- const messages = [new HumanMessage(query)];
757
-
758
- logger.debug("AGENT", "Invoking agent with messages", {
759
- messageCount: messages.length,
760
- hasContext: !!context,
761
- });
762
-
763
- // Execute the agent with optional context
764
- const result = await this.agent.invoke(
765
- {
766
- messages,
767
- },
768
- context ? { context, recursionLimit: 100 } : { recursionLimit: 100 },
769
- );
770
-
771
- // Extract the last message content from the state
772
- const lastMessage = result.messages.at(-1);
773
- const content =
774
- typeof lastMessage.content === "string"
775
- ? lastMessage.content
776
- : JSON.stringify(lastMessage.content);
777
-
778
- logger.timingEnd(timingId, "AGENT", "Query completed");
779
- logger.info("AGENT", "Query result received", {
780
- responseLength: content.length,
781
- });
782
-
783
- return content;
784
- }
785
-
786
- /**
787
- * Stream repository query with optional context
788
- *
789
- * @param repoPath - The repository path (deprecated, for compatibility)
790
- * @param query - The query string
791
- * @param context - Optional context object containing working directory and metadata
792
- * @returns Async generator yielding string chunks
793
- */
794
- async *streamRepository(
795
- _repoPath: string,
796
- query: string,
797
- context?: AgentContext,
798
- ): AsyncGenerator<string, void, unknown> {
799
- logger.info("AGENT", "Stream started", {
800
- queryLength: query.length,
801
- hasContext: !!context,
802
- });
803
-
804
- if (this.config.aiProvider.type === "claude-code") {
805
- yield* this.streamClaudeCli(query, context);
806
- return;
807
- }
808
-
809
- if (this.config.aiProvider.type === "gemini-cli") {
810
- yield* this.streamGeminiCli(query, context);
811
- return;
812
- }
813
-
814
- const timingId = logger.timingStart("agentStream");
815
-
816
- if (!this.agent) {
817
- logger.error(
818
- "AGENT",
819
- "Agent not initialized",
820
- new Error("Agent not initialized. Call initialize() first."),
821
- );
822
- throw new Error("Agent not initialized. Call initialize() first.");
823
- }
824
-
825
- const messages = [new HumanMessage(query)];
826
-
827
- logger.debug("AGENT", "Invoking agent stream with messages", {
828
- messageCount: messages.length,
829
- hasContext: !!context,
830
- });
831
-
832
- const cleanup = () => {
833
- // Signal interruption for potential future use
834
- };
835
-
836
- logger.debug("AGENT", "Setting up interruption handlers for streaming");
837
- process.on("SIGINT", cleanup);
838
- process.on("SIGTERM", cleanup);
839
-
840
- try {
841
- const result = await this.agent.invoke(
842
- { messages },
843
- context ? { context, recursionLimit: 100 } : { recursionLimit: 100 }
844
- );
845
-
846
- const content = extractMessageContent(result);
847
- if (content) {
848
- yield content;
849
- }
850
-
851
- yield "\n";
852
- } catch (error) {
853
- const errorMessage = getStreamingErrorMessage(error);
854
- logger.error(
855
- "AGENT",
856
- "Streaming error",
857
- error instanceof Error ? error : new Error(errorMessage),
858
- );
859
- yield `\n\n[Error: ${errorMessage}]`;
860
- throw error;
861
- } finally {
862
- process.removeListener("SIGINT", cleanup);
863
- process.removeListener("SIGTERM", cleanup);
864
- logger.timingEnd(timingId, "AGENT", "Streaming completed");
865
- }
866
- }
361
+ Remember that ALL tool calls MUST be executed using absolute path in \`${workingDir}\``
362
+ );
363
+ prompt = prompt.replace("</context_block>", "");
364
+ } else {
365
+ prompt = prompt.replace(
366
+ "<context_block>",
367
+ `You have been provided several related repositories to work with grouped in the following working directory: ${workingDir}
368
+
369
+ Remember that ALL tool calls MUST be executed using absolute path in \`${workingDir}\``
370
+ );
371
+ prompt = prompt.replace("</context_block>", "");
372
+ }
373
+
374
+ logger.debug("AGENT", "Dynamic system prompt generated", {
375
+ hasTechnologyContext: !!technology,
376
+ promptLength: prompt.length,
377
+ });
378
+
379
+ return prompt;
380
+ }
381
+
382
+ private async createGeminiTempDir(): Promise<string> {
383
+ const tempDir = path.join(os.tmpdir(), `librarian-gemini-${Date.now()}`);
384
+ await mkdir(tempDir, { recursive: true });
385
+ return tempDir;
386
+ }
387
+
388
+ private async setupGeminiConfig(
389
+ tempDir: string,
390
+ systemPrompt: string,
391
+ _model: string
392
+ ): Promise<{ systemPromptPath: string; settingsPath: string }> {
393
+ const systemPromptPath = path.join(tempDir, "system.md");
394
+ const settingsPath = path.join(tempDir, "settings.json");
395
+
396
+ await Bun.write(systemPromptPath, systemPrompt);
397
+
398
+ const settings = {
399
+ tools: {
400
+ core: ["list_directory", "read_file", "glob", "search_file_content"],
401
+ autoAccept: true,
402
+ },
403
+ mcpServers: {},
404
+ mcp: {
405
+ excluded: ["*"],
406
+ },
407
+ experimental: {
408
+ enableAgents: false,
409
+ },
410
+ output: {
411
+ format: "json",
412
+ },
413
+ };
414
+ await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
415
+
416
+ return { systemPromptPath, settingsPath };
417
+ }
418
+
419
+ private buildGeminiEnv(
420
+ tempDir: string,
421
+ model: string
422
+ ): Record<string, string | undefined> {
423
+ const settingsPath = path.join(tempDir, "settings.json");
424
+ const systemPromptPath = path.join(tempDir, "system.md");
425
+
426
+ return {
427
+ ...Bun.env,
428
+ GEMINI_SYSTEM_MD: systemPromptPath,
429
+ GEMINI_CLI_SYSTEM_DEFAULTS_PATH: settingsPath,
430
+ GEMINI_CLI_SYSTEM_SETTINGS_PATH: settingsPath,
431
+ GEMINI_MODEL: model,
432
+ };
433
+ }
434
+
435
+ private async cleanupGeminiTempDir(tempDir: string): Promise<void> {
436
+ try {
437
+ await rm(tempDir, { recursive: true, force: true });
438
+ } catch (err) {
439
+ logger.warn("AGENT", "Failed to cleanup Gemini temp files", {
440
+ error: err,
441
+ });
442
+ }
443
+ }
444
+
445
+ private async *streamClaudeCli(
446
+ query: string,
447
+ context?: AgentContext
448
+ ): AsyncGenerator<string, void, unknown> {
449
+ const workingDir = context?.workingDir || this.config.workingDir;
450
+ const systemPrompt = this.createDynamicSystemPrompt();
451
+
452
+ const args = [
453
+ "-p",
454
+ query,
455
+ "--system-prompt",
456
+ systemPrompt,
457
+ "--tools",
458
+ "Read,Glob,Grep",
459
+ "--dangerously-skip-permissions",
460
+ "--output-format",
461
+ "stream-json",
462
+ ];
463
+
464
+ const env = {
465
+ ...Bun.env,
466
+ CLAUDE_PROJECT_DIR: workingDir,
467
+ ...(this.config.aiProvider.model && {
468
+ ANTHROPIC_MODEL: this.config.aiProvider.model,
469
+ }),
470
+ };
471
+
472
+ logger.debug("AGENT", "Spawning Claude CLI", {
473
+ args: args.map((a) => (a.length > 100 ? `${a.substring(0, 100)}...` : a)),
474
+ workingDir,
475
+ });
476
+
477
+ const proc = spawn("claude", args, {
478
+ cwd: workingDir,
479
+ env,
480
+ });
481
+
482
+ let buffer = "";
483
+
484
+ if (!proc.stdout) {
485
+ throw new Error("Failed to capture Claude CLI output");
486
+ }
487
+
488
+ const readable = Readable.from(proc.stdout);
489
+
490
+ for await (const chunk of readable) {
491
+ buffer += chunk.toString();
492
+ const lines = buffer.split("\n");
493
+ buffer = lines.pop() || "";
494
+
495
+ for (const line of lines) {
496
+ if (!line.trim()) {
497
+ continue;
498
+ }
499
+ try {
500
+ const data = JSON.parse(line);
501
+ // Filter for text content blocks in the stream
502
+ if (data.type === "text" && data.content) {
503
+ yield data.content;
504
+ } else if (data.type === "content_block_delta" && data.delta?.text) {
505
+ yield data.delta.text;
506
+ } else if (data.type === "message" && Array.isArray(data.content)) {
507
+ // Final message might come as a whole
508
+ for (const block of data.content) {
509
+ if (block.type === "text" && block.text) {
510
+ yield block.text;
511
+ }
512
+ }
513
+ }
514
+ } catch {
515
+ // Silent fail for non-JSON or partial lines
516
+ }
517
+ }
518
+ }
519
+
520
+ // Wait for process to exit
521
+ await new Promise<void>((resolve, reject) => {
522
+ proc.on("exit", (code) => {
523
+ if (code === 0) {
524
+ resolve();
525
+ } else {
526
+ reject(new Error(`Claude CLI exited with code ${code}`));
527
+ }
528
+ });
529
+ proc.on("error", reject);
530
+ });
531
+ }
532
+
533
+ private async *streamGeminiCli(
534
+ query: string,
535
+ context?: AgentContext
536
+ ): AsyncGenerator<string, void, unknown> {
537
+ const workingDir = context?.workingDir || this.config.workingDir;
538
+ const systemPrompt = this.createDynamicSystemPrompt();
539
+
540
+ const tempDir = await this.createGeminiTempDir();
541
+ const model = this.config.aiProvider.model || "gemini-2.5-flash";
542
+
543
+ try {
544
+ await this.setupGeminiConfig(tempDir, systemPrompt, model);
545
+
546
+ const args = [
547
+ "gemini",
548
+ "-p",
549
+ query,
550
+ "--output-format",
551
+ "stream-json",
552
+ "--yolo",
553
+ ];
554
+
555
+ const env = this.buildGeminiEnv(tempDir, model);
556
+
557
+ logger.debug("AGENT", "Spawning Gemini CLI", {
558
+ args,
559
+ workingDir,
560
+ model,
561
+ });
562
+
563
+ const proc = Bun.spawn(args, {
564
+ cwd: workingDir,
565
+ env,
566
+ stdout: "pipe",
567
+ stderr: "pipe",
568
+ });
569
+
570
+ const reader = proc.stdout.getReader();
571
+ let buffer = "";
572
+
573
+ while (true) {
574
+ const { done, value } = await reader.read();
575
+ if (done) {
576
+ break;
577
+ }
578
+
579
+ buffer += new TextDecoder().decode(value);
580
+ const lines = buffer.split("\n");
581
+ buffer = lines.pop() || "";
582
+
583
+ for (const line of lines) {
584
+ if (!line.trim()) {
585
+ continue;
586
+ }
587
+ try {
588
+ const data = JSON.parse(line);
589
+ const text = this.parseGeminiStreamLine(data);
590
+ if (text) {
591
+ yield text;
592
+ }
593
+ } catch {
594
+ // Silent fail for non-JSON or partial lines
595
+ }
596
+ }
597
+ }
598
+
599
+ const exitCode = await proc.exited;
600
+ if (exitCode !== 0) {
601
+ throw new Error(`Gemini CLI exited with code ${exitCode}`);
602
+ }
603
+ } finally {
604
+ await this.cleanupGeminiTempDir(tempDir);
605
+ }
606
+ }
607
+
608
+ private parseGeminiStreamLine(data: unknown): string | null {
609
+ if (
610
+ data &&
611
+ typeof data === "object" &&
612
+ "type" in data &&
613
+ "role" in data &&
614
+ "content" in data
615
+ ) {
616
+ const typedData = data as { type: string; role: string; content: string };
617
+ if (
618
+ typedData.type === "message" &&
619
+ typedData.role === "assistant" &&
620
+ typedData.content
621
+ ) {
622
+ return typedData.content;
623
+ }
624
+ }
625
+ return null;
626
+ }
627
+
628
+ private createAIModel(
629
+ aiProvider: ReactAgentConfig["aiProvider"]
630
+ ): ChatOpenAI | ChatAnthropic | ChatGoogleGenerativeAI {
631
+ const { type, apiKey, model, baseURL } = aiProvider;
632
+
633
+ logger.debug("AGENT", "Creating AI model instance", {
634
+ type,
635
+ model,
636
+ hasBaseURL: !!baseURL,
637
+ });
638
+
639
+ switch (type) {
640
+ case "openai":
641
+ return new ChatOpenAI({
642
+ apiKey,
643
+ modelName: model || "gpt-5.2",
644
+ });
645
+ case "openai-compatible":
646
+ return new ChatOpenAI({
647
+ apiKey,
648
+ modelName: model || "gpt-5.2",
649
+ configuration: {
650
+ baseURL: baseURL || "https://api.openai.com/v1",
651
+ },
652
+ });
653
+ case "anthropic":
654
+ return new ChatAnthropic({
655
+ apiKey,
656
+ modelName: model || "claude-sonnet-4-5",
657
+ });
658
+ case "anthropic-compatible":
659
+ if (!baseURL) {
660
+ throw new Error(
661
+ "baseURL is required for anthropic-compatible provider"
662
+ );
663
+ }
664
+ if (!model) {
665
+ throw new Error(
666
+ "model is required for anthropic-compatible provider"
667
+ );
668
+ }
669
+ return new ChatAnthropic({
670
+ apiKey,
671
+ modelName: model,
672
+ anthropicApiUrl: baseURL,
673
+ });
674
+ case "google":
675
+ return new ChatGoogleGenerativeAI({
676
+ apiKey,
677
+ model: model || "gemini-3-flash-preview",
678
+ });
679
+ default:
680
+ logger.error(
681
+ "AGENT",
682
+ "Unsupported AI provider type",
683
+ new Error(`Unsupported AI provider type: ${type}`),
684
+ { type }
685
+ );
686
+ throw new Error(`Unsupported AI provider type: ${type}`);
687
+ }
688
+ }
689
+
690
+ initialize(): Promise<void> {
691
+ if (
692
+ this.config.aiProvider.type === "claude-code" ||
693
+ this.config.aiProvider.type === "gemini-cli"
694
+ ) {
695
+ logger.info(
696
+ "AGENT",
697
+ `${this.config.aiProvider.type} CLI mode initialized (skipping LangChain setup)`
698
+ );
699
+ return Promise.resolve();
700
+ }
701
+
702
+ if (!this.aiModel) {
703
+ throw new Error("AI model not created for non-CLI provider");
704
+ }
705
+
706
+ // Create the agent using LangChain's createAgent function with dynamic system prompt
707
+ this.agent = createAgent({
708
+ model: this.aiModel,
709
+ tools: this.tools,
710
+ systemPrompt: this.createDynamicSystemPrompt(),
711
+ middleware: [
712
+ todoListMiddleware(),
713
+ ...(this.config.aiProvider.type === "anthropic" ||
714
+ this.config.aiProvider.type === "anthropic-compatible"
715
+ ? [anthropicPromptCachingMiddleware()]
716
+ : []),
717
+ ],
718
+ });
719
+
720
+ logger.info("AGENT", "Agent initialized successfully", {
721
+ toolCount: this.tools.length,
722
+ hasContextSchema: !!this.contextSchema,
723
+ });
724
+
725
+ return Promise.resolve();
726
+ }
727
+
728
+ /**
729
+ * Query repository with a given query and optional context
730
+ *
731
+ * @param repoPath - The repository path (deprecated, for compatibility)
732
+ * @param query - The query string
733
+ * @param context - Optional context object containing working directory and metadata
734
+ * @returns The agent's response as a string
735
+ */
736
+ async queryRepository(
737
+ _repoPath: string,
738
+ query: string,
739
+ context?: AgentContext
740
+ ): Promise<string> {
741
+ logger.info("AGENT", "Query started", {
742
+ queryLength: query.length,
743
+ hasContext: !!context,
744
+ });
745
+
746
+ if (this.config.aiProvider.type === "claude-code") {
747
+ let fullContent = "";
748
+ for await (const chunk of this.streamClaudeCli(query, context)) {
749
+ fullContent += chunk;
750
+ }
751
+ return fullContent;
752
+ }
753
+
754
+ if (this.config.aiProvider.type === "gemini-cli") {
755
+ let fullContent = "";
756
+ for await (const chunk of this.streamGeminiCli(query, context)) {
757
+ fullContent += chunk;
758
+ }
759
+ return fullContent;
760
+ }
761
+
762
+ const timingId = logger.timingStart("agentQuery");
763
+
764
+ if (!this.agent) {
765
+ logger.error(
766
+ "AGENT",
767
+ "Agent not initialized",
768
+ new Error("Agent not initialized. Call initialize() first.")
769
+ );
770
+ throw new Error("Agent not initialized. Call initialize() first.");
771
+ }
772
+
773
+ // Prepare the messages for the agent - system prompt already set during initialization
774
+ const messages = [new HumanMessage(query)];
775
+
776
+ logger.debug("AGENT", "Invoking agent with messages", {
777
+ messageCount: messages.length,
778
+ hasContext: !!context,
779
+ });
780
+
781
+ // Execute the agent with optional context
782
+ const result = await this.agent.invoke(
783
+ {
784
+ messages,
785
+ },
786
+ context ? { context, recursionLimit: 100 } : { recursionLimit: 100 }
787
+ );
788
+
789
+ // Extract the last message content from the state
790
+ const lastMessage = result.messages.at(-1);
791
+ const content =
792
+ typeof lastMessage.content === "string"
793
+ ? lastMessage.content
794
+ : JSON.stringify(lastMessage.content);
795
+
796
+ logger.timingEnd(timingId, "AGENT", "Query completed");
797
+ logger.info("AGENT", "Query result received", {
798
+ responseLength: content.length,
799
+ });
800
+
801
+ return content;
802
+ }
803
+
804
+ /**
805
+ * Stream repository query with optional context
806
+ *
807
+ * @param repoPath - The repository path (deprecated, for compatibility)
808
+ * @param query - The query string
809
+ * @param context - Optional context object containing working directory and metadata
810
+ * @returns Async generator yielding string chunks
811
+ */
812
+ async *streamRepository(
813
+ _repoPath: string,
814
+ query: string,
815
+ context?: AgentContext
816
+ ): AsyncGenerator<string, void, unknown> {
817
+ logger.info("AGENT", "Stream started", {
818
+ queryLength: query.length,
819
+ hasContext: !!context,
820
+ });
821
+
822
+ if (this.config.aiProvider.type === "claude-code") {
823
+ yield* this.streamClaudeCli(query, context);
824
+ return;
825
+ }
826
+
827
+ if (this.config.aiProvider.type === "gemini-cli") {
828
+ yield* this.streamGeminiCli(query, context);
829
+ return;
830
+ }
831
+
832
+ const timingId = logger.timingStart("agentStream");
833
+
834
+ if (!this.agent) {
835
+ logger.error(
836
+ "AGENT",
837
+ "Agent not initialized",
838
+ new Error("Agent not initialized. Call initialize() first.")
839
+ );
840
+ throw new Error("Agent not initialized. Call initialize() first.");
841
+ }
842
+
843
+ const messages = [new HumanMessage(query)];
844
+
845
+ logger.debug("AGENT", "Invoking agent stream with messages", {
846
+ messageCount: messages.length,
847
+ hasContext: !!context,
848
+ });
849
+
850
+ const cleanup = () => {
851
+ // Signal interruption for potential future use
852
+ };
853
+
854
+ logger.debug("AGENT", "Setting up interruption handlers for streaming");
855
+ process.on("SIGINT", cleanup);
856
+ process.on("SIGTERM", cleanup);
857
+
858
+ try {
859
+ const result = await this.agent.invoke(
860
+ { messages },
861
+ context ? { context, recursionLimit: 100 } : { recursionLimit: 100 }
862
+ );
863
+
864
+ const content = extractMessageContent(result);
865
+ if (content) {
866
+ yield content;
867
+ }
868
+
869
+ yield "\n";
870
+ } catch (error) {
871
+ const errorMessage = getStreamingErrorMessage(error);
872
+ logger.error(
873
+ "AGENT",
874
+ "Streaming error",
875
+ error instanceof Error ? error : new Error(errorMessage)
876
+ );
877
+ yield `\n\n[Error: ${errorMessage}]`;
878
+ throw error;
879
+ } finally {
880
+ process.removeListener("SIGINT", cleanup);
881
+ process.removeListener("SIGTERM", cleanup);
882
+ logger.timingEnd(timingId, "AGENT", "Streaming completed");
883
+ }
884
+ }
867
885
  }
868
886
 
869
887
  /**
870
888
  * Extract content from the last message in the result
871
889
  */
872
- function extractMessageContent(result: { messages?: Array<{ content: unknown }> }): string | null {
873
- if (!result.messages || result.messages.length === 0) {
874
- return null;
875
- }
876
-
877
- const lastMessage = result.messages.at(-1);
878
- if (!lastMessage?.content) {
879
- return null;
880
- }
881
-
882
- const content = lastMessage.content;
883
- if (typeof content === "string") {
884
- return content;
885
- }
886
-
887
- if (Array.isArray(content)) {
888
- const parts: string[] = [];
889
- for (const block of content) {
890
- if (block && typeof block === "object") {
891
- const blockObj = block as { type?: string; text?: unknown };
892
- if (blockObj.type === "text" && typeof blockObj.text === "string") {
893
- parts.push(blockObj.text);
894
- }
895
- }
896
- }
897
- return parts.length > 0 ? parts.join("") : null;
898
- }
899
-
900
- return null;
890
+ function extractMessageContent(result: {
891
+ messages?: Array<{ content: unknown }>;
892
+ }): string | null {
893
+ if (!result.messages || result.messages.length === 0) {
894
+ return null;
895
+ }
896
+
897
+ const lastMessage = result.messages.at(-1);
898
+ if (!lastMessage?.content) {
899
+ return null;
900
+ }
901
+
902
+ const content = lastMessage.content;
903
+ if (typeof content === "string") {
904
+ return content;
905
+ }
906
+
907
+ if (Array.isArray(content)) {
908
+ const parts: string[] = [];
909
+ for (const block of content) {
910
+ if (block && typeof block === "object") {
911
+ const blockObj = block as { type?: string; text?: unknown };
912
+ if (blockObj.type === "text" && typeof blockObj.text === "string") {
913
+ parts.push(blockObj.text);
914
+ }
915
+ }
916
+ }
917
+ return parts.length > 0 ? parts.join("") : null;
918
+ }
919
+
920
+ return null;
901
921
  }
902
922
 
903
923
  /**
904
924
  * Get user-friendly error message for streaming errors
905
925
  */
906
926
  function getStreamingErrorMessage(error: unknown): string {
907
- if (!(error instanceof Error)) {
908
- return "Unknown streaming error";
909
- }
910
-
911
- if (error.message.includes("timeout")) {
912
- return "Streaming timeout - request took too long to complete";
913
- }
914
-
915
- if (error.message.includes("network") || error.message.includes("ENOTFOUND")) {
916
- return "Network error - unable to connect to AI provider";
917
- }
918
-
919
- if (error.message.includes("rate limit")) {
920
- return "Rate limit exceeded - please try again later";
921
- }
922
-
923
- if (error.message.includes("authentication") || error.message.includes("unauthorized")) {
924
- return "Authentication error - check your API credentials";
925
- }
926
-
927
- return `Streaming error: ${error.message}`;
927
+ if (!(error instanceof Error)) {
928
+ return "Unknown streaming error";
929
+ }
930
+
931
+ if (error.message.includes("timeout")) {
932
+ return "Streaming timeout - request took too long to complete";
933
+ }
934
+
935
+ if (
936
+ error.message.includes("network") ||
937
+ error.message.includes("ENOTFOUND")
938
+ ) {
939
+ return "Network error - unable to connect to AI provider";
940
+ }
941
+
942
+ if (error.message.includes("rate limit")) {
943
+ return "Rate limit exceeded - please try again later";
944
+ }
945
+
946
+ if (
947
+ error.message.includes("authentication") ||
948
+ error.message.includes("unauthorized")
949
+ ) {
950
+ return "Authentication error - check your API credentials";
951
+ }
952
+
953
+ return `Streaming error: ${error.message}`;
928
954
  }