@ridit/lens 0.3.7 → 0.3.9
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/dist/index.mjs +105368 -274002
- package/package.json +13 -19
- package/src/colors.ts +15 -15
- package/src/commands/chat.tsx +32 -23
- package/src/commands/provider.tsx +11 -238
- package/src/commands/repo.tsx +66 -120
- package/src/commands/timeline.tsx +11 -22
- package/src/components/ChatView.tsx +238 -0
- package/src/components/Message.tsx +46 -0
- package/src/components/ToolCall.tsx +67 -0
- package/src/components/chat/ChatView.tsx +550 -0
- package/src/components/chat/Message.tsx +152 -0
- package/src/components/chat/StatusBar.tsx +214 -0
- package/src/components/chat/TextArea.tsx +173 -176
- package/src/components/provider/ApiKeyStep.tsx +207 -199
- package/src/components/provider/ModelStep.tsx +90 -88
- package/src/components/provider/ProviderSetup.tsx +331 -0
- package/src/components/provider/ProviderTypeStep.tsx +53 -61
- package/src/components/repo/StepRow.tsx +68 -69
- package/src/components/timeline/TimelineView.tsx +840 -0
- package/src/components/toolcall-utils.ts +103 -0
- package/src/components/watch/RunView.tsx +497 -0
- package/src/hooks/useChatInput.ts +49 -0
- package/src/hooks/useCommandHandler.ts +117 -0
- package/src/index.tsx +386 -139
- package/src/utils/git.ts +149 -155
- package/src/utils/repo.ts +62 -69
- package/src/utils/thinking.tsx +64 -0
- package/src/utils/watch.ts +165 -307
- package/tests/message.test.ts +38 -0
- package/tests/toolcall-utils.test.ts +111 -0
- package/tsconfig.json +8 -24
- package/CLAUDE.md +0 -50
- package/LENS.md +0 -48
- package/LICENSE +0 -21
- package/README.md +0 -93
- package/addons/README.md +0 -55
- package/addons/clean-cache.js +0 -48
- package/addons/generate-readme.js +0 -67
- package/addons/git-stats.js +0 -29
- package/addons/run-tests.js +0 -127
- package/src/commands/commit.tsx +0 -668
- package/src/commands/review.tsx +0 -294
- package/src/commands/run.tsx +0 -56
- package/src/commands/task.tsx +0 -36
- package/src/components/chat/ChatMessage.tsx +0 -195
- package/src/components/chat/ChatOverlays.tsx +0 -399
- package/src/components/chat/ChatRunner.tsx +0 -517
- package/src/components/chat/hooks/useChat.ts +0 -631
- package/src/components/chat/hooks/useChatInput.ts +0 -79
- package/src/components/chat/hooks/useCommandHandlers.ts +0 -327
- package/src/components/provider/ProviderPicker.tsx +0 -76
- package/src/components/provider/RemoveProviderStep.tsx +0 -82
- package/src/components/repo/DiffViewer.tsx +0 -175
- package/src/components/repo/FileReviewer.tsx +0 -70
- package/src/components/repo/FileViewer.tsx +0 -60
- package/src/components/repo/IssueFixer.tsx +0 -666
- package/src/components/repo/LensFileMenu.tsx +0 -115
- package/src/components/repo/NoProviderPrompt.tsx +0 -28
- package/src/components/repo/PreviewRunner.tsx +0 -217
- package/src/components/repo/RepoAnalysis.tsx +0 -534
- package/src/components/task/TaskRunner.tsx +0 -396
- package/src/components/timeline/CommitDetail.tsx +0 -272
- package/src/components/timeline/CommitList.tsx +0 -162
- package/src/components/timeline/TimelineChat.tsx +0 -166
- package/src/components/timeline/TimelineRunner.tsx +0 -1285
- package/src/components/watch/RunRunner.tsx +0 -929
- package/src/prompts/fewshot.ts +0 -252
- package/src/prompts/index.ts +0 -2
- package/src/prompts/system.ts +0 -285
- package/src/tools/chart.ts +0 -202
- package/src/tools/convert-image.ts +0 -312
- package/src/tools/files.ts +0 -253
- package/src/tools/git.ts +0 -603
- package/src/tools/index.ts +0 -17
- package/src/tools/pdf.ts +0 -164
- package/src/tools/shell.ts +0 -96
- package/src/tools/view-image.ts +0 -335
- package/src/tools/web.ts +0 -212
- package/src/types/chat.ts +0 -86
- package/src/types/config.ts +0 -20
- package/src/types/repo.ts +0 -54
- package/src/utils/addons/loadAddons.ts +0 -34
- package/src/utils/ai.ts +0 -321
- package/src/utils/chat.ts +0 -326
- package/src/utils/chatHistory.ts +0 -121
- package/src/utils/config.ts +0 -61
- package/src/utils/files.ts +0 -105
- package/src/utils/intentClassifier.ts +0 -58
- package/src/utils/lensfile.ts +0 -142
- package/src/utils/llm.ts +0 -81
- package/src/utils/memory.ts +0 -209
- package/src/utils/preview.ts +0 -119
- package/src/utils/stats.ts +0 -174
- package/src/utils/tools/builtins.ts +0 -377
- package/src/utils/tools/registry.ts +0 -105
package/src/utils/ai.ts
DELETED
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import { exec } from "child_process";
|
|
2
|
-
import { readFileSync, existsSync } from "fs";
|
|
3
|
-
import path from "path";
|
|
4
|
-
import type { Provider } from "../types/config";
|
|
5
|
-
import type { AnalysisResult, ImportantFile } from "../types/repo";
|
|
6
|
-
|
|
7
|
-
export function buildFileListPrompt(
|
|
8
|
-
repoUrl: string,
|
|
9
|
-
fileTree: string[],
|
|
10
|
-
): string {
|
|
11
|
-
return `You are a senior software engineer. You are about to analyze this repository:
|
|
12
|
-
Repository URL: ${repoUrl}
|
|
13
|
-
|
|
14
|
-
Here is the complete file tree (${fileTree.length} files):
|
|
15
|
-
${fileTree.join("\n")}
|
|
16
|
-
|
|
17
|
-
Your job is to select the files you need to read to fully understand what this project is, what it does, and how it works.
|
|
18
|
-
|
|
19
|
-
Rules:
|
|
20
|
-
- ALWAYS include package.json, tsconfig.json, README.md if they exist
|
|
21
|
-
- ALWAYS include ALL files inside src/ — especially index files, main entry points, and any files that reveal the project's purpose (components, hooks, utilities, exports)
|
|
22
|
-
- Include config files: vite.config, eslint.config, tailwind.config, bun.lockb, .nvmrc, etc.
|
|
23
|
-
- If there is a src/index.ts or src/main.ts or src/lib/index.ts, ALWAYS include it
|
|
24
|
-
- Do NOT skip source files just because there are many — pick up to 30 files
|
|
25
|
-
- Prefer breadth: pick at least one file from every folder under src/
|
|
26
|
-
|
|
27
|
-
Respond ONLY with a JSON array of file paths relative to repo root. No markdown, no explanation. Example:
|
|
28
|
-
["package.json", "src/main.ts", "src/components/Button.tsx"]`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function buildAnalysisPrompt(
|
|
32
|
-
repoUrl: string,
|
|
33
|
-
files: ImportantFile[],
|
|
34
|
-
): string {
|
|
35
|
-
const fileList = files
|
|
36
|
-
.map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 3000)}\n\`\`\``)
|
|
37
|
-
.join("\n\n");
|
|
38
|
-
|
|
39
|
-
return `You are a senior software engineer building a persistent knowledge base about a codebase. Your output will be stored and incrementally updated over time — it must be durable, structural knowledge, not ephemeral warnings.
|
|
40
|
-
|
|
41
|
-
Repository URL: ${repoUrl}
|
|
42
|
-
|
|
43
|
-
Here are the file contents:
|
|
44
|
-
|
|
45
|
-
${fileList}
|
|
46
|
-
|
|
47
|
-
Analyze this repository and extract permanent, structural understanding. Focus on WHAT the codebase IS and HOW it works — not linting issues or missing configs.
|
|
48
|
-
|
|
49
|
-
Rules:
|
|
50
|
-
- Read source code carefully. Reference real file names, real function names, real patterns.
|
|
51
|
-
- tooling: detect from package.json, lockfiles, config files. Keys: packageManager (npm/yarn/pnpm/bun), language, runtime, bundler, framework, testRunner, linter, formatter — only include what you actually found evidence of.
|
|
52
|
-
- keyFiles: list the most important files with a one-line description of what they do. Format: "src/utils/ai.ts: callModel abstraction supporting anthropic/gemini/ollama/openai"
|
|
53
|
-
- patterns: list recurring idioms, design patterns, or conventions actually used in the code. E.g. "Discriminated union state machines for multi-stage UI flows", "React + Ink for terminal rendering"
|
|
54
|
-
- architecture: 2-3 sentences describing the high-level structure and how data flows through the system.
|
|
55
|
-
- importantFolders: describe EVERY folder with specifics — what files are in it and what they do.
|
|
56
|
-
- suggestions: specific, actionable improvements referencing real file names and real patterns you saw. No generic advice.
|
|
57
|
-
- overview: 3-5 sentences naming actual components, features, exports. Be specific.
|
|
58
|
-
|
|
59
|
-
Respond ONLY with a JSON object (no markdown, no explanation):
|
|
60
|
-
{
|
|
61
|
-
"overview": "...",
|
|
62
|
-
"architecture": "...",
|
|
63
|
-
"tooling": {
|
|
64
|
-
"packageManager": "bun",
|
|
65
|
-
"language": "TypeScript",
|
|
66
|
-
"runtime": "Node.js",
|
|
67
|
-
"bundler": "tsup",
|
|
68
|
-
"framework": "Ink"
|
|
69
|
-
},
|
|
70
|
-
"importantFolders": [
|
|
71
|
-
"src/commands: contains chat.tsx, commit.tsx, review.tsx — each exports an Ink component that is the top-level renderer for that CLI command"
|
|
72
|
-
],
|
|
73
|
-
"keyFiles": [
|
|
74
|
-
"src/utils/ai.ts: callModel abstraction supporting anthropic/gemini/ollama/openai providers via a unified Provider type"
|
|
75
|
-
],
|
|
76
|
-
"patterns": [
|
|
77
|
-
"Discriminated union state machines (type + stage fields) for multi-step UI flows in every command component"
|
|
78
|
-
],
|
|
79
|
-
"suggestions": [
|
|
80
|
-
"In src/utils/ai.ts, callModel has no retry logic — adding exponential backoff would improve reliability for ollama which can be slow to start"
|
|
81
|
-
]
|
|
82
|
-
}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function buildToolingPatchPrompt(
|
|
86
|
-
repoUrl: string,
|
|
87
|
-
files: ImportantFile[],
|
|
88
|
-
): string {
|
|
89
|
-
const relevant = files.filter((f) =>
|
|
90
|
-
[
|
|
91
|
-
"package.json",
|
|
92
|
-
"bun.lockb",
|
|
93
|
-
"yarn.lock",
|
|
94
|
-
"pnpm-lock.yaml",
|
|
95
|
-
"package-lock.json",
|
|
96
|
-
"tsconfig.json",
|
|
97
|
-
".nvmrc",
|
|
98
|
-
".node-version",
|
|
99
|
-
].includes(path.basename(f.path)),
|
|
100
|
-
);
|
|
101
|
-
|
|
102
|
-
if (relevant.length === 0) return "";
|
|
103
|
-
|
|
104
|
-
const fileList = relevant
|
|
105
|
-
.map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 2000)}\n\`\`\``)
|
|
106
|
-
.join("\n\n");
|
|
107
|
-
|
|
108
|
-
return `You are analyzing a repository's tooling configuration.
|
|
109
|
-
Repository: ${repoUrl}
|
|
110
|
-
|
|
111
|
-
${fileList}
|
|
112
|
-
|
|
113
|
-
Extract only tooling information. Respond ONLY with a JSON object:
|
|
114
|
-
{
|
|
115
|
-
"tooling": {
|
|
116
|
-
"packageManager": "bun | npm | yarn | pnpm",
|
|
117
|
-
"language": "TypeScript | JavaScript | ...",
|
|
118
|
-
"runtime": "Node.js | Bun | Deno | ...",
|
|
119
|
-
"bundler": "tsup | esbuild | vite | webpack | ...",
|
|
120
|
-
"framework": "React | Ink | Next.js | ...",
|
|
121
|
-
"testRunner": "vitest | jest | ...",
|
|
122
|
-
"linter": "eslint | biome | ...",
|
|
123
|
-
"formatter": "prettier | biome | ..."
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
Only include keys where you found actual evidence. No markdown, no explanation.`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function parseStringArray(text: string): string[] {
|
|
130
|
-
const cleaned = text.replace(/```json|```/g, "").trim();
|
|
131
|
-
const match = cleaned.match(/\[[\s\S]*\]/);
|
|
132
|
-
if (!match) return [];
|
|
133
|
-
try {
|
|
134
|
-
return JSON.parse(match[0]) as string[];
|
|
135
|
-
} catch {
|
|
136
|
-
return [];
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function parseResult(text: string): AnalysisResult {
|
|
141
|
-
const cleaned = text.replace(/```json|```/g, "").trim();
|
|
142
|
-
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
|
|
143
|
-
if (!jsonMatch) throw new Error(`No JSON found in response:\n${cleaned}`);
|
|
144
|
-
|
|
145
|
-
const parsed = JSON.parse(jsonMatch[0]) as Partial<AnalysisResult>;
|
|
146
|
-
|
|
147
|
-
return {
|
|
148
|
-
overview: parsed.overview ?? "No overview provided",
|
|
149
|
-
importantFolders: parsed.importantFolders ?? [],
|
|
150
|
-
tooling: parsed.tooling ?? {},
|
|
151
|
-
keyFiles: parsed.keyFiles ?? [],
|
|
152
|
-
patterns: parsed.patterns ?? [],
|
|
153
|
-
architecture: parsed.architecture ?? "",
|
|
154
|
-
suggestions: parsed.suggestions ?? [],
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function parseToolingPatch(text: string): Partial<AnalysisResult> | null {
|
|
159
|
-
try {
|
|
160
|
-
const cleaned = text.replace(/```json|```/g, "").trim();
|
|
161
|
-
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
162
|
-
if (!match) return null;
|
|
163
|
-
return JSON.parse(match[0]) as Partial<AnalysisResult>;
|
|
164
|
-
} catch {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function checkOllamaInstalled(): Promise<boolean> {
|
|
170
|
-
return new Promise((resolve) => {
|
|
171
|
-
exec("ollama --version", (err) => resolve(!err));
|
|
172
|
-
});
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function getOllamaModels(): Promise<string[]> {
|
|
176
|
-
return new Promise((resolve) => {
|
|
177
|
-
exec("ollama list", (err, stdout) => {
|
|
178
|
-
if (err) return resolve([]);
|
|
179
|
-
const models = stdout
|
|
180
|
-
.trim()
|
|
181
|
-
.split("\n")
|
|
182
|
-
.slice(1)
|
|
183
|
-
.map((line) => line.split(/\s+/)[0] ?? "")
|
|
184
|
-
.filter(Boolean);
|
|
185
|
-
resolve(models);
|
|
186
|
-
});
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function callModel(provider: Provider, prompt: string): Promise<string> {
|
|
191
|
-
switch (provider.type) {
|
|
192
|
-
case "anthropic": {
|
|
193
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
194
|
-
method: "POST",
|
|
195
|
-
headers: {
|
|
196
|
-
"Content-Type": "application/json",
|
|
197
|
-
"x-api-key": provider.apiKey ?? "",
|
|
198
|
-
"anthropic-version": "2023-06-01",
|
|
199
|
-
},
|
|
200
|
-
body: JSON.stringify({
|
|
201
|
-
model: provider.model,
|
|
202
|
-
max_tokens: 2048,
|
|
203
|
-
messages: [{ role: "user", content: prompt }],
|
|
204
|
-
}),
|
|
205
|
-
});
|
|
206
|
-
if (!response.ok)
|
|
207
|
-
throw new Error(`Anthropic API error: ${response.statusText}`);
|
|
208
|
-
const data = (await response.json()) as any;
|
|
209
|
-
return data.content
|
|
210
|
-
.filter((b: { type: string }) => b.type === "text")
|
|
211
|
-
.map((b: { text: string }) => b.text)
|
|
212
|
-
.join("");
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
case "gemini": {
|
|
216
|
-
const response = await fetch(
|
|
217
|
-
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${provider.apiKey ?? ""}`,
|
|
218
|
-
{
|
|
219
|
-
method: "POST",
|
|
220
|
-
headers: { "Content-Type": "application/json" },
|
|
221
|
-
body: JSON.stringify({
|
|
222
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
223
|
-
}),
|
|
224
|
-
},
|
|
225
|
-
);
|
|
226
|
-
if (!response.ok)
|
|
227
|
-
throw new Error(`Gemini API error: ${response.statusText}`);
|
|
228
|
-
const data = (await response.json()) as any;
|
|
229
|
-
return data.candidates?.[0]?.content?.parts?.[0]?.text ?? "";
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
case "ollama": {
|
|
233
|
-
const baseUrl = provider.baseUrl ?? "http://localhost:11434";
|
|
234
|
-
const response = await fetch(`${baseUrl}/api/generate`, {
|
|
235
|
-
method: "POST",
|
|
236
|
-
headers: { "Content-Type": "application/json" },
|
|
237
|
-
body: JSON.stringify({
|
|
238
|
-
model: provider.model,
|
|
239
|
-
prompt,
|
|
240
|
-
stream: false,
|
|
241
|
-
}),
|
|
242
|
-
});
|
|
243
|
-
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
|
|
244
|
-
const data = (await response.json()) as any;
|
|
245
|
-
return data.response ?? "";
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
case "openai":
|
|
249
|
-
case "custom": {
|
|
250
|
-
const baseUrl = provider.baseUrl ?? "https://api.openai.com/v1";
|
|
251
|
-
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
252
|
-
method: "POST",
|
|
253
|
-
headers: {
|
|
254
|
-
"Content-Type": "application/json",
|
|
255
|
-
Authorization: `Bearer ${provider.apiKey ?? ""}`,
|
|
256
|
-
},
|
|
257
|
-
body: JSON.stringify({
|
|
258
|
-
model: provider.model,
|
|
259
|
-
messages: [{ role: "user", content: prompt }],
|
|
260
|
-
}),
|
|
261
|
-
});
|
|
262
|
-
if (!response.ok)
|
|
263
|
-
throw new Error(`OpenAI-compat API error: ${response.statusText}`);
|
|
264
|
-
const data = (await response.json()) as any;
|
|
265
|
-
return data.choices?.[0]?.message?.content ?? "";
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
default:
|
|
269
|
-
throw new Error(`Unknown provider type`);
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
export async function requestFileList(
|
|
274
|
-
repoUrl: string,
|
|
275
|
-
repoPath: string,
|
|
276
|
-
fileTree: string[],
|
|
277
|
-
provider: Provider,
|
|
278
|
-
): Promise<ImportantFile[]> {
|
|
279
|
-
const prompt = buildFileListPrompt(repoUrl, fileTree);
|
|
280
|
-
const text = await callModel(provider, prompt);
|
|
281
|
-
const requestedPaths = parseStringArray(text);
|
|
282
|
-
|
|
283
|
-
const files: ImportantFile[] = [];
|
|
284
|
-
for (const filePath of requestedPaths) {
|
|
285
|
-
const fullPath = path.join(repoPath, filePath);
|
|
286
|
-
if (existsSync(fullPath)) {
|
|
287
|
-
try {
|
|
288
|
-
const content = readFileSync(fullPath, "utf-8");
|
|
289
|
-
files.push({ path: filePath, content });
|
|
290
|
-
} catch {}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
return files;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
export async function extractToolingPatch(
|
|
297
|
-
repoUrl: string,
|
|
298
|
-
files: ImportantFile[],
|
|
299
|
-
provider: Provider,
|
|
300
|
-
): Promise<Partial<AnalysisResult> | null> {
|
|
301
|
-
const prompt = buildToolingPatchPrompt(repoUrl, files);
|
|
302
|
-
if (!prompt) return null;
|
|
303
|
-
try {
|
|
304
|
-
const text = await callModel(provider, prompt);
|
|
305
|
-
return parseToolingPatch(text);
|
|
306
|
-
} catch {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export async function analyzeRepo(
|
|
312
|
-
repoUrl: string,
|
|
313
|
-
files: ImportantFile[],
|
|
314
|
-
provider: Provider,
|
|
315
|
-
): Promise<AnalysisResult> {
|
|
316
|
-
const prompt = buildAnalysisPrompt(repoUrl, files);
|
|
317
|
-
const text = await callModel(provider, prompt);
|
|
318
|
-
return parseResult(text);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
export const callModelRaw = callModel;
|
package/src/utils/chat.ts
DELETED
|
@@ -1,326 +0,0 @@
|
|
|
1
|
-
export {
|
|
2
|
-
walkDir,
|
|
3
|
-
applyPatches,
|
|
4
|
-
readFile,
|
|
5
|
-
readFolder,
|
|
6
|
-
grepFiles,
|
|
7
|
-
writeFile,
|
|
8
|
-
deleteFile,
|
|
9
|
-
deleteFolder,
|
|
10
|
-
} from "../tools/files";
|
|
11
|
-
export { runShell, readClipboard, openUrl } from "../tools/shell";
|
|
12
|
-
export { fetchUrl, searchWeb } from "../tools/web";
|
|
13
|
-
export { generatePdf } from "../tools/pdf";
|
|
14
|
-
export { buildSystemPrompt, FEW_SHOT_MESSAGES } from "../prompts";
|
|
15
|
-
|
|
16
|
-
import type { Message } from "../types/chat";
|
|
17
|
-
import type { Provider } from "../types/config";
|
|
18
|
-
import { FEW_SHOT_MESSAGES } from "../prompts";
|
|
19
|
-
import { registry } from "../utils/tools/registry";
|
|
20
|
-
import type { FilePatch } from "../components/repo/DiffViewer";
|
|
21
|
-
|
|
22
|
-
export type ChatResult = {
|
|
23
|
-
text: string;
|
|
24
|
-
truncated: boolean;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type ParsedResponse =
|
|
28
|
-
| { kind: "text"; content: string; remainder?: string }
|
|
29
|
-
| {
|
|
30
|
-
kind: "changes";
|
|
31
|
-
content: string;
|
|
32
|
-
patches: FilePatch[];
|
|
33
|
-
remainder?: string;
|
|
34
|
-
}
|
|
35
|
-
| { kind: "clone"; content: string; repoUrl: string; remainder?: string }
|
|
36
|
-
| {
|
|
37
|
-
kind: "tool";
|
|
38
|
-
toolName: string;
|
|
39
|
-
input: unknown;
|
|
40
|
-
rawInput: string;
|
|
41
|
-
content: string;
|
|
42
|
-
remainder?: string;
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
const MAX_HISTORY = 30;
|
|
46
|
-
|
|
47
|
-
function buildFewShotMessages(): { role: string; content: string }[] {
|
|
48
|
-
const messages = [...FEW_SHOT_MESSAGES];
|
|
49
|
-
for (const tool of registry.all()) {
|
|
50
|
-
if (!tool.fewShots?.length) continue;
|
|
51
|
-
for (const shot of tool.fewShots) {
|
|
52
|
-
messages.push({ role: "user", content: shot.user });
|
|
53
|
-
messages.push({ role: "assistant", content: shot.assistant });
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return messages;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function parseResponse(text: string): ParsedResponse {
|
|
60
|
-
const scanText = text.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
|
|
61
|
-
|
|
62
|
-
type Candidate = {
|
|
63
|
-
index: number;
|
|
64
|
-
toolName: string;
|
|
65
|
-
match: RegExpExecArray;
|
|
66
|
-
};
|
|
67
|
-
const candidates: Candidate[] = [];
|
|
68
|
-
|
|
69
|
-
for (const toolName of registry.names()) {
|
|
70
|
-
const escaped = toolName.replace(/[-]/g, "\\-");
|
|
71
|
-
|
|
72
|
-
const xmlRe = new RegExp(`<${escaped}>([\\s\\S]*?)<\\/${escaped}>`, "g");
|
|
73
|
-
xmlRe.lastIndex = 0;
|
|
74
|
-
const xmlM = xmlRe.exec(scanText);
|
|
75
|
-
if (xmlM) {
|
|
76
|
-
const orig = new RegExp(xmlRe.source);
|
|
77
|
-
const origM = orig.exec(text.slice(xmlM.index));
|
|
78
|
-
if (origM) {
|
|
79
|
-
candidates.push({
|
|
80
|
-
index: xmlM.index,
|
|
81
|
-
toolName,
|
|
82
|
-
match: Object.assign(
|
|
83
|
-
[
|
|
84
|
-
text.slice(xmlM.index, xmlM.index + origM[0].length),
|
|
85
|
-
origM[1],
|
|
86
|
-
] as unknown as RegExpExecArray,
|
|
87
|
-
{ index: xmlM.index, input: text, groups: undefined },
|
|
88
|
-
),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const fencedRe = new RegExp(
|
|
94
|
-
`\`\`\`${escaped}\\r?\\n([\\s\\S]*?)\\r?\\n\`\`\``,
|
|
95
|
-
"g",
|
|
96
|
-
);
|
|
97
|
-
fencedRe.lastIndex = 0;
|
|
98
|
-
const fencedM = fencedRe.exec(scanText);
|
|
99
|
-
if (fencedM) {
|
|
100
|
-
const orig = new RegExp(fencedRe.source);
|
|
101
|
-
const origM = orig.exec(text.slice(fencedM.index));
|
|
102
|
-
if (origM) {
|
|
103
|
-
candidates.push({
|
|
104
|
-
index: fencedM.index,
|
|
105
|
-
toolName,
|
|
106
|
-
match: Object.assign(
|
|
107
|
-
[
|
|
108
|
-
text.slice(fencedM.index, fencedM.index + origM[0].length),
|
|
109
|
-
origM[1],
|
|
110
|
-
] as unknown as RegExpExecArray,
|
|
111
|
-
{ index: fencedM.index, input: text, groups: undefined },
|
|
112
|
-
),
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
if (candidates.length === 0) return { kind: "text", content: text.trim() };
|
|
119
|
-
|
|
120
|
-
candidates.sort((a, b) => a.index - b.index);
|
|
121
|
-
const { toolName, match } = candidates[0]!;
|
|
122
|
-
|
|
123
|
-
const before = text.slice(0, match.index).trim();
|
|
124
|
-
const body = (match[1] ?? "").trim();
|
|
125
|
-
const afterMatch = text.slice(match.index + match[0].length).trim();
|
|
126
|
-
const remainder = afterMatch.length > 0 ? afterMatch : undefined;
|
|
127
|
-
|
|
128
|
-
if (toolName === "changes") {
|
|
129
|
-
try {
|
|
130
|
-
const parsed = JSON.parse(body) as {
|
|
131
|
-
summary: string;
|
|
132
|
-
patches: FilePatch[];
|
|
133
|
-
};
|
|
134
|
-
const display = [before, parsed.summary].filter(Boolean).join("\n\n");
|
|
135
|
-
return {
|
|
136
|
-
kind: "changes",
|
|
137
|
-
content: display,
|
|
138
|
-
patches: parsed.patches,
|
|
139
|
-
remainder,
|
|
140
|
-
};
|
|
141
|
-
} catch (e) {
|
|
142
|
-
console.error("[parseResponse] failed to parse changes JSON:", e);
|
|
143
|
-
console.error("[parseResponse] body was:", body.slice(0, 200));
|
|
144
|
-
return { kind: "text", content: text.trim() };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (toolName === "clone") {
|
|
149
|
-
return {
|
|
150
|
-
kind: "clone",
|
|
151
|
-
content: before,
|
|
152
|
-
repoUrl: body.replace(/^<|>$/g, "").trim(),
|
|
153
|
-
remainder,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const tool = registry.get(toolName);
|
|
158
|
-
if (!tool) {
|
|
159
|
-
console.error("[parseResponse] unknown tool:", toolName);
|
|
160
|
-
return { kind: "text", content: text.trim() };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const input = tool.parseInput(body);
|
|
164
|
-
if (input === null) {
|
|
165
|
-
console.error(
|
|
166
|
-
"[parseResponse] parseInput returned null for tool:",
|
|
167
|
-
toolName,
|
|
168
|
-
);
|
|
169
|
-
console.error("[parseResponse] body was:", body.slice(0, 200));
|
|
170
|
-
return { kind: "text", content: text.trim() };
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return {
|
|
174
|
-
kind: "tool",
|
|
175
|
-
toolName,
|
|
176
|
-
input,
|
|
177
|
-
rawInput: body,
|
|
178
|
-
content: before,
|
|
179
|
-
remainder,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function parseCloneTag(text: string): string | null {
|
|
184
|
-
const m = text.match(/<clone>([\s\S]*?)<\/clone>/);
|
|
185
|
-
return m ? m[1]!.trim() : null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function extractGithubUrl(text: string): string | null {
|
|
189
|
-
const match = text.match(/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+/);
|
|
190
|
-
return match ? match[0]! : null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export function toCloneUrl(url: string): string {
|
|
194
|
-
const clean = url.replace(/\/+$/, "");
|
|
195
|
-
return clean.endsWith(".git") ? clean : `${clean}.git`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function buildApiMessages(
|
|
199
|
-
messages: Message[],
|
|
200
|
-
): { role: string; content: string }[] {
|
|
201
|
-
const recent = messages.slice(-MAX_HISTORY);
|
|
202
|
-
|
|
203
|
-
return recent.map((m) => {
|
|
204
|
-
if (m.type === "tool") {
|
|
205
|
-
if (!m.approved) {
|
|
206
|
-
return {
|
|
207
|
-
role: "user",
|
|
208
|
-
content:
|
|
209
|
-
"The tool call was denied by the user. Please respond without using that tool.",
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
return {
|
|
213
|
-
role: "user",
|
|
214
|
-
content: `Here is the output from the ${m.toolName} of ${m.content}:\n\n${m.result}\n\nPlease continue your response based on this output.`,
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
return { role: m.role, content: m.content };
|
|
218
|
-
});
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export async function callChat(
|
|
222
|
-
provider: Provider,
|
|
223
|
-
systemPrompt: string,
|
|
224
|
-
messages: Message[],
|
|
225
|
-
abortSignal?: AbortSignal,
|
|
226
|
-
retries = 2,
|
|
227
|
-
): Promise<ChatResult> {
|
|
228
|
-
const apiMessages = [
|
|
229
|
-
...buildFewShotMessages(),
|
|
230
|
-
...buildApiMessages(messages),
|
|
231
|
-
];
|
|
232
|
-
|
|
233
|
-
let url: string;
|
|
234
|
-
let headers: Record<string, string>;
|
|
235
|
-
let body: Record<string, unknown>;
|
|
236
|
-
|
|
237
|
-
if (provider.type === "anthropic") {
|
|
238
|
-
url = "https://api.anthropic.com/v1/messages";
|
|
239
|
-
headers = {
|
|
240
|
-
"Content-Type": "application/json",
|
|
241
|
-
"x-api-key": provider.apiKey ?? "",
|
|
242
|
-
"anthropic-version": "2023-06-01",
|
|
243
|
-
};
|
|
244
|
-
body = {
|
|
245
|
-
model: provider.model,
|
|
246
|
-
max_tokens: 16384,
|
|
247
|
-
system: systemPrompt,
|
|
248
|
-
messages: apiMessages,
|
|
249
|
-
};
|
|
250
|
-
} else {
|
|
251
|
-
const base = provider.baseUrl ?? "https://api.openai.com/v1";
|
|
252
|
-
url = `${base}/chat/completions`;
|
|
253
|
-
headers = {
|
|
254
|
-
"Content-Type": "application/json",
|
|
255
|
-
Authorization: `Bearer ${provider.apiKey}`,
|
|
256
|
-
};
|
|
257
|
-
body = {
|
|
258
|
-
model: provider.model,
|
|
259
|
-
max_tokens: 16384,
|
|
260
|
-
messages: [{ role: "system", content: systemPrompt }, ...apiMessages],
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const controller = new AbortController();
|
|
265
|
-
const timer = setTimeout(() => controller.abort(), 60_000);
|
|
266
|
-
abortSignal?.addEventListener("abort", () => controller.abort());
|
|
267
|
-
|
|
268
|
-
try {
|
|
269
|
-
const res = await fetch(url, {
|
|
270
|
-
method: "POST",
|
|
271
|
-
headers,
|
|
272
|
-
body: JSON.stringify(body),
|
|
273
|
-
signal: controller.signal,
|
|
274
|
-
});
|
|
275
|
-
clearTimeout(timer);
|
|
276
|
-
|
|
277
|
-
if (!res.ok) {
|
|
278
|
-
const errText = await res.text();
|
|
279
|
-
if (res.status >= 500 && retries > 0) {
|
|
280
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
281
|
-
return callChat(
|
|
282
|
-
provider,
|
|
283
|
-
systemPrompt,
|
|
284
|
-
messages,
|
|
285
|
-
abortSignal,
|
|
286
|
-
retries - 1,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
throw new Error(`API error ${res.status}: ${errText}`);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
const data = (await res.json()) as Record<string, unknown>;
|
|
293
|
-
|
|
294
|
-
if (provider.type === "anthropic") {
|
|
295
|
-
const content = data.content as { type: string; text: string }[];
|
|
296
|
-
const text = content
|
|
297
|
-
.filter((b) => b.type === "text")
|
|
298
|
-
.map((b) => b.text)
|
|
299
|
-
.join("");
|
|
300
|
-
const truncated = (data as any).stop_reason === "max_tokens";
|
|
301
|
-
return { text, truncated };
|
|
302
|
-
} else {
|
|
303
|
-
const choices = data.choices as {
|
|
304
|
-
message: { content: string };
|
|
305
|
-
finish_reason?: string;
|
|
306
|
-
}[];
|
|
307
|
-
const text = choices[0]?.message.content ?? "";
|
|
308
|
-
const truncated = choices[0]?.finish_reason === "length";
|
|
309
|
-
return { text, truncated };
|
|
310
|
-
}
|
|
311
|
-
} catch (err) {
|
|
312
|
-
clearTimeout(timer);
|
|
313
|
-
if (err instanceof Error && err.name === "AbortError") throw err;
|
|
314
|
-
if (retries > 0) {
|
|
315
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
316
|
-
return callChat(
|
|
317
|
-
provider,
|
|
318
|
-
systemPrompt,
|
|
319
|
-
messages,
|
|
320
|
-
abortSignal,
|
|
321
|
-
retries - 1,
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
throw err;
|
|
325
|
-
}
|
|
326
|
-
}
|