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.
- package/README.md +30 -0
- package/dist/commands/action.js +9 -9
- package/dist/commands/chat.js +13 -12
- package/dist/commands/config.js +2 -1
- package/dist/commands/daemon.js +4 -3
- package/dist/commands/docker.js +102 -66
- package/dist/commands/fix-config.js +2 -1
- package/dist/commands/fix-prd.js +2 -2
- package/dist/commands/init.js +78 -17
- package/dist/commands/listen.js +3 -1
- package/dist/commands/notify.js +1 -1
- package/dist/commands/once.js +17 -9
- package/dist/commands/prd.js +4 -1
- package/dist/commands/run.js +40 -25
- package/dist/commands/slack.js +2 -2
- package/dist/config/responder-presets.json +69 -0
- package/dist/index.js +1 -1
- package/dist/providers/discord.d.ts +28 -0
- package/dist/providers/discord.js +227 -14
- package/dist/providers/slack.d.ts +41 -1
- package/dist/providers/slack.js +389 -8
- package/dist/providers/telegram.d.ts +30 -0
- package/dist/providers/telegram.js +185 -5
- package/dist/responders/claude-code-responder.d.ts +48 -0
- package/dist/responders/claude-code-responder.js +203 -0
- package/dist/responders/cli-responder.d.ts +62 -0
- package/dist/responders/cli-responder.js +298 -0
- package/dist/responders/llm-responder.d.ts +135 -0
- package/dist/responders/llm-responder.js +582 -0
- package/dist/templates/macos-scripts.js +2 -4
- package/dist/templates/prompts.js +4 -2
- package/dist/tui/ConfigEditor.js +19 -5
- package/dist/tui/components/ArrayEditor.js +1 -1
- package/dist/tui/components/EditorPanel.js +10 -6
- package/dist/tui/components/HelpPanel.d.ts +1 -1
- package/dist/tui/components/HelpPanel.js +1 -1
- package/dist/tui/components/JsonSnippetEditor.js +8 -5
- package/dist/tui/components/KeyValueEditor.js +54 -9
- package/dist/tui/components/LLMProvidersEditor.d.ts +22 -0
- package/dist/tui/components/LLMProvidersEditor.js +357 -0
- package/dist/tui/components/ObjectEditor.js +1 -1
- package/dist/tui/components/Preview.js +1 -1
- package/dist/tui/components/RespondersEditor.d.ts +22 -0
- package/dist/tui/components/RespondersEditor.js +437 -0
- package/dist/tui/components/SectionNav.js +27 -3
- package/dist/utils/chat-client.d.ts +4 -0
- package/dist/utils/chat-client.js +12 -5
- package/dist/utils/config.d.ts +84 -0
- package/dist/utils/config.js +78 -1
- package/dist/utils/daemon-client.d.ts +21 -0
- package/dist/utils/daemon-client.js +28 -1
- package/dist/utils/llm-client.d.ts +82 -0
- package/dist/utils/llm-client.js +185 -0
- package/dist/utils/message-queue.js +6 -6
- package/dist/utils/notification.d.ts +6 -1
- package/dist/utils/notification.js +103 -2
- package/dist/utils/prd-validator.js +60 -19
- package/dist/utils/prompt.js +22 -12
- package/dist/utils/responder-logger.d.ts +47 -0
- package/dist/utils/responder-logger.js +129 -0
- package/dist/utils/responder-presets.d.ts +92 -0
- package/dist/utils/responder-presets.js +156 -0
- package/dist/utils/responder.d.ts +88 -0
- package/dist/utils/responder.js +207 -0
- package/dist/utils/stream-json.js +6 -6
- package/docs/CHAT-RESPONDERS.md +785 -0
- package/docs/DEVELOPMENT.md +25 -0
- package/docs/chat-architecture.md +251 -0
- 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(
|
|
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(
|
|
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
|
|
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,
|
package/dist/tui/ConfigEditor.js
CHANGED
|
@@ -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
|
|
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 &&
|
|
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;
|