@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.
Files changed (96) hide show
  1. package/dist/index.mjs +105368 -274002
  2. package/package.json +13 -19
  3. package/src/colors.ts +15 -15
  4. package/src/commands/chat.tsx +32 -23
  5. package/src/commands/provider.tsx +11 -238
  6. package/src/commands/repo.tsx +66 -120
  7. package/src/commands/timeline.tsx +11 -22
  8. package/src/components/ChatView.tsx +238 -0
  9. package/src/components/Message.tsx +46 -0
  10. package/src/components/ToolCall.tsx +67 -0
  11. package/src/components/chat/ChatView.tsx +550 -0
  12. package/src/components/chat/Message.tsx +152 -0
  13. package/src/components/chat/StatusBar.tsx +214 -0
  14. package/src/components/chat/TextArea.tsx +173 -176
  15. package/src/components/provider/ApiKeyStep.tsx +207 -199
  16. package/src/components/provider/ModelStep.tsx +90 -88
  17. package/src/components/provider/ProviderSetup.tsx +331 -0
  18. package/src/components/provider/ProviderTypeStep.tsx +53 -61
  19. package/src/components/repo/StepRow.tsx +68 -69
  20. package/src/components/timeline/TimelineView.tsx +840 -0
  21. package/src/components/toolcall-utils.ts +103 -0
  22. package/src/components/watch/RunView.tsx +497 -0
  23. package/src/hooks/useChatInput.ts +49 -0
  24. package/src/hooks/useCommandHandler.ts +117 -0
  25. package/src/index.tsx +386 -139
  26. package/src/utils/git.ts +149 -155
  27. package/src/utils/repo.ts +62 -69
  28. package/src/utils/thinking.tsx +64 -0
  29. package/src/utils/watch.ts +165 -307
  30. package/tests/message.test.ts +38 -0
  31. package/tests/toolcall-utils.test.ts +111 -0
  32. package/tsconfig.json +8 -24
  33. package/CLAUDE.md +0 -50
  34. package/LENS.md +0 -48
  35. package/LICENSE +0 -21
  36. package/README.md +0 -93
  37. package/addons/README.md +0 -55
  38. package/addons/clean-cache.js +0 -48
  39. package/addons/generate-readme.js +0 -67
  40. package/addons/git-stats.js +0 -29
  41. package/addons/run-tests.js +0 -127
  42. package/src/commands/commit.tsx +0 -668
  43. package/src/commands/review.tsx +0 -294
  44. package/src/commands/run.tsx +0 -56
  45. package/src/commands/task.tsx +0 -36
  46. package/src/components/chat/ChatMessage.tsx +0 -195
  47. package/src/components/chat/ChatOverlays.tsx +0 -399
  48. package/src/components/chat/ChatRunner.tsx +0 -517
  49. package/src/components/chat/hooks/useChat.ts +0 -631
  50. package/src/components/chat/hooks/useChatInput.ts +0 -79
  51. package/src/components/chat/hooks/useCommandHandlers.ts +0 -327
  52. package/src/components/provider/ProviderPicker.tsx +0 -76
  53. package/src/components/provider/RemoveProviderStep.tsx +0 -82
  54. package/src/components/repo/DiffViewer.tsx +0 -175
  55. package/src/components/repo/FileReviewer.tsx +0 -70
  56. package/src/components/repo/FileViewer.tsx +0 -60
  57. package/src/components/repo/IssueFixer.tsx +0 -666
  58. package/src/components/repo/LensFileMenu.tsx +0 -115
  59. package/src/components/repo/NoProviderPrompt.tsx +0 -28
  60. package/src/components/repo/PreviewRunner.tsx +0 -217
  61. package/src/components/repo/RepoAnalysis.tsx +0 -534
  62. package/src/components/task/TaskRunner.tsx +0 -396
  63. package/src/components/timeline/CommitDetail.tsx +0 -272
  64. package/src/components/timeline/CommitList.tsx +0 -162
  65. package/src/components/timeline/TimelineChat.tsx +0 -166
  66. package/src/components/timeline/TimelineRunner.tsx +0 -1285
  67. package/src/components/watch/RunRunner.tsx +0 -929
  68. package/src/prompts/fewshot.ts +0 -252
  69. package/src/prompts/index.ts +0 -2
  70. package/src/prompts/system.ts +0 -285
  71. package/src/tools/chart.ts +0 -202
  72. package/src/tools/convert-image.ts +0 -312
  73. package/src/tools/files.ts +0 -253
  74. package/src/tools/git.ts +0 -603
  75. package/src/tools/index.ts +0 -17
  76. package/src/tools/pdf.ts +0 -164
  77. package/src/tools/shell.ts +0 -96
  78. package/src/tools/view-image.ts +0 -335
  79. package/src/tools/web.ts +0 -212
  80. package/src/types/chat.ts +0 -86
  81. package/src/types/config.ts +0 -20
  82. package/src/types/repo.ts +0 -54
  83. package/src/utils/addons/loadAddons.ts +0 -34
  84. package/src/utils/ai.ts +0 -321
  85. package/src/utils/chat.ts +0 -326
  86. package/src/utils/chatHistory.ts +0 -121
  87. package/src/utils/config.ts +0 -61
  88. package/src/utils/files.ts +0 -105
  89. package/src/utils/intentClassifier.ts +0 -58
  90. package/src/utils/lensfile.ts +0 -142
  91. package/src/utils/llm.ts +0 -81
  92. package/src/utils/memory.ts +0 -209
  93. package/src/utils/preview.ts +0 -119
  94. package/src/utils/stats.ts +0 -174
  95. package/src/utils/tools/builtins.ts +0 -377
  96. 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
- }