@ridit/lens 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LENS.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +0 -0
  4. package/dist/index.js +49363 -0
  5. package/package.json +38 -0
  6. package/src/colors.ts +1 -0
  7. package/src/commands/chat.tsx +23 -0
  8. package/src/commands/provider.tsx +224 -0
  9. package/src/commands/repo.tsx +120 -0
  10. package/src/commands/review.tsx +294 -0
  11. package/src/commands/task.tsx +36 -0
  12. package/src/commands/timeline.tsx +22 -0
  13. package/src/components/chat/ChatMessage.tsx +176 -0
  14. package/src/components/chat/ChatOverlays.tsx +329 -0
  15. package/src/components/chat/ChatRunner.tsx +732 -0
  16. package/src/components/provider/ApiKeyStep.tsx +243 -0
  17. package/src/components/provider/ModelStep.tsx +73 -0
  18. package/src/components/provider/ProviderTypeStep.tsx +54 -0
  19. package/src/components/provider/RemoveProviderStep.tsx +83 -0
  20. package/src/components/repo/DiffViewer.tsx +175 -0
  21. package/src/components/repo/FileReviewer.tsx +70 -0
  22. package/src/components/repo/FileViewer.tsx +60 -0
  23. package/src/components/repo/IssueFixer.tsx +666 -0
  24. package/src/components/repo/LensFileMenu.tsx +122 -0
  25. package/src/components/repo/NoProviderPrompt.tsx +28 -0
  26. package/src/components/repo/PreviewRunner.tsx +217 -0
  27. package/src/components/repo/ProviderPicker.tsx +76 -0
  28. package/src/components/repo/RepoAnalysis.tsx +343 -0
  29. package/src/components/repo/StepRow.tsx +69 -0
  30. package/src/components/task/TaskRunner.tsx +396 -0
  31. package/src/components/timeline/CommitDetail.tsx +274 -0
  32. package/src/components/timeline/CommitList.tsx +174 -0
  33. package/src/components/timeline/TimelineChat.tsx +167 -0
  34. package/src/components/timeline/TimelineRunner.tsx +1209 -0
  35. package/src/index.tsx +60 -0
  36. package/src/types/chat.ts +69 -0
  37. package/src/types/config.ts +20 -0
  38. package/src/types/repo.ts +42 -0
  39. package/src/utils/ai.ts +233 -0
  40. package/src/utils/chat.ts +833 -0
  41. package/src/utils/config.ts +61 -0
  42. package/src/utils/files.ts +104 -0
  43. package/src/utils/git.ts +155 -0
  44. package/src/utils/history.ts +86 -0
  45. package/src/utils/lensfile.ts +77 -0
  46. package/src/utils/llm.ts +81 -0
  47. package/src/utils/preview.ts +119 -0
  48. package/src/utils/repo.ts +69 -0
  49. package/src/utils/stats.ts +174 -0
  50. package/src/utils/thinking.tsx +191 -0
  51. package/tsconfig.json +24 -0
@@ -0,0 +1,833 @@
1
+ import type { ImportantFile } from "../types/repo";
2
+ import type { Provider } from "../types/config";
3
+ import type { FilePatch } from "../components/repo/DiffViewer";
4
+ import type { Message } from "../types/chat";
5
+
6
+ import path from "path";
7
+ import { execSync } from "child_process";
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readdirSync,
12
+ readFileSync,
13
+ statSync,
14
+ writeFileSync,
15
+ } from "fs";
16
+
17
+ // ── Prompt builder ────────────────────────────────────────────────────────────
18
+
19
+ export function buildSystemPrompt(
20
+ files: ImportantFile[],
21
+ historySummary = "",
22
+ ): string {
23
+ const fileList = files
24
+ .map((f) => `### ${f.path}\n\`\`\`\n${f.content.slice(0, 2000)}\n\`\`\``)
25
+ .join("\n\n");
26
+
27
+ return `You are an expert software engineer assistant with access to the user's codebase and six tools.
28
+
29
+ ## TOOLS
30
+
31
+ You have exactly six tools. To use a tool you MUST wrap it in the exact XML tags shown below — no other format will work.
32
+
33
+ ### 1. fetch — load a URL
34
+ <fetch>https://example.com</fetch>
35
+
36
+ ### 2. shell — run a terminal command
37
+ <shell>node -v</shell>
38
+
39
+ ### 3. read-file — read a file from the repo
40
+ <read-file>src/foo.ts</read-file>
41
+
42
+ ### 4. write-file — create or overwrite a file
43
+ <write-file>
44
+ {"path": "data/output.csv", "content": "col1,col2\nval1,val2"}
45
+ </write-file>
46
+
47
+ ### 5. search — search the internet for anything you are unsure about
48
+ <search>how to use React useEffect cleanup function</search>
49
+
50
+ ### 6. clone — clone a GitHub repo so you can explore and discuss it
51
+ <clone>https://github.com/owner/repo</clone>
52
+
53
+ ### 7. changes — propose code edits (shown as a diff for user approval)
54
+ <changes>
55
+ {"summary": "what changed and why", "patches": [{"path": "src/foo.ts", "content": "COMPLETE file content", "isNew": false}]}
56
+ </changes>
57
+
58
+ ## RULES
59
+
60
+ 1. When you need to use a tool, output ONLY the XML tag — nothing before or after it in that response
61
+ 2. ONE tool per response — emit the tag, then stop completely
62
+ 3. After the user approves and you get the result, continue your analysis in the next response
63
+ 4. NEVER print a URL, command, filename, or JSON blob as plain text when you should be using a tool
64
+ 5. NEVER say "I'll fetch" / "run this command" / "here's the write-file" — just emit the tag
65
+ 6. NEVER use shell to run git clone — always use the clone tag instead
66
+ 7. write-file content field must be the COMPLETE file content, never empty or placeholder
67
+ 8. After a write-file succeeds, do NOT repeat it — trust the result and move on
68
+ 9. After a write-file succeeds, use read-file to verify the content before telling the user it is done
69
+ 10. NEVER apologize and redo a tool call you already made — if write-file or shell ran and returned a result, it worked, do not run it again
70
+ 11. NEVER say "I made a mistake" and repeat the same tool — one attempt is enough, trust the output
71
+ 12. NEVER second-guess yourself mid-response — commit to your answer
72
+ 13. Every shell command runs from the repo root — \`cd\` has NO persistent effect. NEVER use \`cd\` alone. Use full paths or combine with && e.g. \`cd list && bun run index.ts\`
73
+ 14. write-file paths are relative to the repo root — if creating files in a subfolder write the full relative path e.g. \`list/src/index.tsx\` NOT \`src/index.tsx\`
74
+ 15. When scaffolding a new project in a subfolder, ALL write-file paths must start with that subfolder name e.g. \`list/package.json\`, \`list/src/index.tsx\`
75
+ 16. For JSX/TSX files always use \`.tsx\` extension and include \`/** @jsxImportSource react */\` or ensure tsconfig has jsx set — bun needs this to parse JSX
76
+
77
+ ## SCAFFOLDING A NEW PROJECT (follow this exactly)
78
+
79
+ When the user asks to create a new CLI/app in a subfolder (e.g. "make a todo app called list"):
80
+ 1. Create all files first using write-file with paths like \`list/package.json\`, \`list/src/index.tsx\`
81
+ 2. Then run \`cd list && bun install\` (or npm/pnpm) in one shell command
82
+ 3. Then run the project with \`cd list && bun run index.ts\` or whatever the entry point is
83
+ 4. NEVER run \`bun init\` — it is interactive and will hang. Create package.json manually with write-file instead
84
+ 5. TSX files need either tsconfig.json with \`"jsx": "react-jsx"\` or \`/** @jsxImportSource react */\` at the top
85
+
86
+ ## FETCH → WRITE FLOW (follow this exactly when saving fetched data)
87
+
88
+ 1. fetch the URL
89
+ 2. Analyze the result — count the rows, identify columns, check completeness
90
+ 3. Tell the user what you found: "Found X rows with columns: A, B, C. Writing now."
91
+ 4. emit write-file with correctly structured, complete content
92
+ 5. After write-file confirms success, emit read-file to verify
93
+ 6. Only after read-file confirms content is correct, tell the user it is done
94
+
95
+ ## WHEN TO USE TOOLS
96
+
97
+ - User shares any URL → fetch it immediately
98
+ - User asks to run anything → shell it immediately
99
+ - User asks to read a file → read-file it immediately
100
+ - User asks to save/create/write a file → write-file it immediately, then read-file to verify
101
+ - User shares a GitHub URL and wants to clone/explore/discuss it → use clone immediately, NEVER use shell git clone
102
+ - After clone succeeds, you will see context about the clone in the conversation. Wait for the user to ask a specific question before using any tools. Do NOT auto-read files, do NOT emit any tool tags until the user asks.
103
+ - You are unsure about an API, library, error, concept, or piece of code → search it immediately
104
+ - User asks about something recent or that you might not know → search it immediately
105
+ - You are about to say "I'm not sure" or "I don't know" → search instead of guessing
106
+
107
+ ## CODEBASE
108
+
109
+ ${fileList.length > 0 ? fileList : "(no files indexed)"}
110
+
111
+ ${historySummary}`;
112
+ }
113
+
114
+ // ── Few-shot examples ─────────────────────────────────────────────────────────
115
+
116
+ export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
117
+ {
118
+ role: "user",
119
+ content: "fetch https://api.github.com/repos/microsoft/typescript",
120
+ },
121
+ {
122
+ role: "assistant",
123
+ content: "<fetch>https://api.github.com/repos/microsoft/typescript</fetch>",
124
+ },
125
+ {
126
+ role: "user",
127
+ content:
128
+ 'Here is the output from fetch of https://api.github.com/repos/microsoft/typescript:\n\n{"name":"TypeScript","stargazers_count":100000}\n\nPlease continue your response based on this output.',
129
+ },
130
+ {
131
+ role: "assistant",
132
+ content:
133
+ "Found 1 object with fields: name, stargazers_count. Writing to ts-info.json now.",
134
+ },
135
+ {
136
+ role: "user",
137
+ content: "ok go ahead",
138
+ },
139
+ {
140
+ role: "assistant",
141
+ content:
142
+ '<write-file>\n{"path": "ts-info.json", "content": "{\"name\":\"TypeScript\",\"stars\":100000}"}\n</write-file>',
143
+ },
144
+ {
145
+ role: "user",
146
+ content:
147
+ "Here is the output from write-file to ts-info.json:\n\nWritten: /repo/ts-info.json (1 lines, 44 bytes)\n\nPlease continue your response based on this output.",
148
+ },
149
+ {
150
+ role: "assistant",
151
+ content: "<read-file>ts-info.json</read-file>",
152
+ },
153
+ {
154
+ role: "user",
155
+ content:
156
+ 'Here is the output from read-file of ts-info.json:\n\nFile: ts-info.json (1 lines)\n\n{"name":"TypeScript","stars":100000}\n\nPlease continue your response based on this output.',
157
+ },
158
+ {
159
+ role: "assistant",
160
+ content: "Done — saved and verified `ts-info.json`. Data looks correct.",
161
+ },
162
+ {
163
+ role: "user",
164
+ content: "what node version am I on",
165
+ },
166
+ {
167
+ role: "assistant",
168
+ content: "<shell>node -v</shell>",
169
+ },
170
+ {
171
+ role: "user",
172
+ content:
173
+ "Here is the output from shell command `node -v`:\n\nv20.11.0\n\nPlease continue your response based on this output.",
174
+ },
175
+ {
176
+ role: "assistant",
177
+ content: "You're running Node.js v20.11.0.",
178
+ },
179
+ {
180
+ role: "user",
181
+ content: "clone https://github.com/facebook/react",
182
+ },
183
+ {
184
+ role: "assistant",
185
+ content: "<clone>https://github.com/facebook/react</clone>",
186
+ },
187
+ {
188
+ role: "user",
189
+ content:
190
+ "Cloned react to /tmp/react — 2847 files available. You can now read files from this repo using read-file with paths relative to /tmp/react.",
191
+ },
192
+ {
193
+ role: "assistant",
194
+ content:
195
+ "Cloned! The React repo has 2847 files. I can read source files, explain how it works, or suggest improvements — just ask.",
196
+ },
197
+ {
198
+ role: "user",
199
+ content: "what does the ?? operator do in typescript",
200
+ },
201
+ {
202
+ role: "assistant",
203
+ content: "<search>nullish coalescing operator ?? TypeScript</search>",
204
+ },
205
+ {
206
+ role: "user",
207
+ content:
208
+ 'Here is the output from web search for "nullish coalescing operator ?? TypeScript":\n\nAnswer: The ?? operator returns the right-hand side when the left-hand side is null or undefined.\n\nPlease continue your response based on this output.',
209
+ },
210
+ {
211
+ role: "assistant",
212
+ content:
213
+ "The `??` operator is the nullish coalescing operator. It returns the right side only when the left side is `null` or `undefined`.",
214
+ },
215
+ ];
216
+
217
+ // ── Response parser ───────────────────────────────────────────────────────────
218
+
219
+ export type ParsedResponse =
220
+ | { kind: "text"; content: string }
221
+ | { kind: "changes"; content: string; patches: FilePatch[] }
222
+ | { kind: "shell"; content: string; command: string }
223
+ | { kind: "fetch"; content: string; url: string }
224
+ | { kind: "read-file"; content: string; filePath: string }
225
+ | {
226
+ kind: "write-file";
227
+ content: string;
228
+ filePath: string;
229
+ fileContent: string;
230
+ }
231
+ | { kind: "search"; content: string; query: string }
232
+ | { kind: "clone"; content: string; repoUrl: string };
233
+
234
+ export function parseResponse(text: string): ParsedResponse {
235
+ type Candidate = {
236
+ index: number;
237
+ kind:
238
+ | "changes"
239
+ | "shell"
240
+ | "fetch"
241
+ | "read-file"
242
+ | "write-file"
243
+ | "search"
244
+ | "clone";
245
+ match: RegExpExecArray;
246
+ };
247
+ const candidates: Candidate[] = [];
248
+
249
+ const patterns: { kind: Candidate["kind"]; re: RegExp }[] = [
250
+ { kind: "fetch", re: /<fetch>([\s\S]*?)<\/fetch>/g },
251
+ { kind: "shell", re: /<shell>([\s\S]*?)<\/shell>/g },
252
+ { kind: "read-file", re: /<read-file>([\s\S]*?)<\/read-file>/g },
253
+ { kind: "write-file", re: /<write-file>([\s\S]*?)<\/write-file>/g },
254
+ { kind: "search", re: /<search>([\s\S]*?)<\/search>/g },
255
+ { kind: "clone", re: /<clone>([\s\S]*?)<\/clone>/g },
256
+ { kind: "changes", re: /<changes>([\s\S]*?)<\/changes>/g },
257
+ { kind: "fetch", re: /```fetch\r?\n([\s\S]*?)\r?\n```/g },
258
+ { kind: "shell", re: /```shell\r?\n([\s\S]*?)\r?\n```/g },
259
+ { kind: "read-file", re: /```read-file\r?\n([\s\S]*?)\r?\n```/g },
260
+ { kind: "write-file", re: /```write-file\r?\n([\s\S]*?)\r?\n```/g },
261
+ { kind: "search", re: /```search\r?\n([\s\S]*?)\r?\n```/g },
262
+ { kind: "changes", re: /```changes\r?\n([\s\S]*?)\r?\n```/g },
263
+ ];
264
+
265
+ for (const { kind, re } of patterns) {
266
+ re.lastIndex = 0;
267
+ const m = re.exec(text);
268
+ if (m) candidates.push({ index: m.index, kind, match: m });
269
+ }
270
+
271
+ if (candidates.length === 0) return { kind: "text", content: text.trim() };
272
+
273
+ candidates.sort((a, b) => a.index - b.index);
274
+ const { kind, match } = candidates[0]!;
275
+ const before = text.slice(0, match.index).trim();
276
+ const body = match[1]!.trim();
277
+
278
+ if (kind === "changes") {
279
+ try {
280
+ const parsed = JSON.parse(body) as {
281
+ summary: string;
282
+ patches: FilePatch[];
283
+ };
284
+ const display = [before, parsed.summary].filter(Boolean).join("\n\n");
285
+ return { kind: "changes", content: display, patches: parsed.patches };
286
+ } catch {
287
+ // fall through
288
+ }
289
+ }
290
+
291
+ if (kind === "shell")
292
+ return { kind: "shell", content: before, command: body };
293
+
294
+ if (kind === "fetch") {
295
+ const url = body.replace(/^<|>$/g, "").trim();
296
+ return { kind: "fetch", content: before, url };
297
+ }
298
+
299
+ if (kind === "read-file")
300
+ return { kind: "read-file", content: before, filePath: body };
301
+
302
+ if (kind === "write-file") {
303
+ try {
304
+ const parsed = JSON.parse(body) as { path: string; content: string };
305
+ return {
306
+ kind: "write-file",
307
+ content: before,
308
+ filePath: parsed.path,
309
+ fileContent: parsed.content,
310
+ };
311
+ } catch {
312
+ // fall through
313
+ }
314
+ }
315
+
316
+ if (kind === "search")
317
+ return { kind: "search", content: before, query: body };
318
+
319
+ if (kind === "clone") {
320
+ const url = body.replace(/^<|>$/g, "").trim();
321
+ return { kind: "clone", content: before, repoUrl: url };
322
+ }
323
+
324
+ return { kind: "text", content: text.trim() };
325
+ }
326
+
327
+ // ── Clone tag helper ──────────────────────────────────────────────────────────
328
+
329
+ export function parseCloneTag(text: string): string | null {
330
+ const m = text.match(/<clone>([\s\S]*?)<\/clone>/);
331
+ return m ? m[1]!.trim() : null;
332
+ }
333
+
334
+ // ── GitHub URL detection ──────────────────────────────────────────────────────
335
+
336
+ export function extractGithubUrl(text: string): string | null {
337
+ const match = text.match(/https?:\/\/github\.com\/[\w.-]+\/[\w.-]+/);
338
+ return match ? match[0]! : null;
339
+ }
340
+
341
+ export function toCloneUrl(url: string): string {
342
+ const clean = url.replace(/\/+$/, "");
343
+ return clean.endsWith(".git") ? clean : `${clean}.git`;
344
+ }
345
+
346
+ // ── API call ──────────────────────────────────────────────────────────────────
347
+
348
+ function buildApiMessages(
349
+ messages: Message[],
350
+ ): { role: string; content: string }[] {
351
+ return messages.map((m) => {
352
+ if (m.type === "tool") {
353
+ if (!m.approved) {
354
+ return {
355
+ role: "user",
356
+ content:
357
+ "The tool call was denied by the user. Please respond without using that tool.",
358
+ };
359
+ }
360
+ const label =
361
+ m.toolName === "shell"
362
+ ? `shell command \`${m.content}\``
363
+ : m.toolName === "fetch"
364
+ ? `fetch of ${m.content}`
365
+ : m.toolName === "read-file"
366
+ ? `read-file of ${m.content}`
367
+ : m.toolName === "search"
368
+ ? `web search for "${m.content}"`
369
+ : `write-file to ${m.content}`;
370
+ return {
371
+ role: "user",
372
+ content: `Here is the output from the ${label}:\n\n${m.result}\n\nPlease continue your response based on this output.`,
373
+ };
374
+ }
375
+ return { role: m.role, content: m.content };
376
+ });
377
+ }
378
+
379
+ export async function callChat(
380
+ provider: Provider,
381
+ systemPrompt: string,
382
+ messages: Message[],
383
+ ): Promise<string> {
384
+ const apiMessages = [...FEW_SHOT_MESSAGES, ...buildApiMessages(messages)];
385
+
386
+ let url: string;
387
+ let headers: Record<string, string>;
388
+ let body: Record<string, unknown>;
389
+
390
+ if (provider.type === "anthropic") {
391
+ url = "https://api.anthropic.com/v1/messages";
392
+ headers = {
393
+ "Content-Type": "application/json",
394
+ "x-api-key": provider.apiKey ?? "",
395
+ "anthropic-version": "2023-06-01",
396
+ };
397
+ body = {
398
+ model: provider.model,
399
+ max_tokens: 4096,
400
+ system: systemPrompt,
401
+ messages: apiMessages,
402
+ };
403
+ } else {
404
+ const base = provider.baseUrl ?? "https://api.openai.com/v1";
405
+ url = `${base}/chat/completions`;
406
+ headers = {
407
+ "Content-Type": "application/json",
408
+ Authorization: `Bearer ${provider.apiKey}`,
409
+ };
410
+ body = {
411
+ model: provider.model,
412
+ max_tokens: 4096,
413
+ messages: [{ role: "system", content: systemPrompt }, ...apiMessages],
414
+ };
415
+ }
416
+
417
+ const controller = new AbortController();
418
+ const timer = setTimeout(() => controller.abort(), 60_000);
419
+
420
+ const res = await fetch(url, {
421
+ method: "POST",
422
+ headers,
423
+ body: JSON.stringify(body),
424
+ signal: controller.signal,
425
+ });
426
+ clearTimeout(timer);
427
+ if (!res.ok) throw new Error(`API error ${res.status}: ${await res.text()}`);
428
+ const data = (await res.json()) as Record<string, unknown>;
429
+
430
+ if (provider.type === "anthropic") {
431
+ const content = data.content as { type: string; text: string }[];
432
+ return content
433
+ .filter((b) => b.type === "text")
434
+ .map((b) => b.text)
435
+ .join("");
436
+ } else {
437
+ const choices = data.choices as { message: { content: string } }[];
438
+ return choices[0]?.message.content ?? "";
439
+ }
440
+ }
441
+
442
+ // ── Clipboard read ────────────────────────────────────────────────────────────
443
+
444
+ export function readClipboard(): string {
445
+ try {
446
+ const platform = process.platform;
447
+ if (platform === "win32") {
448
+ return execSync("powershell -noprofile -command Get-Clipboard", {
449
+ encoding: "utf-8",
450
+ timeout: 2000,
451
+ })
452
+ .replace(/\r\n/g, "\n")
453
+ .trimEnd();
454
+ }
455
+ if (platform === "darwin") {
456
+ return execSync("pbpaste", {
457
+ encoding: "utf-8",
458
+ timeout: 2000,
459
+ }).trimEnd();
460
+ }
461
+ for (const cmd of [
462
+ "xclip -selection clipboard -o",
463
+ "xsel --clipboard --output",
464
+ "wl-paste",
465
+ ]) {
466
+ try {
467
+ return execSync(cmd, { encoding: "utf-8", timeout: 2000 }).trimEnd();
468
+ } catch {
469
+ continue;
470
+ }
471
+ }
472
+ return "";
473
+ } catch {
474
+ return "";
475
+ }
476
+ }
477
+
478
+ // ── File system ───────────────────────────────────────────────────────────────
479
+
480
+ const SKIP_DIRS = new Set([
481
+ "node_modules",
482
+ ".git",
483
+ "dist",
484
+ "build",
485
+ ".next",
486
+ "out",
487
+ "coverage",
488
+ "__pycache__",
489
+ ".venv",
490
+ "venv",
491
+ ]);
492
+
493
+ export function walkDir(dir: string, base = dir): string[] {
494
+ const results: string[] = [];
495
+ let entries: string[];
496
+ try {
497
+ entries = readdirSync(dir, { encoding: "utf-8" });
498
+ } catch {
499
+ return results;
500
+ }
501
+ for (const entry of entries) {
502
+ if (SKIP_DIRS.has(entry)) continue;
503
+ const full = path.join(dir, entry);
504
+ const rel = path.relative(base, full).replace(/\\/g, "/");
505
+ let isDir = false;
506
+ try {
507
+ isDir = statSync(full).isDirectory();
508
+ } catch {
509
+ continue;
510
+ }
511
+ if (isDir) results.push(...walkDir(full, base));
512
+ else results.push(rel);
513
+ }
514
+ return results;
515
+ }
516
+
517
+ export function applyPatches(repoPath: string, patches: FilePatch[]): void {
518
+ for (const patch of patches) {
519
+ const fullPath = path.join(repoPath, patch.path);
520
+ const dir = path.dirname(fullPath);
521
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
522
+ writeFileSync(fullPath, patch.content, "utf-8");
523
+ }
524
+ }
525
+
526
+ // ── Tool execution ────────────────────────────────────────────────────────────
527
+
528
+ export async function runShell(command: string, cwd: string): Promise<string> {
529
+ return new Promise((resolve) => {
530
+ // Use spawn so we can stream stdout+stderr and impose no hard cap on
531
+ // long-running commands (pip install, python scripts, git commit, etc.)
532
+ // We still cap at 5 minutes to avoid hanging forever.
533
+ const { spawn } =
534
+ require("child_process") as typeof import("child_process");
535
+ const isWin = process.platform === "win32";
536
+ const shell = isWin ? "cmd.exe" : "/bin/sh";
537
+ const shellFlag = isWin ? "/c" : "-c";
538
+
539
+ const proc = spawn(shell, [shellFlag, command], {
540
+ cwd,
541
+ env: process.env,
542
+ stdio: ["ignore", "pipe", "pipe"],
543
+ });
544
+
545
+ const chunks: Buffer[] = [];
546
+ const errChunks: Buffer[] = [];
547
+
548
+ proc.stdout.on("data", (d: Buffer) => chunks.push(d));
549
+ proc.stderr.on("data", (d: Buffer) => errChunks.push(d));
550
+
551
+ const killTimer = setTimeout(
552
+ () => {
553
+ proc.kill();
554
+ resolve("(command timed out after 5 minutes)");
555
+ },
556
+ 5 * 60 * 1000,
557
+ );
558
+
559
+ proc.on("close", (code: number | null) => {
560
+ clearTimeout(killTimer);
561
+ const stdout = Buffer.concat(chunks).toString("utf-8").trim();
562
+ const stderr = Buffer.concat(errChunks).toString("utf-8").trim();
563
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
564
+ resolve(combined || (code === 0 ? "(no output)" : `exit code ${code}`));
565
+ });
566
+
567
+ proc.on("error", (err: Error) => {
568
+ clearTimeout(killTimer);
569
+ resolve(`Error: ${err.message}`);
570
+ });
571
+ });
572
+ }
573
+
574
+ // ── HTML table / list extractor ───────────────────────────────────────────────
575
+
576
+ function stripTags(html: string): string {
577
+ return html
578
+ .replace(/<[^>]+>/g, " ")
579
+ .replace(/&nbsp;/g, " ")
580
+ .replace(/&amp;/g, "&")
581
+ .replace(/&lt;/g, "<")
582
+ .replace(/&gt;/g, ">")
583
+ .replace(/&quot;/g, '"')
584
+ .replace(/&#\d+;/g, " ")
585
+ .replace(/\s+/g, " ")
586
+ .trim();
587
+ }
588
+
589
+ function extractTables(html: string): string {
590
+ const tables: string[] = [];
591
+ const tableRe = /<table[\s\S]*?<\/table>/gi;
592
+ let tMatch: RegExpExecArray | null;
593
+
594
+ while ((tMatch = tableRe.exec(html)) !== null) {
595
+ const tableHtml = tMatch[0]!;
596
+ const rows: string[][] = [];
597
+
598
+ const rowRe = /<tr[\s\S]*?<\/tr>/gi;
599
+ let rMatch: RegExpExecArray | null;
600
+ while ((rMatch = rowRe.exec(tableHtml)) !== null) {
601
+ const cells: string[] = [];
602
+ const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
603
+ let cMatch: RegExpExecArray | null;
604
+ while ((cMatch = cellRe.exec(rMatch[0]!)) !== null) {
605
+ cells.push(stripTags(cMatch[1] ?? ""));
606
+ }
607
+ if (cells.length > 0) rows.push(cells);
608
+ }
609
+
610
+ if (rows.length < 2) continue;
611
+
612
+ const cols = Math.max(...rows.map((r) => r.length));
613
+ const padded = rows.map((r) => {
614
+ while (r.length < cols) r.push("");
615
+ return r;
616
+ });
617
+ const widths = Array.from({ length: cols }, (_, ci) =>
618
+ Math.max(...padded.map((r) => (r[ci] ?? "").length), 3),
619
+ );
620
+ const fmt = (r: string[]) =>
621
+ r.map((c, ci) => c.padEnd(widths[ci] ?? 0)).join(" | ");
622
+ const header = fmt(padded[0]!);
623
+ const sep = widths.map((w) => "-".repeat(w)).join("-|-");
624
+ const body = padded.slice(1).map(fmt).join("\n");
625
+ tables.push(`${header}\n${sep}\n${body}`);
626
+ }
627
+
628
+ return tables.length > 0
629
+ ? `=== TABLES (${tables.length}) ===\n\n${tables.join("\n\n---\n\n")}`
630
+ : "";
631
+ }
632
+
633
+ function extractLists(html: string): string {
634
+ const lists: string[] = [];
635
+ const listRe = /<[ou]l[\s\S]*?<\/[ou]l>/gi;
636
+ let lMatch: RegExpExecArray | null;
637
+ while ((lMatch = listRe.exec(html)) !== null) {
638
+ const items: string[] = [];
639
+ const itemRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
640
+ let iMatch: RegExpExecArray | null;
641
+ while ((iMatch = itemRe.exec(lMatch[0]!)) !== null) {
642
+ const text = stripTags(iMatch[1] ?? "");
643
+ if (text.length > 2) items.push(`• ${text}`);
644
+ }
645
+ if (items.length > 1) lists.push(items.join("\n"));
646
+ }
647
+ return lists.length > 0
648
+ ? `=== LISTS ===\n\n${lists.slice(0, 5).join("\n\n")}`
649
+ : "";
650
+ }
651
+
652
+ export async function fetchUrl(url: string): Promise<string> {
653
+ const res = await fetch(url, {
654
+ headers: {
655
+ "User-Agent":
656
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
657
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
658
+ "Accept-Language": "en-US,en;q=0.5",
659
+ },
660
+ signal: AbortSignal.timeout(15000),
661
+ });
662
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
663
+
664
+ const contentType = res.headers.get("content-type") ?? "";
665
+ if (contentType.includes("application/json")) {
666
+ const json = await res.json();
667
+ return JSON.stringify(json, null, 2).slice(0, 8000);
668
+ }
669
+
670
+ const html = await res.text();
671
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
672
+ const title = titleMatch ? stripTags(titleMatch[1]!) : "No title";
673
+
674
+ const tables = extractTables(html);
675
+ const lists = extractLists(html);
676
+ const bodyText = stripTags(
677
+ html
678
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
679
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
680
+ .replace(/<nav[\s\S]*?<\/nav>/gi, "")
681
+ .replace(/<footer[\s\S]*?<\/footer>/gi, "")
682
+ .replace(/<header[\s\S]*?<\/header>/gi, ""),
683
+ )
684
+ .replace(/\s{3,}/g, "\n\n")
685
+ .slice(0, 3000);
686
+
687
+ const parts = [`PAGE: ${title}`, `URL: ${url}`];
688
+ if (tables) parts.push(tables);
689
+ if (lists) parts.push(lists);
690
+ parts.push(`=== TEXT ===\n${bodyText}`);
691
+
692
+ return parts.join("\n\n");
693
+ }
694
+
695
+ // ── Web search ────────────────────────────────────────────────────────────────
696
+
697
+ export async function searchWeb(query: string): Promise<string> {
698
+ const encoded = encodeURIComponent(query);
699
+
700
+ const ddgUrl = `https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1&skip_disambig=1`;
701
+ try {
702
+ const res = await fetch(ddgUrl, {
703
+ headers: { "User-Agent": "Lens/1.0" },
704
+ signal: AbortSignal.timeout(8000),
705
+ });
706
+ if (res.ok) {
707
+ const data = (await res.json()) as {
708
+ AbstractText?: string;
709
+ AbstractURL?: string;
710
+ RelatedTopics?: { Text?: string; FirstURL?: string }[];
711
+ Answer?: string;
712
+ Infobox?: { content?: { label: string; value: string }[] };
713
+ };
714
+
715
+ const parts: string[] = [`Search: ${query}`];
716
+ if (data.Answer) parts.push(`Answer: ${data.Answer}`);
717
+ if (data.AbstractText) {
718
+ parts.push(`Summary: ${data.AbstractText}`);
719
+ if (data.AbstractURL) parts.push(`Source: ${data.AbstractURL}`);
720
+ }
721
+ if (data.Infobox?.content?.length) {
722
+ const fields = data.Infobox.content
723
+ .slice(0, 8)
724
+ .map((f) => ` ${f.label}: ${f.value}`)
725
+ .join("\n");
726
+ parts.push(`Info:\n${fields}`);
727
+ }
728
+ if (data.RelatedTopics?.length) {
729
+ const topics = (data.RelatedTopics as { Text?: string }[])
730
+ .filter((t) => t.Text)
731
+ .slice(0, 5)
732
+ .map((t) => ` - ${t.Text}`)
733
+ .join("\n");
734
+ if (topics) parts.push(`Related:\n${topics}`);
735
+ }
736
+
737
+ const result = parts.join("\n\n");
738
+ if (result.length > 60) return result;
739
+ }
740
+ } catch {
741
+ // fall through to HTML scrape
742
+ }
743
+
744
+ try {
745
+ const htmlUrl = `https://html.duckduckgo.com/html/?q=${encoded}`;
746
+ const res = await fetch(htmlUrl, {
747
+ headers: {
748
+ "User-Agent":
749
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
750
+ Accept: "text/html",
751
+ },
752
+ signal: AbortSignal.timeout(10000),
753
+ });
754
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
755
+ const html = await res.text();
756
+
757
+ const snippets: string[] = [];
758
+ const snippetRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
759
+ let m: RegExpExecArray | null;
760
+ while ((m = snippetRe.exec(html)) !== null && snippets.length < 6) {
761
+ const text = m[1]!
762
+ .replace(/<[^>]+>/g, " ")
763
+ .replace(/&nbsp;/g, " ")
764
+ .replace(/&amp;/g, "&")
765
+ .replace(/&lt;/g, "<")
766
+ .replace(/&gt;/g, ">")
767
+ .replace(/&quot;/g, '"')
768
+ .replace(/\s+/g, " ")
769
+ .trim();
770
+ if (text.length > 20) snippets.push(`- ${text}`);
771
+ }
772
+
773
+ const links: string[] = [];
774
+ const linkRe = /class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
775
+ while ((m = linkRe.exec(html)) !== null && links.length < 5) {
776
+ const title = m[2]!.replace(/<[^>]+>/g, "").trim();
777
+ const href = m[1]!;
778
+ if (title && href) links.push(` ${title} \u2014 ${href}`);
779
+ }
780
+
781
+ if (snippets.length === 0 && links.length === 0) {
782
+ return `No results found for: ${query}`;
783
+ }
784
+
785
+ const parts = [`Search results for: ${query}`];
786
+ if (snippets.length > 0) parts.push(`Snippets:\n${snippets.join("\n")}`);
787
+ if (links.length > 0) parts.push(`Links:\n${links.join("\n")}`);
788
+ return parts.join("\n\n");
789
+ } catch (err) {
790
+ return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
791
+ }
792
+ }
793
+
794
+ // ── File tools ────────────────────────────────────────────────────────────────
795
+
796
+ export function readFile(filePath: string, repoPath: string): string {
797
+ const candidates = path.isAbsolute(filePath)
798
+ ? [filePath]
799
+ : [filePath, path.join(repoPath, filePath)];
800
+ for (const candidate of candidates) {
801
+ if (existsSync(candidate)) {
802
+ try {
803
+ const content = readFileSync(candidate, "utf-8");
804
+ const lines = content.split("\n").length;
805
+ return `File: ${candidate} (${lines} lines)\n\n${content.slice(0, 8000)}${
806
+ content.length > 8000 ? "\n\n… (truncated)" : ""
807
+ }`;
808
+ } catch (err) {
809
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
810
+ }
811
+ }
812
+ }
813
+ return `File not found: ${filePath}. If reading from a cloned repo, use the full absolute path e.g. C:\\Users\\...\\repo\\file.ts`;
814
+ }
815
+
816
+ export function writeFile(
817
+ filePath: string,
818
+ content: string,
819
+ repoPath: string,
820
+ ): string {
821
+ const fullPath = path.isAbsolute(filePath)
822
+ ? filePath
823
+ : path.join(repoPath, filePath);
824
+ try {
825
+ const dir = path.dirname(fullPath);
826
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
827
+ writeFileSync(fullPath, content, "utf-8");
828
+ const lines = content.split("\n").length;
829
+ return `Written: ${fullPath} (${lines} lines, ${content.length} bytes)`;
830
+ } catch (err) {
831
+ return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
832
+ }
833
+ }