@ridit/lens 0.1.9 → 0.2.1

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.
@@ -0,0 +1,261 @@
1
+ import path from "path";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "fs";
10
+ import type { FilePatch } from "../components/repo/DiffViewer";
11
+
12
+ // ── Walk ──────────────────────────────────────────────────────────────────────
13
+
14
+ const SKIP_DIRS = new Set([
15
+ "node_modules",
16
+ ".git",
17
+ "dist",
18
+ "build",
19
+ ".next",
20
+ "out",
21
+ "coverage",
22
+ "__pycache__",
23
+ ".venv",
24
+ "venv",
25
+ ]);
26
+
27
+ export function walkDir(
28
+ dir: string,
29
+ base = dir,
30
+ results: string[] = [],
31
+ ): string[] {
32
+ let entries: string[];
33
+ try {
34
+ entries = readdirSync(dir, { encoding: "utf-8" });
35
+ } catch {
36
+ return results;
37
+ }
38
+ for (const entry of entries) {
39
+ if (results.length >= 100) return results;
40
+ if (SKIP_DIRS.has(entry)) continue;
41
+ const full = path.join(dir, entry);
42
+ const rel = path.relative(base, full).replace(/\\/g, "/");
43
+ let isDir = false;
44
+ try {
45
+ isDir = statSync(full).isDirectory();
46
+ } catch {
47
+ continue;
48
+ }
49
+ if (isDir) walkDir(full, base, results);
50
+ else results.push(rel);
51
+ }
52
+ return results;
53
+ }
54
+
55
+ export function applyPatches(repoPath: string, patches: FilePatch[]): void {
56
+ for (const patch of patches) {
57
+ const fullPath = path.join(repoPath, patch.path);
58
+ const dir = path.dirname(fullPath);
59
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
60
+ writeFileSync(fullPath, patch.content, "utf-8");
61
+ }
62
+ }
63
+
64
+ // ── Read ──────────────────────────────────────────────────────────────────────
65
+
66
+ export function readFile(filePath: string, repoPath: string): string {
67
+ const candidates = path.isAbsolute(filePath)
68
+ ? [filePath]
69
+ : [filePath, path.join(repoPath, filePath)];
70
+ for (const candidate of candidates) {
71
+ if (existsSync(candidate)) {
72
+ try {
73
+ const content = readFileSync(candidate, "utf-8");
74
+ const lines = content.split("\n").length;
75
+ return `File: ${candidate} (${lines} lines)\n\n${content.slice(0, 8000)}${
76
+ content.length > 8000 ? "\n\n… (truncated)" : ""
77
+ }`;
78
+ } catch (err) {
79
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
80
+ }
81
+ }
82
+ }
83
+ return `File not found: ${filePath}. If reading from a cloned repo, use the full absolute path e.g. C:\\Users\\...\\repo\\file.ts`;
84
+ }
85
+
86
+ export function readFolder(folderPath: string, repoPath: string): string {
87
+ const sanitized = folderPath
88
+ .replace(/^(ls|dir|find|tree|cat|read|ls -la?|ls -al?)\s+/i, "")
89
+ .trim();
90
+
91
+ const candidates = path.isAbsolute(sanitized)
92
+ ? [sanitized]
93
+ : [sanitized, path.join(repoPath, sanitized)];
94
+
95
+ for (const candidate of candidates) {
96
+ if (!existsSync(candidate)) continue;
97
+ let stat: ReturnType<typeof statSync>;
98
+ try {
99
+ stat = statSync(candidate);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (!stat.isDirectory()) {
104
+ return `Not a directory: ${candidate}. Use read-file to read a file.`;
105
+ }
106
+
107
+ let entries: string[];
108
+ try {
109
+ entries = readdirSync(candidate, { encoding: "utf-8" });
110
+ } catch (err) {
111
+ return `Error reading folder: ${err instanceof Error ? err.message : String(err)}`;
112
+ }
113
+
114
+ const files: string[] = [];
115
+ const subfolders: string[] = [];
116
+
117
+ for (const entry of entries) {
118
+ if (entry.startsWith(".") && entry !== ".env") continue;
119
+ const full = path.join(candidate, entry);
120
+ try {
121
+ if (statSync(full).isDirectory()) subfolders.push(`${entry}/`);
122
+ else files.push(entry);
123
+ } catch {
124
+ // skip
125
+ }
126
+ }
127
+
128
+ const total = files.length + subfolders.length;
129
+ const lines: string[] = [`Folder: ${candidate} (${total} entries)`, ""];
130
+ if (files.length > 0) {
131
+ lines.push("Files:");
132
+ files.forEach((f) => lines.push(` ${f}`));
133
+ }
134
+ if (subfolders.length > 0) {
135
+ if (files.length > 0) lines.push("");
136
+ lines.push("Subfolders:");
137
+ subfolders.forEach((d) => lines.push(` ${d}`));
138
+ }
139
+ if (total === 0) lines.push("(empty folder)");
140
+ return lines.join("\n");
141
+ }
142
+
143
+ return `Folder not found: ${sanitized}`;
144
+ }
145
+
146
+ export function grepFiles(
147
+ pattern: string,
148
+ glob: string,
149
+ repoPath: string,
150
+ ): string {
151
+ let regex: RegExp;
152
+ try {
153
+ regex = new RegExp(pattern, "i");
154
+ } catch {
155
+ regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
156
+ }
157
+
158
+ const globToFilter = (g: string): ((rel: string) => boolean) => {
159
+ const cleaned = g.replace(/^\*\*\//, "");
160
+ const parts = cleaned.split("/");
161
+ const ext = parts[parts.length - 1];
162
+ const prefix = parts.slice(0, -1).join("/");
163
+ return (rel: string) => {
164
+ if (ext?.startsWith("*.")) {
165
+ if (!rel.endsWith(ext.slice(1))) return false;
166
+ } else if (ext && !ext.includes("*")) {
167
+ if (!rel.endsWith(ext)) return false;
168
+ }
169
+ if (prefix && !prefix.includes("*")) {
170
+ if (!rel.startsWith(prefix)) return false;
171
+ }
172
+ return true;
173
+ };
174
+ };
175
+
176
+ const filter = globToFilter(glob);
177
+ const allFiles = walkDir(repoPath);
178
+ const matchedFiles = allFiles.filter(filter);
179
+ if (matchedFiles.length === 0) return `No files matched glob: ${glob}`;
180
+
181
+ const results: string[] = [];
182
+ let totalMatches = 0;
183
+
184
+ for (const relPath of matchedFiles) {
185
+ const fullPath = path.join(repoPath, relPath);
186
+ let content: string;
187
+ try {
188
+ content = readFileSync(fullPath, "utf-8");
189
+ } catch {
190
+ continue;
191
+ }
192
+ const fileLines = content.split("\n");
193
+ const fileMatches: string[] = [];
194
+ fileLines.forEach((line, i) => {
195
+ if (regex.test(line)) {
196
+ fileMatches.push(` ${i + 1}: ${line.trimEnd()}`);
197
+ totalMatches++;
198
+ }
199
+ });
200
+ if (fileMatches.length > 0)
201
+ results.push(`${relPath}\n${fileMatches.join("\n")}`);
202
+ if (totalMatches >= 200) {
203
+ results.push("(truncated — too many matches)");
204
+ break;
205
+ }
206
+ }
207
+
208
+ if (results.length === 0)
209
+ return `No matches for /${pattern}/ in ${matchedFiles.length} file(s) matching ${glob}`;
210
+
211
+ return `grep /${pattern}/ ${glob} — ${totalMatches} match(es) in ${results.length} file(s)\n\n${results.join("\n\n")}`;
212
+ }
213
+
214
+ // ── Write / Delete ────────────────────────────────────────────────────────────
215
+
216
+ export function writeFile(
217
+ filePath: string,
218
+ content: string,
219
+ repoPath: string,
220
+ ): string {
221
+ const fullPath = path.isAbsolute(filePath)
222
+ ? filePath
223
+ : path.join(repoPath, filePath);
224
+ try {
225
+ const dir = path.dirname(fullPath);
226
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
227
+ writeFileSync(fullPath, content, "utf-8");
228
+ const lines = content.split("\n").length;
229
+ return `Written: ${fullPath} (${lines} lines, ${content.length} bytes)`;
230
+ } catch (err) {
231
+ return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
232
+ }
233
+ }
234
+
235
+ export function deleteFile(filePath: string, repoPath: string): string {
236
+ const fullPath = path.isAbsolute(filePath)
237
+ ? filePath
238
+ : path.join(repoPath, filePath);
239
+ try {
240
+ if (!existsSync(fullPath)) return `File not found: ${fullPath}`;
241
+ const { unlinkSync } = require("fs") as typeof import("fs");
242
+ unlinkSync(fullPath);
243
+ return `Deleted: ${fullPath}`;
244
+ } catch (err) {
245
+ return `Error deleting file: ${err instanceof Error ? err.message : String(err)}`;
246
+ }
247
+ }
248
+
249
+ export function deleteFolder(folderPath: string, repoPath: string): string {
250
+ const fullPath = path.isAbsolute(folderPath)
251
+ ? folderPath
252
+ : path.join(repoPath, folderPath);
253
+ try {
254
+ if (!existsSync(fullPath)) return `Folder not found: ${fullPath}`;
255
+ const { rmSync } = require("fs") as typeof import("fs");
256
+ rmSync(fullPath, { recursive: true, force: true });
257
+ return `Deleted folder: ${fullPath}`;
258
+ } catch (err) {
259
+ return `Error deleting folder: ${err instanceof Error ? err.message : String(err)}`;
260
+ }
261
+ }
@@ -0,0 +1,13 @@
1
+ export { runShell, readClipboard, openUrl } from "./shell";
2
+ export {
3
+ walkDir,
4
+ applyPatches,
5
+ readFile,
6
+ readFolder,
7
+ grepFiles,
8
+ writeFile,
9
+ deleteFile,
10
+ deleteFolder,
11
+ } from "./files";
12
+ export { fetchUrl, searchWeb } from "./web";
13
+ export { generatePdf } from "./pdf";
@@ -0,0 +1,106 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
4
+
5
+ export function generatePdf(
6
+ filePath: string,
7
+ content: string,
8
+ repoPath: string,
9
+ ): string {
10
+ const fullPath = path.isAbsolute(filePath)
11
+ ? filePath
12
+ : path.join(repoPath, filePath);
13
+
14
+ try {
15
+ const dir = path.dirname(fullPath);
16
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
17
+
18
+ const escaped = content
19
+ .replace(/\\/g, "\\\\")
20
+ .replace(/"""/g, '\\"\\"\\"')
21
+ .replace(/\r/g, "");
22
+
23
+ const script = `
24
+ import sys
25
+ try:
26
+ from reportlab.lib.pagesizes import letter
27
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
28
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
29
+ from reportlab.lib.units import inch
30
+ from reportlab.lib import colors
31
+ except ImportError:
32
+ import subprocess
33
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "reportlab", "--break-system-packages", "-q"])
34
+ from reportlab.lib.pagesizes import letter
35
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
36
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
37
+ from reportlab.lib.units import inch
38
+ from reportlab.lib import colors
39
+
40
+ doc = SimpleDocTemplate(
41
+ r"""${fullPath}""",
42
+ pagesize=letter,
43
+ rightMargin=inch,
44
+ leftMargin=inch,
45
+ topMargin=inch,
46
+ bottomMargin=inch,
47
+ )
48
+
49
+ styles = getSampleStyleSheet()
50
+ styles.add(ParagraphStyle(name="H1", parent=styles["Heading1"], fontSize=22, spaceAfter=10))
51
+ styles.add(ParagraphStyle(name="H2", parent=styles["Heading2"], fontSize=16, spaceAfter=8))
52
+ styles.add(ParagraphStyle(name="H3", parent=styles["Heading3"], fontSize=13, spaceAfter=6))
53
+ styles.add(ParagraphStyle(name="Body", parent=styles["Normal"], fontSize=11, leading=16, spaceAfter=8))
54
+ styles.add(ParagraphStyle(name="Bullet", parent=styles["Normal"], fontSize=11, leading=16, leftIndent=20, spaceAfter=4, bulletIndent=10))
55
+
56
+ raw = """${escaped}"""
57
+
58
+ story = []
59
+ for line in raw.split("\\n"):
60
+ s = line.rstrip()
61
+ if s.startswith("### "):
62
+ story.append(Paragraph(s[4:], styles["H3"]))
63
+ elif s.startswith("## "):
64
+ story.append(Spacer(1, 6))
65
+ story.append(Paragraph(s[3:], styles["H2"]))
66
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey, spaceAfter=4))
67
+ elif s.startswith("# "):
68
+ story.append(Paragraph(s[2:], styles["H1"]))
69
+ story.append(HRFlowable(width="100%", thickness=1, color=colors.black, spaceAfter=6))
70
+ elif s.startswith("- ") or s.startswith("* "):
71
+ story.append(Paragraph(u"\\u2022 " + s[2:], styles["Bullet"]))
72
+ elif s.startswith("---"):
73
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey, spaceAfter=4))
74
+ elif s == "":
75
+ story.append(Spacer(1, 6))
76
+ else:
77
+ import re
78
+ s = re.sub(r"\\*\\*(.+?)\\*\\*", r"<b>\\1</b>", s)
79
+ s = re.sub(r"\\*(.+?)\\*", r"<i>\\1</i>", s)
80
+ s = re.sub(r"\`(.+?)\`", r"<font name='Courier'>\\1</font>", s)
81
+ story.append(Paragraph(s, styles["Body"]))
82
+
83
+ doc.build(story)
84
+ print("OK")
85
+ `
86
+ .replace("${fullPath}", fullPath.replace(/\\/g, "/"))
87
+ .replace("${escaped}", escaped);
88
+
89
+ const tmpFile = path.join(os.tmpdir(), `lens_pdf_${Date.now()}.py`);
90
+ writeFileSync(tmpFile, script, "utf-8");
91
+
92
+ const { execSync } =
93
+ require("child_process") as typeof import("child_process");
94
+ execSync(`python "${tmpFile}"`, { stdio: "pipe" });
95
+
96
+ try {
97
+ require("fs").unlinkSync(tmpFile);
98
+ } catch {
99
+ /* ignore */
100
+ }
101
+
102
+ return `PDF generated: ${fullPath}`;
103
+ } catch (err) {
104
+ return `Error generating PDF: ${err instanceof Error ? err.message : String(err)}`;
105
+ }
106
+ }
@@ -0,0 +1,96 @@
1
+ import { execSync } from "child_process";
2
+
3
+ export async function runShell(command: string, cwd: string): Promise<string> {
4
+ return new Promise((resolve) => {
5
+ const { spawn } =
6
+ require("child_process") as typeof import("child_process");
7
+ const isWin = process.platform === "win32";
8
+ const shell = isWin ? "cmd.exe" : "/bin/sh";
9
+ const shellFlag = isWin ? "/c" : "-c";
10
+
11
+ const proc = spawn(shell, [shellFlag, command], {
12
+ cwd,
13
+ env: process.env,
14
+ stdio: ["ignore", "pipe", "pipe"],
15
+ });
16
+
17
+ const chunks: Buffer[] = [];
18
+ const errChunks: Buffer[] = [];
19
+
20
+ proc.stdout.on("data", (d: Buffer) => chunks.push(d));
21
+ proc.stderr.on("data", (d: Buffer) => errChunks.push(d));
22
+
23
+ const killTimer = setTimeout(
24
+ () => {
25
+ proc.kill();
26
+ resolve("(command timed out after 5 minutes)");
27
+ },
28
+ 5 * 60 * 1000,
29
+ );
30
+
31
+ proc.on("close", (code: number | null) => {
32
+ clearTimeout(killTimer);
33
+ const stdout = Buffer.concat(chunks).toString("utf-8").trim();
34
+ const stderr = Buffer.concat(errChunks).toString("utf-8").trim();
35
+ const combined = [stdout, stderr].filter(Boolean).join("\n");
36
+ resolve(combined || (code === 0 ? "(no output)" : `exit code ${code}`));
37
+ });
38
+
39
+ proc.on("error", (err: Error) => {
40
+ clearTimeout(killTimer);
41
+ resolve(`Error: ${err.message}`);
42
+ });
43
+ });
44
+ }
45
+
46
+ export function readClipboard(): string {
47
+ try {
48
+ const platform = process.platform;
49
+ if (platform === "win32") {
50
+ return execSync("powershell -noprofile -command Get-Clipboard", {
51
+ encoding: "utf-8",
52
+ timeout: 2000,
53
+ })
54
+ .replace(/\r\n/g, "\n")
55
+ .trimEnd();
56
+ }
57
+ if (platform === "darwin") {
58
+ return execSync("pbpaste", {
59
+ encoding: "utf-8",
60
+ timeout: 2000,
61
+ }).trimEnd();
62
+ }
63
+ for (const cmd of [
64
+ "xclip -selection clipboard -o",
65
+ "xsel --clipboard --output",
66
+ "wl-paste",
67
+ ]) {
68
+ try {
69
+ return execSync(cmd, { encoding: "utf-8", timeout: 2000 }).trimEnd();
70
+ } catch {
71
+ continue;
72
+ }
73
+ }
74
+ return "";
75
+ } catch {
76
+ return "";
77
+ }
78
+ }
79
+
80
+ export function openUrl(url: string): string {
81
+ try {
82
+ const { execSync: exec } =
83
+ require("child_process") as typeof import("child_process");
84
+ const platform = process.platform;
85
+ if (platform === "win32") {
86
+ exec(`start "" "${url}"`, { stdio: "ignore" });
87
+ } else if (platform === "darwin") {
88
+ exec(`open "${url}"`, { stdio: "ignore" });
89
+ } else {
90
+ exec(`xdg-open "${url}"`, { stdio: "ignore" });
91
+ }
92
+ return `Opened: ${url}`;
93
+ } catch (err) {
94
+ return `Error opening URL: ${err instanceof Error ? err.message : String(err)}`;
95
+ }
96
+ }
@@ -0,0 +1,216 @@
1
+ // ── HTML helpers ──────────────────────────────────────────────────────────────
2
+
3
+ function stripTags(html: string): string {
4
+ return html
5
+ .replace(/<[^>]+>/g, " ")
6
+ .replace(/&nbsp;/g, " ")
7
+ .replace(/&amp;/g, "&")
8
+ .replace(/&lt;/g, "<")
9
+ .replace(/&gt;/g, ">")
10
+ .replace(/&quot;/g, '"')
11
+ .replace(/&#\d+;/g, " ")
12
+ .replace(/\s+/g, " ")
13
+ .trim();
14
+ }
15
+
16
+ function extractTables(html: string): string {
17
+ const tables: string[] = [];
18
+ const tableRe = /<table[\s\S]*?<\/table>/gi;
19
+ let tMatch: RegExpExecArray | null;
20
+
21
+ while ((tMatch = tableRe.exec(html)) !== null) {
22
+ const tableHtml = tMatch[0]!;
23
+ const rows: string[][] = [];
24
+ const rowRe = /<tr[\s\S]*?<\/tr>/gi;
25
+ let rMatch: RegExpExecArray | null;
26
+ while ((rMatch = rowRe.exec(tableHtml)) !== null) {
27
+ const cells: string[] = [];
28
+ const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
29
+ let cMatch: RegExpExecArray | null;
30
+ while ((cMatch = cellRe.exec(rMatch[0]!)) !== null) {
31
+ cells.push(stripTags(cMatch[1] ?? ""));
32
+ }
33
+ if (cells.length > 0) rows.push(cells);
34
+ }
35
+ if (rows.length < 2) continue;
36
+ const cols = Math.max(...rows.map((r) => r.length));
37
+ const padded = rows.map((r) => {
38
+ while (r.length < cols) r.push("");
39
+ return r;
40
+ });
41
+ const widths = Array.from({ length: cols }, (_, ci) =>
42
+ Math.max(...padded.map((r) => (r[ci] ?? "").length), 3),
43
+ );
44
+ const fmt = (r: string[]) =>
45
+ r.map((c, ci) => c.padEnd(widths[ci] ?? 0)).join(" | ");
46
+ const header = fmt(padded[0]!);
47
+ const sep = widths.map((w) => "-".repeat(w)).join("-|-");
48
+ const body = padded.slice(1).map(fmt).join("\n");
49
+ tables.push(`${header}\n${sep}\n${body}`);
50
+ }
51
+
52
+ return tables.length > 0
53
+ ? `=== TABLES (${tables.length}) ===\n\n${tables.join("\n\n---\n\n")}`
54
+ : "";
55
+ }
56
+
57
+ function extractLists(html: string): string {
58
+ const lists: string[] = [];
59
+ const listRe = /<[ou]l[\s\S]*?<\/[ou]l>/gi;
60
+ let lMatch: RegExpExecArray | null;
61
+ while ((lMatch = listRe.exec(html)) !== null) {
62
+ const items: string[] = [];
63
+ const itemRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
64
+ let iMatch: RegExpExecArray | null;
65
+ while ((iMatch = itemRe.exec(lMatch[0]!)) !== null) {
66
+ const text = stripTags(iMatch[1] ?? "");
67
+ if (text.length > 2) items.push(`• ${text}`);
68
+ }
69
+ if (items.length > 1) lists.push(items.join("\n"));
70
+ }
71
+ return lists.length > 0
72
+ ? `=== LISTS ===\n\n${lists.slice(0, 5).join("\n\n")}`
73
+ : "";
74
+ }
75
+
76
+ // ── Fetch ─────────────────────────────────────────────────────────────────────
77
+
78
+ export async function fetchUrl(url: string): Promise<string> {
79
+ const res = await fetch(url, {
80
+ headers: {
81
+ "User-Agent":
82
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
83
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
84
+ "Accept-Language": "en-US,en;q=0.5",
85
+ },
86
+ signal: AbortSignal.timeout(15000),
87
+ });
88
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
89
+
90
+ const contentType = res.headers.get("content-type") ?? "";
91
+ if (contentType.includes("application/json")) {
92
+ const json = await res.json();
93
+ return JSON.stringify(json, null, 2).slice(0, 8000);
94
+ }
95
+
96
+ const html = await res.text();
97
+ const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
98
+ const title = titleMatch ? stripTags(titleMatch[1]!) : "No title";
99
+
100
+ const tables = extractTables(html);
101
+ const lists = extractLists(html);
102
+ const bodyText = stripTags(
103
+ html
104
+ .replace(/<script[\s\S]*?<\/script>/gi, "")
105
+ .replace(/<style[\s\S]*?<\/style>/gi, "")
106
+ .replace(/<nav[\s\S]*?<\/nav>/gi, "")
107
+ .replace(/<footer[\s\S]*?<\/footer>/gi, "")
108
+ .replace(/<header[\s\S]*?<\/header>/gi, ""),
109
+ )
110
+ .replace(/\s{3,}/g, "\n\n")
111
+ .slice(0, 3000);
112
+
113
+ const parts = [`PAGE: ${title}`, `URL: ${url}`];
114
+ if (tables) parts.push(tables);
115
+ if (lists) parts.push(lists);
116
+ parts.push(`=== TEXT ===\n${bodyText}`);
117
+ return parts.join("\n\n");
118
+ }
119
+
120
+ // ── Search ────────────────────────────────────────────────────────────────────
121
+
122
+ export async function searchWeb(query: string): Promise<string> {
123
+ const encoded = encodeURIComponent(query);
124
+
125
+ const ddgUrl = `https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1&skip_disambig=1`;
126
+ try {
127
+ const res = await fetch(ddgUrl, {
128
+ headers: { "User-Agent": "Lens/1.0" },
129
+ signal: AbortSignal.timeout(8000),
130
+ });
131
+ if (res.ok) {
132
+ const data = (await res.json()) as {
133
+ AbstractText?: string;
134
+ AbstractURL?: string;
135
+ RelatedTopics?: { Text?: string; FirstURL?: string }[];
136
+ Answer?: string;
137
+ Infobox?: { content?: { label: string; value: string }[] };
138
+ };
139
+
140
+ const parts: string[] = [`Search: ${query}`];
141
+ if (data.Answer) parts.push(`Answer: ${data.Answer}`);
142
+ if (data.AbstractText) {
143
+ parts.push(`Summary: ${data.AbstractText}`);
144
+ if (data.AbstractURL) parts.push(`Source: ${data.AbstractURL}`);
145
+ }
146
+ if (data.Infobox?.content?.length) {
147
+ const fields = data.Infobox.content
148
+ .slice(0, 8)
149
+ .map((f) => ` ${f.label}: ${f.value}`)
150
+ .join("\n");
151
+ parts.push(`Info:\n${fields}`);
152
+ }
153
+ if (data.RelatedTopics?.length) {
154
+ const topics = (data.RelatedTopics as { Text?: string }[])
155
+ .filter((t) => t.Text)
156
+ .slice(0, 5)
157
+ .map((t) => ` - ${t.Text}`)
158
+ .join("\n");
159
+ if (topics) parts.push(`Related:\n${topics}`);
160
+ }
161
+
162
+ const result = parts.join("\n\n");
163
+ if (result.length > 60) return result;
164
+ }
165
+ } catch {
166
+ // fall through to HTML scrape
167
+ }
168
+
169
+ try {
170
+ const htmlUrl = `https://html.duckduckgo.com/html/?q=${encoded}`;
171
+ const res = await fetch(htmlUrl, {
172
+ headers: {
173
+ "User-Agent":
174
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
175
+ Accept: "text/html",
176
+ },
177
+ signal: AbortSignal.timeout(10000),
178
+ });
179
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
180
+ const html = await res.text();
181
+
182
+ const snippets: string[] = [];
183
+ const snippetRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
184
+ let m: RegExpExecArray | null;
185
+ while ((m = snippetRe.exec(html)) !== null && snippets.length < 6) {
186
+ const text = m[1]!
187
+ .replace(/<[^>]+>/g, " ")
188
+ .replace(/&nbsp;/g, " ")
189
+ .replace(/&amp;/g, "&")
190
+ .replace(/&lt;/g, "<")
191
+ .replace(/&gt;/g, ">")
192
+ .replace(/&quot;/g, '"')
193
+ .replace(/\s+/g, " ")
194
+ .trim();
195
+ if (text.length > 20) snippets.push(`- ${text}`);
196
+ }
197
+
198
+ const links: string[] = [];
199
+ const linkRe = /class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
200
+ while ((m = linkRe.exec(html)) !== null && links.length < 5) {
201
+ const title = m[2]!.replace(/<[^>]+>/g, "").trim();
202
+ const href = m[1]!;
203
+ if (title && href) links.push(` ${title} \u2014 ${href}`);
204
+ }
205
+
206
+ if (snippets.length === 0 && links.length === 0)
207
+ return `No results found for: ${query}`;
208
+
209
+ const parts = [`Search results for: ${query}`];
210
+ if (snippets.length > 0) parts.push(`Snippets:\n${snippets.join("\n")}`);
211
+ if (links.length > 0) parts.push(`Links:\n${links.join("\n")}`);
212
+ return parts.join("\n\n");
213
+ } catch (err) {
214
+ return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
215
+ }
216
+ }