ralph-cli-sandboxed 0.4.1 → 0.4.2

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 (69) hide show
  1. package/README.md +30 -0
  2. package/dist/commands/action.js +9 -9
  3. package/dist/commands/chat.js +13 -12
  4. package/dist/commands/config.js +2 -1
  5. package/dist/commands/daemon.js +4 -3
  6. package/dist/commands/docker.js +102 -66
  7. package/dist/commands/fix-config.js +2 -1
  8. package/dist/commands/fix-prd.js +2 -2
  9. package/dist/commands/init.js +78 -17
  10. package/dist/commands/listen.js +3 -1
  11. package/dist/commands/notify.js +1 -1
  12. package/dist/commands/once.js +17 -9
  13. package/dist/commands/prd.js +4 -1
  14. package/dist/commands/run.js +40 -25
  15. package/dist/commands/slack.js +2 -2
  16. package/dist/config/responder-presets.json +69 -0
  17. package/dist/index.js +1 -1
  18. package/dist/providers/discord.d.ts +28 -0
  19. package/dist/providers/discord.js +227 -14
  20. package/dist/providers/slack.d.ts +41 -1
  21. package/dist/providers/slack.js +389 -8
  22. package/dist/providers/telegram.d.ts +30 -0
  23. package/dist/providers/telegram.js +185 -5
  24. package/dist/responders/claude-code-responder.d.ts +48 -0
  25. package/dist/responders/claude-code-responder.js +203 -0
  26. package/dist/responders/cli-responder.d.ts +62 -0
  27. package/dist/responders/cli-responder.js +298 -0
  28. package/dist/responders/llm-responder.d.ts +135 -0
  29. package/dist/responders/llm-responder.js +582 -0
  30. package/dist/templates/macos-scripts.js +2 -4
  31. package/dist/templates/prompts.js +4 -2
  32. package/dist/tui/ConfigEditor.js +19 -5
  33. package/dist/tui/components/ArrayEditor.js +1 -1
  34. package/dist/tui/components/EditorPanel.js +10 -6
  35. package/dist/tui/components/HelpPanel.d.ts +1 -1
  36. package/dist/tui/components/HelpPanel.js +1 -1
  37. package/dist/tui/components/JsonSnippetEditor.js +8 -5
  38. package/dist/tui/components/KeyValueEditor.js +54 -9
  39. package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
  40. package/dist/tui/components/LLMProvidersEditor.js +357 -0
  41. package/dist/tui/components/ObjectEditor.js +1 -1
  42. package/dist/tui/components/Preview.js +1 -1
  43. package/dist/tui/components/RespondersEditor.d.ts +22 -0
  44. package/dist/tui/components/RespondersEditor.js +437 -0
  45. package/dist/tui/components/SectionNav.js +27 -3
  46. package/dist/utils/chat-client.d.ts +4 -0
  47. package/dist/utils/chat-client.js +12 -5
  48. package/dist/utils/config.d.ts +84 -0
  49. package/dist/utils/config.js +78 -1
  50. package/dist/utils/daemon-client.d.ts +21 -0
  51. package/dist/utils/daemon-client.js +28 -1
  52. package/dist/utils/llm-client.d.ts +82 -0
  53. package/dist/utils/llm-client.js +185 -0
  54. package/dist/utils/message-queue.js +6 -6
  55. package/dist/utils/notification.d.ts +6 -1
  56. package/dist/utils/notification.js +103 -2
  57. package/dist/utils/prd-validator.js +60 -19
  58. package/dist/utils/prompt.js +22 -12
  59. package/dist/utils/responder-logger.d.ts +47 -0
  60. package/dist/utils/responder-logger.js +129 -0
  61. package/dist/utils/responder-presets.d.ts +92 -0
  62. package/dist/utils/responder-presets.js +156 -0
  63. package/dist/utils/responder.d.ts +88 -0
  64. package/dist/utils/responder.js +207 -0
  65. package/dist/utils/stream-json.js +6 -6
  66. package/docs/CHAT-RESPONDERS.md +785 -0
  67. package/docs/DEVELOPMENT.md +25 -0
  68. package/docs/chat-architecture.md +251 -0
  69. package/package.json +11 -1
@@ -0,0 +1,582 @@
1
+ /**
2
+ * LLM Responder - Sends messages to LLM providers and returns responses.
3
+ * Used by chat clients to respond to messages matched by the responder matcher.
4
+ */
5
+ import { getLLMProviders, loadConfig, } from "../utils/config.js";
6
+ import { createLLMClient } from "../utils/llm-client.js";
7
+ import { createResponderLog } from "../utils/responder-logger.js";
8
+ import { basename, resolve } from "path";
9
+ import { execSync } from "child_process";
10
+ import { existsSync, readFileSync, statSync } from "fs";
11
+ /**
12
+ * Default max length for chat responses (characters).
13
+ */
14
+ const DEFAULT_MAX_LENGTH = 2000;
15
+ /**
16
+ * Default timeout for LLM requests (milliseconds).
17
+ */
18
+ const DEFAULT_TIMEOUT = 60000;
19
+ /**
20
+ * Replaces {{project}} placeholder in system prompt with actual project name.
21
+ */
22
+ export function applyProjectPlaceholder(systemPrompt, projectName) {
23
+ return systemPrompt.replace(/\{\{project\}\}/g, projectName);
24
+ }
25
+ /**
26
+ * Gets the project name from the current working directory.
27
+ */
28
+ export function getProjectName() {
29
+ return basename(process.cwd());
30
+ }
31
+ const GIT_DIFF_PATTERNS = [
32
+ // "diff" or "changes" - show unstaged changes
33
+ { pattern: /^(diff|changes)$/i, command: "git diff", description: "unstaged changes" },
34
+ // "staged" - show staged changes
35
+ { pattern: /^staged$/i, command: "git diff --cached", description: "staged changes" },
36
+ // "last" or "last commit" - show last commit
37
+ {
38
+ pattern: /^(last|last\s*commit)$/i,
39
+ command: "git show HEAD --stat --patch",
40
+ description: "last commit",
41
+ },
42
+ // "HEAD~N" - show specific commit
43
+ { pattern: /^HEAD~(\d+)$/i, command: "git show HEAD~$1 --stat --patch", description: "commit" },
44
+ // "all" - show all uncommitted changes (staged + unstaged)
45
+ { pattern: /^all$/i, command: "git diff HEAD", description: "all uncommitted changes" },
46
+ ];
47
+ /**
48
+ * Maximum length for git diff output to avoid overwhelming the LLM.
49
+ */
50
+ const MAX_DIFF_LENGTH = 8000;
51
+ /**
52
+ * Detects if the message contains a git diff request and fetches the diff.
53
+ * Returns the message with diff content prepended, or the original message if no diff requested.
54
+ */
55
+ export function processGitDiffRequest(message) {
56
+ const trimmed = message.trim();
57
+ // Check each pattern
58
+ for (const { pattern, command, description } of GIT_DIFF_PATTERNS) {
59
+ const match = trimmed.match(pattern);
60
+ if (match) {
61
+ try {
62
+ // Build the actual command (replace $1 with capture group if present)
63
+ let gitCommand = command;
64
+ if (match[1] && command.includes("$1")) {
65
+ gitCommand = command.replace("$1", match[1]);
66
+ }
67
+ // Execute git command
68
+ const diff = execSync(gitCommand, {
69
+ encoding: "utf-8",
70
+ maxBuffer: 1024 * 1024, // 1MB buffer
71
+ timeout: 10000, // 10 second timeout
72
+ }).trim();
73
+ if (!diff) {
74
+ return {
75
+ message: `No ${description} found. The working directory is clean.`,
76
+ diffIncluded: false,
77
+ gitCommand,
78
+ };
79
+ }
80
+ // Truncate if too long
81
+ let diffContent = diff;
82
+ let truncatedNote = "";
83
+ if (diff.length > MAX_DIFF_LENGTH) {
84
+ diffContent = diff.slice(0, MAX_DIFF_LENGTH);
85
+ truncatedNote = "\n\n[... diff truncated due to length ...]";
86
+ }
87
+ return {
88
+ message: `Here is the ${description} to review:\n\n\`\`\`diff\n${diffContent}${truncatedNote}\n\`\`\`\n\nPlease review these changes.`,
89
+ diffIncluded: true,
90
+ gitCommand,
91
+ diffLength: diff.length,
92
+ };
93
+ }
94
+ catch (err) {
95
+ const error = err instanceof Error ? err.message : String(err);
96
+ // Check if it's not a git repository
97
+ if (error.includes("not a git repository")) {
98
+ return {
99
+ message: `Cannot fetch git diff: not in a git repository.`,
100
+ diffIncluded: false,
101
+ };
102
+ }
103
+ return {
104
+ message: `Failed to fetch ${description}: ${error}`,
105
+ diffIncluded: false,
106
+ };
107
+ }
108
+ }
109
+ }
110
+ // No git diff pattern matched, return original message
111
+ return { message, diffIncluded: false };
112
+ }
113
+ /**
114
+ * Maximum total size for included files (to avoid overwhelming the LLM).
115
+ */
116
+ const MAX_FILE_CONTENT_LENGTH = 15000;
117
+ /**
118
+ * Maximum size for a single file.
119
+ */
120
+ const MAX_SINGLE_FILE_LENGTH = 8000;
121
+ /**
122
+ * Supported file extensions for auto-detection.
123
+ */
124
+ const SUPPORTED_EXTENSIONS = [
125
+ "ts",
126
+ "tsx",
127
+ "js",
128
+ "jsx",
129
+ "py",
130
+ "rb",
131
+ "go",
132
+ "rs",
133
+ "java",
134
+ "c",
135
+ "cpp",
136
+ "h",
137
+ "hpp",
138
+ "cs",
139
+ "swift",
140
+ "kt",
141
+ "scala",
142
+ "php",
143
+ "sh",
144
+ "bash",
145
+ "zsh",
146
+ "json",
147
+ "yaml",
148
+ "yml",
149
+ "toml",
150
+ "xml",
151
+ "html",
152
+ "css",
153
+ "scss",
154
+ "less",
155
+ "md",
156
+ "txt",
157
+ "sql",
158
+ "graphql",
159
+ "proto",
160
+ "dockerfile",
161
+ "makefile",
162
+ "env",
163
+ "gitignore",
164
+ "config",
165
+ ];
166
+ /**
167
+ * Detects file paths in a message and reads their contents.
168
+ * Supports formats like:
169
+ * - src/utils/config.ts
170
+ * - src/utils/config.ts:42 (with line number)
171
+ * - ./relative/path.js
172
+ * - package.json
173
+ */
174
+ export function detectAndReadFiles(message) {
175
+ const result = {
176
+ filesRead: [],
177
+ filesNotFound: [],
178
+ totalLength: 0,
179
+ };
180
+ // Pattern to match file paths with optional line numbers
181
+ // Matches: path/to/file.ext or path/to/file.ext:123
182
+ const extensionPattern = SUPPORTED_EXTENSIONS.join("|");
183
+ const filePattern = new RegExp(`(?:^|\\s|["'\`(])([\\w./-]+\\.(?:${extensionPattern}))(?::(\\d+))?(?=[\\s"'\`),]|$)`, "gi");
184
+ // Also match common config files without extensions
185
+ const configFilePattern = /(?:^|\s|["'`(])((?:\.?[\w/-]+)?(?:Dockerfile|Makefile|\.gitignore|\.env(?:\.local)?))(?=[\s"'`),]|$)/gi;
186
+ const seenFiles = new Set();
187
+ const matches = [];
188
+ // Find all file pattern matches
189
+ let match;
190
+ while ((match = filePattern.exec(message)) !== null) {
191
+ const filePath = match[1];
192
+ const lineNumber = match[2] ? parseInt(match[2], 10) : undefined;
193
+ if (!seenFiles.has(filePath)) {
194
+ seenFiles.add(filePath);
195
+ matches.push({ path: filePath, lineNumber });
196
+ }
197
+ }
198
+ // Find config file matches
199
+ while ((match = configFilePattern.exec(message)) !== null) {
200
+ const filePath = match[1];
201
+ if (!seenFiles.has(filePath)) {
202
+ seenFiles.add(filePath);
203
+ matches.push({ path: filePath });
204
+ }
205
+ }
206
+ // Try to read each file
207
+ for (const { path: filePath, lineNumber } of matches) {
208
+ // Stop if we've already included too much content
209
+ if (result.totalLength >= MAX_FILE_CONTENT_LENGTH) {
210
+ break;
211
+ }
212
+ try {
213
+ // Resolve relative paths
214
+ const resolvedPath = resolve(process.cwd(), filePath);
215
+ // Check if file exists and is a file (not directory)
216
+ if (!existsSync(resolvedPath)) {
217
+ result.filesNotFound.push(filePath);
218
+ continue;
219
+ }
220
+ const stats = statSync(resolvedPath);
221
+ if (!stats.isFile()) {
222
+ continue;
223
+ }
224
+ // Check file size before reading
225
+ if (stats.size > 100000) {
226
+ // Skip files larger than 100KB
227
+ result.filesNotFound.push(`${filePath} (too large)`);
228
+ continue;
229
+ }
230
+ // Read the file
231
+ let content = readFileSync(resolvedPath, "utf-8");
232
+ let truncated = false;
233
+ // Truncate if too long
234
+ if (content.length > MAX_SINGLE_FILE_LENGTH) {
235
+ content = content.slice(0, MAX_SINGLE_FILE_LENGTH);
236
+ truncated = true;
237
+ }
238
+ // Check if adding this would exceed total limit
239
+ if (result.totalLength + content.length > MAX_FILE_CONTENT_LENGTH) {
240
+ const remaining = MAX_FILE_CONTENT_LENGTH - result.totalLength;
241
+ if (remaining > 500) {
242
+ content = content.slice(0, remaining);
243
+ truncated = true;
244
+ }
245
+ else {
246
+ break;
247
+ }
248
+ }
249
+ result.filesRead.push({
250
+ path: filePath,
251
+ content,
252
+ lineNumber,
253
+ truncated,
254
+ });
255
+ result.totalLength += content.length;
256
+ }
257
+ catch {
258
+ result.filesNotFound.push(filePath);
259
+ }
260
+ }
261
+ return result;
262
+ }
263
+ /**
264
+ * Formats detected files as context to prepend to the user message.
265
+ */
266
+ export function formatFileContext(fileResult) {
267
+ if (fileResult.filesRead.length === 0) {
268
+ return "";
269
+ }
270
+ const parts = ["Here are the referenced files:\n"];
271
+ for (const file of fileResult.filesRead) {
272
+ const truncatedNote = file.truncated ? " (truncated)" : "";
273
+ const lineNote = file.lineNumber ? ` (focus on line ${file.lineNumber})` : "";
274
+ // Detect language for syntax highlighting
275
+ const ext = file.path.split(".").pop() || "";
276
+ const langMap = {
277
+ ts: "typescript",
278
+ tsx: "typescript",
279
+ js: "javascript",
280
+ jsx: "javascript",
281
+ py: "python",
282
+ rb: "ruby",
283
+ go: "go",
284
+ rs: "rust",
285
+ java: "java",
286
+ json: "json",
287
+ yaml: "yaml",
288
+ yml: "yaml",
289
+ md: "markdown",
290
+ sh: "bash",
291
+ bash: "bash",
292
+ };
293
+ const lang = langMap[ext] || ext;
294
+ parts.push(`\n**${file.path}**${lineNote}${truncatedNote}:`);
295
+ parts.push("```" + lang);
296
+ // If line number specified, add line numbers to help locate
297
+ if (file.lineNumber) {
298
+ const lines = file.content.split("\n");
299
+ const start = Math.max(0, file.lineNumber - 10);
300
+ const end = Math.min(lines.length, file.lineNumber + 10);
301
+ const contextLines = lines.slice(start, end);
302
+ const numberedLines = contextLines.map((line, i) => `${String(start + i + 1).padStart(4, " ")} | ${line}`);
303
+ parts.push(numberedLines.join("\n"));
304
+ if (start > 0)
305
+ parts[parts.length - 1] = "...\n" + parts[parts.length - 1];
306
+ if (end < lines.length)
307
+ parts[parts.length - 1] += "\n...";
308
+ }
309
+ else {
310
+ parts.push(file.content);
311
+ }
312
+ parts.push("```");
313
+ }
314
+ if (fileResult.filesNotFound.length > 0) {
315
+ parts.push(`\n_Files not found: ${fileResult.filesNotFound.join(", ")}_`);
316
+ }
317
+ parts.push("\n---\n");
318
+ return parts.join("\n");
319
+ }
320
+ /**
321
+ * Truncates a response to the specified max length.
322
+ * Adds a truncation indicator if the response was shortened.
323
+ */
324
+ export function truncateResponse(response, maxLength) {
325
+ const originalLength = response.length;
326
+ if (originalLength <= maxLength) {
327
+ return { text: response, truncated: false, originalLength };
328
+ }
329
+ // Leave room for truncation indicator
330
+ const indicator = "\n\n[...response truncated]";
331
+ const truncatedLength = maxLength - indicator.length;
332
+ if (truncatedLength <= 0) {
333
+ return { text: "[response too long]", truncated: true, originalLength };
334
+ }
335
+ // Try to truncate at a sentence or word boundary
336
+ let text = response.slice(0, truncatedLength);
337
+ // Look for a good break point (sentence end, then word end)
338
+ const sentenceEnd = text.lastIndexOf(". ");
339
+ const paragraphEnd = text.lastIndexOf("\n\n");
340
+ const wordEnd = text.lastIndexOf(" ");
341
+ // Prefer paragraph, then sentence, then word boundary
342
+ if (paragraphEnd > truncatedLength * 0.7) {
343
+ text = text.slice(0, paragraphEnd);
344
+ }
345
+ else if (sentenceEnd > truncatedLength * 0.7) {
346
+ text = text.slice(0, sentenceEnd + 1); // Include the period
347
+ }
348
+ else if (wordEnd > truncatedLength * 0.8) {
349
+ text = text.slice(0, wordEnd);
350
+ }
351
+ return {
352
+ text: text + indicator,
353
+ truncated: true,
354
+ originalLength,
355
+ };
356
+ }
357
+ /**
358
+ * Executes an LLM responder with the given message.
359
+ *
360
+ * @param message The user message to send to the LLM
361
+ * @param responderConfig The responder configuration
362
+ * @param config Optional Ralph config (loaded automatically if not provided)
363
+ * @param options Optional execution options
364
+ * @returns The responder result with response or error
365
+ */
366
+ export async function executeLLMResponder(message, responderConfig, config, options) {
367
+ try {
368
+ // Process git diff requests (e.g., "@review diff", "@review last")
369
+ const gitDiffResult = processGitDiffRequest(message);
370
+ let processedMessage = gitDiffResult.message;
371
+ // Detect and read referenced files (e.g., "src/utils/config.ts")
372
+ const fileResult = detectAndReadFiles(message);
373
+ const fileContext = formatFileContext(fileResult);
374
+ if (fileContext) {
375
+ processedMessage = fileContext + processedMessage;
376
+ }
377
+ // Load config if not provided
378
+ const ralphConfig = config ?? loadConfig();
379
+ // Get LLM providers
380
+ const providers = getLLMProviders(ralphConfig);
381
+ // Get provider name from responder config (default to "anthropic")
382
+ const providerName = responderConfig.provider ?? "anthropic";
383
+ // Look up the provider
384
+ const providerConfig = providers[providerName];
385
+ if (!providerConfig) {
386
+ return {
387
+ success: false,
388
+ response: "",
389
+ error: `LLM provider "${providerName}" not found. Available providers: ${Object.keys(providers).join(", ")}`,
390
+ };
391
+ }
392
+ // Create LLM client
393
+ let client;
394
+ try {
395
+ client = createLLMClient(providerConfig);
396
+ }
397
+ catch (err) {
398
+ const error = err instanceof Error ? err.message : String(err);
399
+ return {
400
+ success: false,
401
+ response: "",
402
+ error: `Failed to create LLM client for "${providerName}": ${error}`,
403
+ };
404
+ }
405
+ // Prepare system prompt with project placeholder
406
+ const projectName = options?.projectName ?? getProjectName();
407
+ let systemPrompt = responderConfig.systemPrompt;
408
+ if (systemPrompt) {
409
+ systemPrompt = applyProjectPlaceholder(systemPrompt, projectName);
410
+ }
411
+ // Prepare chat options
412
+ const chatOptions = {
413
+ maxTokens: options?.maxTokens,
414
+ temperature: options?.temperature,
415
+ };
416
+ // Prepare messages (use processed message which may include git diff content)
417
+ // Include conversation history if provided for multi-turn chat
418
+ const messages = [];
419
+ if (options?.conversationHistory && options.conversationHistory.length > 0) {
420
+ for (const msg of options.conversationHistory) {
421
+ messages.push({ role: msg.role, content: msg.content });
422
+ }
423
+ }
424
+ messages.push({ role: "user", content: processedMessage });
425
+ // Log the responder call
426
+ createResponderLog({
427
+ responderName: options?.responderName,
428
+ responderType: "llm",
429
+ trigger: options?.trigger,
430
+ gitCommand: gitDiffResult.gitCommand,
431
+ gitDiffLength: gitDiffResult.diffLength,
432
+ filesRead: fileResult.filesRead.map((f) => f.path),
433
+ filesNotFound: fileResult.filesNotFound,
434
+ filesTotalLength: fileResult.totalLength,
435
+ threadContextLength: options?.threadContextLength,
436
+ message: processedMessage,
437
+ systemPrompt,
438
+ debug: options?.debug,
439
+ });
440
+ // Execute with timeout
441
+ const timeout = responderConfig.timeout ?? DEFAULT_TIMEOUT;
442
+ const responsePromise = client.chat(messages, systemPrompt, chatOptions);
443
+ let response;
444
+ try {
445
+ response = await Promise.race([
446
+ responsePromise,
447
+ new Promise((_, reject) => setTimeout(() => reject(new Error("LLM request timed out")), timeout)),
448
+ ]);
449
+ }
450
+ catch (err) {
451
+ const error = err instanceof Error ? err.message : String(err);
452
+ return {
453
+ success: false,
454
+ response: "",
455
+ error: `LLM request failed: ${error}`,
456
+ };
457
+ }
458
+ // Truncate response if needed
459
+ const maxLength = responderConfig.maxLength ?? DEFAULT_MAX_LENGTH;
460
+ const { text, truncated, originalLength } = truncateResponse(response, maxLength);
461
+ return {
462
+ success: true,
463
+ response: text,
464
+ truncated,
465
+ originalLength: truncated ? originalLength : undefined,
466
+ };
467
+ }
468
+ catch (err) {
469
+ const error = err instanceof Error ? err.message : String(err);
470
+ return {
471
+ success: false,
472
+ response: "",
473
+ error: `Unexpected error: ${error}`,
474
+ };
475
+ }
476
+ }
477
+ /**
478
+ * Creates a reusable LLM responder function with pre-loaded configuration.
479
+ * This is useful for handling multiple messages without reloading config each time.
480
+ *
481
+ * @param responderConfig The responder configuration
482
+ * @param config The Ralph configuration
483
+ * @returns A function that executes the responder with a message
484
+ */
485
+ export function createLLMResponder(responderConfig, config) {
486
+ // Pre-load provider and client
487
+ const providers = getLLMProviders(config);
488
+ const providerName = responderConfig.provider ?? "anthropic";
489
+ const providerConfig = providers[providerName];
490
+ // Pre-create client if possible
491
+ let client = null;
492
+ let clientError = null;
493
+ if (!providerConfig) {
494
+ clientError = `LLM provider "${providerName}" not found. Available providers: ${Object.keys(providers).join(", ")}`;
495
+ }
496
+ else {
497
+ try {
498
+ client = createLLMClient(providerConfig);
499
+ }
500
+ catch (err) {
501
+ clientError = `Failed to create LLM client for "${providerName}": ${err instanceof Error ? err.message : String(err)}`;
502
+ }
503
+ }
504
+ return async (message, options) => {
505
+ // Return cached error if client creation failed
506
+ if (clientError || !client) {
507
+ return {
508
+ success: false,
509
+ response: "",
510
+ error: clientError ?? "LLM client not initialized",
511
+ };
512
+ }
513
+ try {
514
+ // Prepare system prompt with project placeholder
515
+ const projectName = options?.projectName ?? getProjectName();
516
+ let systemPrompt = responderConfig.systemPrompt;
517
+ if (systemPrompt) {
518
+ systemPrompt = applyProjectPlaceholder(systemPrompt, projectName);
519
+ }
520
+ // Prepare chat options
521
+ const chatOptions = {
522
+ maxTokens: options?.maxTokens,
523
+ temperature: options?.temperature,
524
+ };
525
+ // Prepare messages
526
+ const messages = [{ role: "user", content: message }];
527
+ // Execute with timeout
528
+ const timeout = responderConfig.timeout ?? DEFAULT_TIMEOUT;
529
+ const responsePromise = client.chat(messages, systemPrompt, chatOptions);
530
+ let response;
531
+ try {
532
+ response = await Promise.race([
533
+ responsePromise,
534
+ new Promise((_, reject) => setTimeout(() => reject(new Error("LLM request timed out")), timeout)),
535
+ ]);
536
+ }
537
+ catch (err) {
538
+ const error = err instanceof Error ? err.message : String(err);
539
+ return {
540
+ success: false,
541
+ response: "",
542
+ error: `LLM request failed: ${error}`,
543
+ };
544
+ }
545
+ // Truncate response if needed
546
+ const maxLength = responderConfig.maxLength ?? DEFAULT_MAX_LENGTH;
547
+ const { text, truncated, originalLength } = truncateResponse(response, maxLength);
548
+ return {
549
+ success: true,
550
+ response: text,
551
+ truncated,
552
+ originalLength: truncated ? originalLength : undefined,
553
+ };
554
+ }
555
+ catch (err) {
556
+ const error = err instanceof Error ? err.message : String(err);
557
+ return {
558
+ success: false,
559
+ response: "",
560
+ error: `Unexpected error: ${error}`,
561
+ };
562
+ }
563
+ };
564
+ }
565
+ /**
566
+ * Validates that a responder configuration is valid for LLM execution.
567
+ *
568
+ * @param responderConfig The responder configuration to validate
569
+ * @param config The Ralph configuration for looking up providers
570
+ * @returns An error message if invalid, or null if valid
571
+ */
572
+ export function validateLLMResponder(responderConfig, config) {
573
+ if (responderConfig.type !== "llm") {
574
+ return `Responder type is "${responderConfig.type}", expected "llm"`;
575
+ }
576
+ const providers = getLLMProviders(config);
577
+ const providerName = responderConfig.provider ?? "anthropic";
578
+ if (!providers[providerName]) {
579
+ return `LLM provider "${providerName}" not found. Available providers: ${Object.keys(providers).join(", ")}`;
580
+ }
581
+ return null;
582
+ }
@@ -203,15 +203,13 @@ echo " ./scripts/gen_xcode.sh"
203
203
  * Check if the selected technologies include SwiftUI
204
204
  */
205
205
  export function hasSwiftUI(technologies) {
206
- return technologies.some(tech => tech.toLowerCase().includes('swiftui') ||
207
- tech.toLowerCase() === 'swiftui');
206
+ return technologies.some((tech) => tech.toLowerCase().includes("swiftui") || tech.toLowerCase() === "swiftui");
208
207
  }
209
208
  /**
210
209
  * Check if the selected technologies include Fastlane
211
210
  */
212
211
  export function hasFastlane(technologies) {
213
- return technologies.some(tech => tech.toLowerCase().includes('fastlane') ||
214
- tech.toLowerCase() === 'fastlane');
212
+ return technologies.some((tech) => tech.toLowerCase().includes("fastlane") || tech.toLowerCase() === "fastlane");
215
213
  }
216
214
  /**
217
215
  * Generate Fastfile template for macOS/iOS deployment
@@ -122,7 +122,9 @@ Now, read the PRD and begin working on the highest priority incomplete feature.`
122
122
  export function resolvePromptVariables(template, config) {
123
123
  const languageConfig = LANGUAGES[config.language];
124
124
  const languageName = languageConfig?.name || config.language;
125
- const technologies = config.technologies?.length ? config.technologies.join(", ") : "(none specified)";
125
+ const technologies = config.technologies?.length
126
+ ? config.technologies.join(", ")
127
+ : "(none specified)";
126
128
  return template
127
129
  .replace(/\$language/g, languageName)
128
130
  .replace(/\$technologies/g, technologies)
@@ -133,7 +135,7 @@ export function resolvePromptVariables(template, config) {
133
135
  export function generatePrompt(config, technologies) {
134
136
  const template = generatePromptTemplate();
135
137
  return resolvePromptVariables(template, {
136
- language: Object.keys(LANGUAGES).find(k => LANGUAGES[k].name === config.name) || "none",
138
+ language: Object.keys(LANGUAGES).find((k) => LANGUAGES[k].name === config.name) || "none",
137
139
  checkCommand: config.checkCommand,
138
140
  testCommand: config.testCommand,
139
141
  technologies,
@@ -11,6 +11,8 @@ import { ArrayEditor } from "./components/ArrayEditor.js";
11
11
  import { ObjectEditor } from "./components/ObjectEditor.js";
12
12
  import { KeyValueEditor } from "./components/KeyValueEditor.js";
13
13
  import { JsonSnippetEditor } from "./components/JsonSnippetEditor.js";
14
+ import { LLMProvidersEditor } from "./components/LLMProvidersEditor.js";
15
+ import { RespondersEditor } from "./components/RespondersEditor.js";
14
16
  import { Preview } from "./components/Preview.js";
15
17
  import { HelpPanel } from "./components/HelpPanel.js";
16
18
  import { PresetSelector } from "./components/PresetSelector.js";
@@ -41,7 +43,7 @@ function setValueAtPath(obj, path, value) {
41
43
  export function ConfigEditor() {
42
44
  const { exit } = useApp();
43
45
  const terminalSize = useTerminalSize();
44
- const { config, loading, error, hasChanges, saveConfig, updateConfig, } = useConfig();
46
+ const { config, loading, error, hasChanges, saveConfig, updateConfig } = useConfig();
45
47
  // Calculate available height for scrollable content
46
48
  // Reserve lines for: header (2), status message (1), footer (2), borders (2)
47
49
  const availableHeight = Math.max(8, terminalSize.rows - 7);
@@ -272,15 +274,23 @@ export function ConfigEditor() {
272
274
  for (const [k, v] of Object.entries(objValue)) {
273
275
  stringEntries[k] = typeof v === "string" ? v : JSON.stringify(v);
274
276
  }
277
+ // Check if this is the llmProviders field
278
+ const isLLMProviders = selectedField === "llmProviders";
279
+ if (isLLMProviders) {
280
+ return (_jsx(LLMProvidersEditor, { label: currentFieldLabel, providers: currentFieldValue || {}, onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight }));
281
+ }
282
+ // Check if this is the chat.responders field
283
+ const isResponders = selectedField === "chat.responders";
284
+ if (isResponders) {
285
+ return (_jsx(RespondersEditor, { label: currentFieldLabel, responders: currentFieldValue || {}, onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true, maxHeight: editorMaxHeight }));
286
+ }
275
287
  // Check if this is a notification provider config field
276
288
  const isNotificationProvider = selectedField &&
277
289
  (selectedField === "notifications.ntfy" ||
278
290
  selectedField === "notifications.pushover" ||
279
291
  selectedField === "notifications.gotify");
280
292
  // Check if this is a chat provider config field
281
- const isChatProvider = selectedField &&
282
- (selectedField === "chat.slack" ||
283
- selectedField === "chat.telegram");
293
+ const isChatProvider = selectedField && (selectedField === "chat.slack" || selectedField === "chat.telegram");
284
294
  if (isNotificationProvider || isChatProvider) {
285
295
  // Extract provider name from field path
286
296
  const providerName = selectedField.split(".").pop() || "";
@@ -331,6 +341,10 @@ export function ConfigEditor() {
331
341
  return (_jsx(StringEditor, { label: currentFieldLabel, value: String(currentFieldValue || ""), onConfirm: handleFieldConfirm, onCancel: handleFieldCancel, isFocused: true }));
332
342
  }
333
343
  };
334
- return (_jsxs(Box, { flexDirection: "column", children: [helpVisible && (_jsx(HelpPanel, { visible: helpVisible, onClose: toggleHelp })), _jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), hasChanges && _jsx(Text, { color: "yellow", children: " (unsaved changes)" })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "[S] Save" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[Q] Quit" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[?] Help" })] })] }), statusMessage && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: statusMessage.includes("Failed") || statusMessage.includes("Validation") ? "red" : "green", children: statusMessage }) })), focusPane === "field-editor" ? (_jsx(Box, { children: renderFieldEditor() })) : focusPane === "preset-selector" ? (_jsx(Box, { children: _jsx(PresetSelector, { sectionId: selectedSection, config: config, onSelectPreset: handleSelectPreset, onSkip: handleSkipPreset, onCancel: handleCancelPreset, isFocused: true }) })) : (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(SectionNav, { selectedSection: selectedSection, onSelectSection: handleSelectSection, isFocused: focusPane === "nav", maxHeight: navMaxHeight }) }), _jsx(Box, { flexGrow: 1, children: _jsx(EditorPanel, { config: config, selectedSection: selectedSection, selectedField: selectedField, onSelectField: handleSelectField, onBack: handleBack, isFocused: focusPane === "editor", validationErrors: validationErrors, maxHeight: editorMaxHeight }) }), _jsx(Preview, { config: config, selectedSection: selectedSection, visible: previewVisible })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [focusPane === "nav" && "j/k: navigate | Enter: select | l/→: editor | Tab: toggle preview", focusPane === "editor" && "j/k: navigate | Enter: edit | J: JSON | h/←: nav | Tab: preview | p: presets", focusPane === "field-editor" && "Follow editor hints", focusPane === "preset-selector" && "j/k: navigate | Enter: select | Esc: back"] }) })] }));
344
+ return (_jsxs(Box, { flexDirection: "column", children: [helpVisible && _jsx(HelpPanel, { visible: helpVisible, onClose: toggleHelp }), _jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { color: "cyan", bold: true, children: "ralph config" }), hasChanges && _jsx(Text, { color: "yellow", children: " (unsaved changes)" })] }), _jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "[S] Save" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[Q] Quit" }), _jsx(Text, { dimColor: true, children: " | " }), _jsx(Text, { dimColor: true, children: "[?] Help" })] })] }), statusMessage && (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: statusMessage.includes("Failed") || statusMessage.includes("Validation")
345
+ ? "red"
346
+ : "green", children: statusMessage }) })), focusPane === "field-editor" ? (_jsx(Box, { children: renderFieldEditor() })) : focusPane === "preset-selector" ? (_jsx(Box, { children: _jsx(PresetSelector, { sectionId: selectedSection, config: config, onSelectPreset: handleSelectPreset, onSkip: handleSkipPreset, onCancel: handleCancelPreset, isFocused: true }) })) : (_jsxs(Box, { children: [_jsx(Box, { width: 20, children: _jsx(SectionNav, { selectedSection: selectedSection, onSelectSection: handleSelectSection, isFocused: focusPane === "nav", maxHeight: navMaxHeight }) }), _jsx(Box, { flexGrow: 1, children: _jsx(EditorPanel, { config: config, selectedSection: selectedSection, selectedField: selectedField, onSelectField: handleSelectField, onBack: handleBack, isFocused: focusPane === "editor", validationErrors: validationErrors, maxHeight: editorMaxHeight }) }), _jsx(Preview, { config: config, selectedSection: selectedSection, visible: previewVisible })] })), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [focusPane === "nav" &&
347
+ "j/k: navigate | Enter: select | l/→: editor | Tab: toggle preview", focusPane === "editor" &&
348
+ "j/k: navigate | Enter: edit | J: JSON | h/←: nav | Tab: preview | p: presets", focusPane === "field-editor" && "Follow editor hints", focusPane === "preset-selector" && "j/k: navigate | Enter: select | Esc: back"] }) })] }));
335
349
  }
336
350
  export default ConfigEditor;