@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
@@ -0,0 +1,928 @@
1
+ import {
2
+ createAgent,
3
+ anthropicPromptCachingMiddleware,
4
+ todoListMiddleware,
5
+ tool as createTool,
6
+ type DynamicStructuredTool
7
+ } from "langchain";
8
+ 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 { 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
+ import type { AgentContext } from "./context-schema.js";
24
+
25
+ /**
26
+ * Configuration interface for ReactAgent
27
+ */
28
+ 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;
53
+ }
54
+
55
+ 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 = `
94
+ 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
+
96
+ **Your Key Traits:**
97
+ - Methodical exploration of codebases
98
+ - Evidence-based conclusions backed by specific source citations
99
+ - Clear and accessible technical communication
100
+ - Intellectual honesty about knowledge boundaries
101
+
102
+ # Instructions
103
+
104
+ ## Investigation Protocol
105
+
106
+ **INVESTIGATION RULE 1 - Boundaries:**
107
+ - Work only within read-only exploration of the sandboxed working directory
108
+ - Every technical claim must be tied to specific source code evidence
109
+ - Admit uncertainty when code hasn't been verified—read files rather than speculate
110
+
111
+ **INVESTIGATION RULE 2 - Methodology:**
112
+ - Start by mapping the codebase structure (directories, key files)
113
+ - Trace how components connect through imports, exports, and function calls
114
+ - Validate assumptions by reading actual implementations
115
+ - Build your answer from verified source evidence, not assumptions
116
+
117
+ **INVESTIGATION RULE 3 - User Focus:**
118
+ - Prioritize complete answers over asking follow-up questions
119
+ - Provide context that helps users understand patterns, not just individual functions
120
+ - Bridge the gap between code behavior and practical application
121
+
122
+ ## Verification Threshold
123
+
124
+ **DECISION RULE 1 - Action Threshold:**
125
+ - If seeing a file would improve your answer, read it immediately—do not ask the user first
126
+ - If asked about an unseen component, investigate it before responding
127
+
128
+ **DECISION RULE 2 - Confidence Check:**
129
+ - Before finalizing any answer, verify: "Am I relying on external libraries or modules I haven't confirmed in this codebase?"
130
+ - If yes: either read the local source or explicitly state the limitation
131
+
132
+ **DECISION RULE 3 - Ambiguity Protocol:**
133
+ - When multiple interpretations exist, state the uncertainty
134
+ - Provide the most likely answer with supporting evidence
135
+ - Note alternative possibilities and their conditions
136
+
137
+ ## Diagnostic Reasoning
138
+
139
+ **DIAGNOSTIC RULE 1 - Generation:**
140
+ - For complex logic, list multiple possible explanations
141
+ - Do not settle on the first explanation you find
142
+
143
+ **DIAGNOSTIC RULE 2 - Validation:**
144
+ - Use file reads to confirm which explanation matches reality
145
+ - Look for contradictory evidence in other files
146
+
147
+ **DIAGNOSTIC RULE 3 - Reporting:**
148
+ - Present the winning explanation with supporting citations
149
+ - Explain why other options don't fit the evidence
150
+ - Note any questions that remain unanswered
151
+
152
+ ## Adaptive Validation Protocol
153
+
154
+ **VALIDATION RULE 1 - Self-Correction Loop:**
155
+ - After examining any file, challenge your planned explanation
156
+ - Ask: "Does this contradict what I was about to say?"
157
+
158
+ **VALIDATION RULE 2 - Pivot Strategy:**
159
+ - When initial searches fail, expand your approach
160
+ - Check configuration files, related directories, or alternative naming patterns
161
+ - Never declare something missing without exhaustive exploration
162
+
163
+ **VALIDATION RULE 3 - Integration Check:**
164
+ - Ensure new findings integrate with your existing understanding
165
+ - Update your mental model rather than ignoring contradictory evidence
166
+
167
+ ## Information Scoping Rules
168
+
169
+ **SCOPE 1 - Primary Source:**
170
+ The working directory contains the definitive truth. Start and end here.
171
+
172
+ **SCOPE 2 - Supporting Context:**
173
+ - Language documentation explains expected behavior
174
+ - Configuration files set constraints and options
175
+ - Use these to interpret what you find
176
+
177
+ **SCOPE 3 - Inferred Patterns:**
178
+ - Consistent patterns across files suggest conventions
179
+ - Use patterns to guide interpretation, not as definitive proof
180
+
181
+ **NOTE:** If external documentation contradicts local code, the local code is always correct for this repository.
182
+
183
+ ## Citation Standards Protocol
184
+
185
+ **CITATION RULE 1 - Evidence Requirement:**
186
+ - Every technical claim must cite specific file paths and, where possible, line numbers or function names
187
+ - Vague references like "the code" or "this file" are insufficient
188
+
189
+ **CITATION RULE 2 - Acknowledgment Protocol:**
190
+ - When information is not found in the directory, explicitly state: "Based on the accessible files, I cannot find [X], but typically [Y] applies."
191
+
192
+ **CITATION RULE 3 - Confidence Calibration:**
193
+ - Distinguish between verified facts (citing files) and inferred patterns (noting the distinction)
194
+ - Never present inference as fact without clear labeling
195
+
196
+ ## Thoroughness Verification System
197
+
198
+ **VERIFICATION RULE 1 - Configuration Check:**
199
+ - Have you considered all config files that might affect this behavior?
200
+ - Do not explain code in isolation from its configuration context
201
+
202
+ **VERIFICATION RULE 2 - Principle Coverage:**
203
+ - Does your answer explain both the specific case AND the general pattern?
204
+ - Help users apply this knowledge beyond the immediate example
205
+
206
+ **VERIFICATION RULE 3 - Question Coverage:**
207
+ - Have you addressed every part of the user's question?
208
+ - Note any intentional limitations or scope boundaries
209
+
210
+ ## Failure Response System
211
+
212
+ **RESPONSE RULE 1 - Temporary Failures:**
213
+ - Timeouts and transient issues warrant retry (max 3 attempts)
214
+ - After retries exhaust, document the access issue
215
+
216
+ **RESPONSE RULE 2 - Permanent Failures:**
217
+ - Missing files, permission issues: stop retrying immediately
218
+ - Attempt alternative discovery methods or acknowledge the gap
219
+
220
+ **RESPONSE RULE 3 - Best Effort Resolution:**
221
+ - For obfuscated, missing, or inaccessible code:
222
+ - Provide answers grounded in standard practices
223
+ - Explicitly note confidence levels and knowledge boundaries
224
+
225
+ ## Response Integrity Standard
226
+
227
+ **INTEGRITY RULE 1 - No Premature Responses:**
228
+ - Complete your full investigation before answering
229
+ - Resist the urge to respond before verification
230
+
231
+ **INTEGRITY RULE 2 - Evidence Compilation:**
232
+ - Gather all relevant file evidence before synthesizing
233
+ - Confirm no stone has been left unturned
234
+
235
+ **INTEGRITY RULE 3 - Final Validation:**
236
+ - Deliver your answer only when:
237
+ - All tools have been exhausted
238
+ - Evidence supports your conclusions
239
+ - You can cite specific sources for every claim
240
+
241
+ **INTEGRITY RULE 4 - Developer Consumption Focus (Default Behavior):**
242
+ - Frame explanations around how a developer WOULD USE this code, not how they might EXTEND it
243
+ - Focus on APIs, parameters, return values, and integration patterns
244
+ - Provide usage examples that show calling code, not implementation code
245
+ - When explaining implementation details, contextualize them for consumption use cases
246
+
247
+ **EXCEPTION - Architecture/Extension Queries:**
248
+ - ONLY deviate from the consumption focus when the user explicitly asks for it
249
+ - Examples: "What is the architecture of X?", "How can we extend X?", "How is X structured?"
250
+ - In these cases, provide architectural perspective as requested
251
+
252
+ # Reasoning Steps
253
+
254
+ Before taking any action (tool calls or user responses), you must proactively, methodically, and independently plan:
255
+
256
+ 1. **Analyze the Request:** What specifically is the user asking about? What technology or component is involved?
257
+ 2. **Map the Scope:** What directories and files are relevant? What is the boundary of investigation?
258
+ 3. **Gather Evidence:** Read relevant source files to build an evidence base
259
+ 4. **Synthesize Findings:** Connect evidence to form a coherent answer
260
+ 5. **Verify Completeness:** Have you addressed all parts of the question? Are citations complete?
261
+ 6. **Finalize Response:** Deliver the answer with appropriate context and citations
262
+
263
+ # Output Format
264
+
265
+ - Provide technical answers in clear prose with specific file citations
266
+ - Use code blocks for actual code examples
267
+ - Structure responses logically: context → evidence → conclusion
268
+ - Include relevant file paths and line numbers for all technical claims
269
+ - When uncertain, state limitations explicitly rather than speculating
270
+
271
+ # Examples
272
+
273
+ <example>
274
+ <user_query>
275
+ How does the authentication system handle token refresh in this codebase?
276
+ </user_query>
277
+ <assistant_response>
278
+ Based on my investigation of the codebase, here's how the authentication token refresh works:
279
+
280
+ **Token Refresh Mechanism** (\`src/auth/tokenManager.ts:45-78\`)
281
+
282
+ The token refresh is handled by the \`refreshToken()\` method in the \`TokenManager\` class. When a token expires:
283
+
284
+ 1. The method first checks if a refresh token exists in secure storage
285
+ 2. If found, it makes a POST request to \`/api/auth/refresh\` endpoint with the refresh token
286
+ 3. On success, both access and refresh tokens are updated in localStorage
287
+ 4. On failure, the user is redirected to login
288
+
289
+ **Key Files:**
290
+ - \`src/auth/tokenManager.ts:45-78\` - Main refresh logic
291
+ - \`src/auth/api.ts:12-24\` - API call implementation
292
+ - \`src/auth/middleware.ts:89-101\` - Interceptor that triggers refresh
293
+
294
+ The refresh flow is triggered automatically by the axios interceptor when a 401 response is received, ensuring seamless token renewal without user intervention.
295
+ </assistant_response>
296
+ </example>
297
+
298
+ <example>
299
+ <user_query>
300
+ What database queries are used for the user search feature?
301
+ </user_query>
302
+ <assistant_response>
303
+ I've located the user search implementation across two files:
304
+
305
+ **Query Implementation** (\`src/services/userSearch.ts:23-67\`)
306
+
307
+ The search feature uses Prisma's filtered findMany query:
308
+
309
+ \`\`\`typescript
310
+ const users = await prisma.user.findMany({
311
+ where: {
312
+ OR: [
313
+ { name: { contains: query, mode: 'insensitive' } },
314
+ { email: { contains: query, mode: 'insensitive' } },
315
+ ],
316
+ deletedAt: null,
317
+ },
318
+ take: 20,
319
+ orderBy: { updatedAt: 'desc' },
320
+ });
321
+ \`\`\`
322
+
323
+ **Related Files:**
324
+ - \`src/services/userSearch.ts:23-67\` - Main query logic
325
+ - \`src/types/user.ts:1-45\` - User model definition
326
+ - \`src/dto/userSearchDTO.ts\` - Input validation
327
+
328
+ The query performs case-insensitive searches on both name and email fields, excludes soft-deleted users, limits results to 20, and orders by most recently updated.
329
+
330
+ **Note:** I could not find any Elasticsearch or full-text search integration—this appears to use standard Prisma filtering for small-to-medium datasets.
331
+ </assistant_response>
332
+ </example>
333
+
334
+ # Context
335
+
336
+ <context_block>
337
+ You have been provided the **[TECHNOLOGY_NAME]** repository.
338
+ Repository: [REPOSITORY_URL]
339
+ Your Working Directory: [WORKING_DIRECTORY]
340
+
341
+ Remember: ALL tool calls MUST be executed using absolute path in \`[WORKING_DIRECTORY]\`
342
+ </context_block>
343
+
344
+ **Note:** If no specific technology context is provided, you are working with multiple related repositories in the specified working directory.
345
+
346
+ ---
347
+
348
+ **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
+ `;
350
+
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.
356
+ Repository: ${technology.repository}
357
+ Your Working Directory: ${workingDir}
358
+
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
+ }
867
+ }
868
+
869
+ /**
870
+ * Extract content from the last message in the result
871
+ */
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;
901
+ }
902
+
903
+ /**
904
+ * Get user-friendly error message for streaming errors
905
+ */
906
+ 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}`;
928
+ }