@ridit/lens 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/utils/chat.ts CHANGED
@@ -1,691 +1,105 @@
1
- import type { ImportantFile } from "../types/repo";
1
+ // ── chat.ts ───────────────────────────────────────────────────────────────────
2
+ //
3
+ // Response parsing and API calls.
4
+ // Tool parsing is now fully driven by the ToolRegistry — adding a new tool
5
+ // to the registry automatically makes it parseable here.
6
+
7
+ export {
8
+ walkDir,
9
+ applyPatches,
10
+ readFile,
11
+ readFolder,
12
+ grepFiles,
13
+ writeFile,
14
+ deleteFile,
15
+ deleteFolder,
16
+ } from "../tools/files";
17
+ export { runShell, readClipboard, openUrl } from "../tools/shell";
18
+ export { fetchUrl, searchWeb } from "../tools/web";
19
+ export { generatePdf } from "../tools/pdf";
20
+ export { buildSystemPrompt, FEW_SHOT_MESSAGES } from "../prompts";
21
+
22
+ import type { Message } from "../types/chat";
2
23
  import type { Provider } from "../types/config";
24
+ import { FEW_SHOT_MESSAGES } from "../prompts";
25
+ import { registry } from "./tools/registry";
3
26
  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 eleven tools.
28
-
29
- ## TOOLS
30
-
31
- You have exactly eleven 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. read-folder — list contents of a folder (files + subfolders, one level deep)
43
- <read-folder>src/components</read-folder>
44
-
45
- ### 5. grep — search for a pattern across files in the repo (cross-platform, no shell needed)
46
- <grep>
47
- {"pattern": "ChatRunner", "glob": "src/**/*.tsx"}
48
- </grep>
49
-
50
- ### 6. write-file — create or overwrite a file
51
- <write-file>
52
- {"path": "data/output.csv", "content": "col1,col2\nval1,val2"}
53
- </write-file>
54
-
55
- ### 7. delete-file — permanently delete a single file
56
- <delete-file>src/old-component.tsx</delete-file>
57
-
58
- ### 8. delete-folder — permanently delete a folder and all its contents
59
- <delete-folder>src/legacy</delete-folder>
60
-
61
- ### 9. open-url — open a URL in the user's default browser
62
- <open-url>https://github.com/owner/repo</open-url>
63
-
64
- ### 10. generate-pdf — generate a PDF file from markdown-style content
65
- <generate-pdf>
66
- {"path": "output/report.pdf", "content": "# Title\n\nSome body text.\n\n## Section\n\nMore content."}
67
- </generate-pdf>
68
-
69
- ### 11. search — search the internet for anything you are unsure about
70
- <search>how to use React useEffect cleanup function</search>
71
-
72
- ### 12. clone — clone a GitHub repo so you can explore and discuss it
73
- <clone>https://github.com/owner/repo</clone>
74
-
75
- ### 13. changes — propose code edits (shown as a diff for user approval)
76
- <changes>
77
- {"summary": "what changed and why", "patches": [{"path": "src/foo.ts", "content": "COMPLETE file content", "isNew": false}]}
78
- </changes>
79
-
80
- ## RULES
81
-
82
- 1. When you need to use a tool, output ONLY the XML tag — nothing before or after it in that response
83
- 2. ONE tool per response — emit the tag, then stop completely
84
- 3. After the user approves and you get the result, continue your analysis in the next response
85
- 4. NEVER print a URL, command, filename, or JSON blob as plain text when you should be using a tool
86
- 5. NEVER say "I'll fetch" / "run this command" / "here's the write-file" — just emit the tag
87
- 6. NEVER use shell to run git clone — always use the clone tag instead
88
- 7. NEVER use shell to list files or folders (no ls, dir, find, git ls-files, tree) — ALWAYS use read-folder instead
89
- 8. NEVER use shell to read a file (no cat, type, Get-Content) — ALWAYS use read-file instead
90
- 9. NEVER use shell grep, findstr, or Select-String to search file contents — ALWAYS use grep instead
91
- 10. shell is ONLY for running code, installing packages, building, testing — not for filesystem inspection
92
- 11. write-file content field must be the COMPLETE file content, never empty or placeholder
93
- 12. After a write-file succeeds, do NOT repeat it — trust the result and move on
94
- 13. After a write-file succeeds, tell the user it is done immediately — do NOT auto-read the file back to verify
95
- 14. 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
96
- 15. NEVER say "I made a mistake" and repeat the same tool — one attempt is enough, trust the output
97
- 16. NEVER second-guess yourself mid-response — commit to your answer
98
- 17. If a read-folder or read-file returns "not found", accept it and move on — do NOT retry the same path
99
- 18. If you have already retrieved a result for a path in this conversation, do NOT request it again — use the result you already have
100
- 19. 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\`
101
- 20. 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\`
102
- 21. 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\`
103
- 22. For JSX/TSX files always use \`.tsx\` extension and include \`/** @jsxImportSource react */\` or ensure tsconfig has jsx set — bun needs this to parse JSX
104
- 23. When explaining how to use a tool in text, use [tag] bracket notation or a fenced code block — NEVER emit a real XML tool tag as part of an explanation or example
105
- 24. NEVER chain tool calls unless the user's request explicitly requires multiple steps
106
- 25. NEVER read files, list folders, or run tools that were not asked for in the current user message
107
- 26. NEVER use markdown formatting in plain text responses — no **bold**, no *italics*, no # headings, no bullet points with -, *, or +, no numbered lists, no backtick inline code. Write in plain prose. Only use fenced \`\`\` code blocks when showing actual code.
108
-
109
- ## CRITICAL: READ BEFORE YOU WRITE
110
-
111
- These rules are mandatory whenever you plan to edit or create a file:
112
-
113
- ### Before modifying ANY existing file:
114
- 1. ALWAYS use read-file on the exact file you plan to change FIRST
115
- 2. Study the full current content — understand every import, every export, every type, every existing feature
116
- 3. Your changes patch MUST preserve ALL existing functionality — do not remove or rewrite things that were not part of the request
117
- 4. If you are unsure what other files import from the file you are editing, use read-folder on the parent directory first to see what exists nearby, then read-file the relevant ones
118
-
119
- ### Before adding a feature that touches multiple files:
120
- 1. Use read-folder on the relevant directory to see what files exist
121
- 2. Use read-file on each file you plan to touch
122
- 3. Only then emit a changes tag — with patches that are surgical additions, not wholesale rewrites
123
-
124
- ### The golden rule for write-file and changes:
125
- - The output file must contain EVERYTHING the original had, PLUS your new additions
126
- - NEVER produce a file that is shorter than the original unless you are explicitly asked to delete things
127
- - If you catch yourself rewriting a file from scratch, STOP — go back and read the original first
128
-
129
- ## WHEN TO USE read-folder:
130
- - Before editing files in an unfamiliar directory — list it first to understand the structure
131
- - When a feature spans multiple files and you are not sure what exists
132
- - When the user asks you to explore or explain a part of the codebase
133
-
134
- ## SCAFFOLDING A NEW PROJECT (follow this exactly)
135
-
136
- When the user asks to create a new CLI/app in a subfolder (e.g. "make a todo app called list"):
137
- 1. Create all files first using write-file with paths like \`list/package.json\`, \`list/src/index.tsx\`
138
- 2. Then run \`cd list && bun install\` (or npm/pnpm) in one shell command
139
- 3. Then run the project with \`cd list && bun run index.ts\` or whatever the entry point is
140
- 4. NEVER run \`bun init\` — it is interactive and will hang. Create package.json manually with write-file instead
141
- 5. TSX files need either tsconfig.json with \`"jsx": "react-jsx"\` or \`/** @jsxImportSource react */\` at the top
142
-
143
- ## FETCH → WRITE FLOW (follow this exactly when saving fetched data)
144
-
145
- 1. fetch the URL
146
- 2. Analyze the result — count the rows, identify columns, check completeness
147
- 3. Tell the user what you found: "Found X rows with columns: A, B, C. Writing now."
148
- 4. emit write-file with correctly structured, complete content
149
- 5. After write-file confirms success, emit read-file to verify
150
- 6. Only after read-file confirms content is correct, tell the user it is done
151
-
152
- ## WHEN TO USE TOOLS
153
-
154
- - User shares any URL → fetch it immediately
155
- - User asks to run anything → shell it immediately
156
- - User asks to open a link, open a URL, or visit a website → open-url it immediately, do NOT use fetch
157
- - User asks to delete a file → delete-file it immediately (requires approval)
158
- - User asks to delete a folder or directory → delete-folder it immediately (requires approval)
159
- - User asks to search for a pattern in files, find usages, find where something is defined → grep it immediately, NEVER use shell grep/findstr/Select-String
160
- - User asks to read a file → read-file it immediately, NEVER use shell cat/type
161
- - User asks what files are in a folder, or to explore/list a directory → read-folder it immediately, NEVER use shell ls/dir/find/git ls-files
162
- - User asks to explore a folder or directory → read-folder it immediately
163
- - User asks to save/create/write a file → write-file it immediately, then read-file to verify
164
- - User asks to modify/edit/add to an existing file → read-file it FIRST, then emit changes
165
- - User shares a GitHub URL and wants to clone/explore/discuss it → use clone immediately, NEVER use shell git clone
166
- - 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.
167
- - You are unsure about an API, library, error, concept, or piece of code → search it immediately
168
- - User asks about something recent or that you might not know → search it immediately
169
- - You are about to say "I'm not sure" or "I don't know" → search instead of guessing
170
-
171
- ## shell IS ONLY FOR:
172
- - Running code: \`node script.js\`, \`bun run dev\`, \`python main.py\`
173
- - Installing packages: \`npm install\`, \`pip install\`
174
- - Building/testing: \`npm run build\`, \`bun test\`
175
- - Git operations other than clone: \`git status\`, \`git log\`, \`git diff\`
176
- - Anything that EXECUTES — not reads or lists
177
-
178
- ## CODEBASE
179
-
180
- ${fileList.length > 0 ? fileList : "(no files indexed)"}
181
-
182
- ${historySummary}`;
183
- }
184
-
185
- // ── Few-shot examples ─────────────────────────────────────────────────────────
186
- export const FEW_SHOT_MESSAGES: { role: string; content: string }[] = [
187
- // ── delete / open / pdf ───────────────────────────────────────────────────
188
- {
189
- role: "user",
190
- content: "delete src/old-component.tsx",
191
- },
192
- {
193
- role: "assistant",
194
- content: "<delete-file>src/old-component.tsx</delete-file>",
195
- },
196
- {
197
- role: "user",
198
- content:
199
- "Here is the output from delete-file of src/old-component.tsx:\n\nDeleted: /repo/src/old-component.tsx\n\nPlease continue your response based on this output.",
200
- },
201
- {
202
- role: "assistant",
203
- content: "Done — `src/old-component.tsx` has been deleted.",
204
- },
205
- {
206
- role: "user",
207
- content: "delete the legacy folder",
208
- },
209
- {
210
- role: "assistant",
211
- content: "<delete-folder>src/legacy</delete-folder>",
212
- },
213
- {
214
- role: "user",
215
- content:
216
- "Here is the output from delete-folder of src/legacy:\n\nDeleted folder: /repo/src/legacy\n\nPlease continue your response based on this output.",
217
- },
218
- {
219
- role: "assistant",
220
- content:
221
- "Done — the `src/legacy` folder and all its contents have been deleted.",
222
- },
223
- {
224
- role: "user",
225
- content: "open https://github.com/microsoft/typescript",
226
- },
227
- {
228
- role: "assistant",
229
- content: "<open-url>https://github.com/microsoft/typescript</open-url>",
230
- },
231
- {
232
- role: "user",
233
- content:
234
- "Here is the output from open-url https://github.com/microsoft/typescript:\n\nOpened: https://github.com/microsoft/typescript\n\nPlease continue your response based on this output.",
235
- },
236
- {
237
- role: "assistant",
238
- content: "Opened the TypeScript GitHub page in your browser.",
239
- },
240
- {
241
- role: "user",
242
- content:
243
- "generate a PDF report about the project and save it to docs/report.pdf",
244
- },
245
- {
246
- role: "assistant",
247
- content:
248
- '<generate-pdf>\n{"path": "docs/report.pdf", "content": "# Project Report\\n\\n## Overview\\n\\nThis document summarizes the project.\\n\\n## Details\\n\\nMore content here."}\n</generate-pdf>',
249
- },
250
- {
251
- role: "user",
252
- content:
253
- "Here is the output from generate-pdf to docs/report.pdf:\n\nPDF generated: /repo/docs/report.pdf\n\nPlease continue your response based on this output.",
254
- },
255
- {
256
- role: "assistant",
257
- content: "Done — the PDF report has been saved to `docs/report.pdf`.",
258
- },
259
-
260
- // ── grep ──────────────────────────────────────────────────────────────────
261
- {
262
- role: "user",
263
- content: 'grep -R "ChatRunner" -n src',
264
- },
265
- {
266
- role: "assistant",
267
- content: '<grep>\n{"pattern": "ChatRunner", "glob": "src/**/*"}\n</grep>',
268
- },
269
- {
270
- role: "user",
271
- content:
272
- 'Here is the output from grep for "ChatRunner":\n\ngrep /ChatRunner/ src/**/* — 3 match(es) in 2 file(s)\n\nsrc/index.tsx\n 12: import { ChatRunner } from "./components/chat/ChatRunner";\n\nsrc/components/chat/ChatRunner.tsx\n 1: export const ChatRunner = ...\n\nPlease continue your response based on this output.',
273
- },
274
- {
275
- role: "assistant",
276
- content:
277
- "`ChatRunner` is defined in `src/components/chat/ChatRunner.tsx` and imported in `src/index.tsx`.",
278
- },
279
- {
280
- role: "user",
281
- content: "find all usages of useInput in the codebase",
282
- },
283
- {
284
- role: "assistant",
285
- content: '<grep>\n{"pattern": "useInput", "glob": "src/**/*.tsx"}\n</grep>',
286
- },
287
- {
288
- role: "user",
289
- content:
290
- 'Here is the output from grep for "useInput":\n\ngrep /useInput/ src/**/*.tsx — 2 match(es) in 1 file(s)\n\nsrc/components/chat/ChatRunner.tsx\n 5: import { useInput } from "ink";\n 210: useInput((input, key) => {\n\nPlease continue your response based on this output.',
291
- },
292
- {
293
- role: "assistant",
294
- content:
295
- "`useInput` is used in `src/components/chat/ChatRunner.tsx` — imported on line 5 and called on line 210.",
296
- },
297
-
298
- // ── showing tool usage as an example (bracket notation, NOT real tags) ────
299
- {
300
- role: "user",
301
- content: "show me how to use the read-file tool",
302
- },
303
- {
304
- role: "assistant",
305
- content:
306
- "To read a file, emit the tag alone in your response — nothing else on that turn:\n\n```text\n[read-file]src/components/Header.tsx[/read-file]\n```\n\nThe result comes back as the next user message and you can then analyze it.",
307
- },
308
- {
309
- role: "user",
310
- content: "show me an example of the shell tool",
311
- },
312
- {
313
- role: "assistant",
314
- content:
315
- "To run a shell command, emit only the tag:\n\n```text\n[shell]node -v[/shell]\n```\n\nThe stdout/stderr is returned and you can read it in the next turn.",
316
- },
317
-
318
- // ── read-folder ───────────────────────────────────────────────────────────
319
- {
320
- role: "user",
321
- content: "read src folder",
322
- },
323
- {
324
- role: "assistant",
325
- content: "<read-folder>src</read-folder>",
326
- },
327
- {
328
- role: "user",
329
- content:
330
- "Here is the output from read-folder of src:\n\nFolder: src (4 entries)\n\nFiles:\n index.ts\n App.tsx\n\nSubfolders:\n components/\n utils/\n\nPlease continue your response based on this output.",
331
- },
332
- {
333
- role: "assistant",
334
- content:
335
- "The `src` folder contains `index.ts`, `App.tsx`, plus subfolders `components/` and `utils/`.",
336
- },
337
- {
338
- role: "user",
339
- content: "list src folder",
340
- },
341
- {
342
- role: "assistant",
343
- content: "<read-folder>src</read-folder>",
344
- },
345
- {
346
- role: "user",
347
- content:
348
- "Here is the output from read-folder of src:\n\nFolder: src (4 entries)\n\nFiles:\n index.ts\n App.tsx\n\nSubfolders:\n components/\n utils/\n\nPlease continue your response based on this output.",
349
- },
350
- {
351
- role: "assistant",
352
- content:
353
- "The `src` folder contains `index.ts`, `App.tsx`, plus subfolders `components/` and `utils/`.",
354
- },
355
- {
356
- role: "user",
357
- content: "what files are in src/components?",
358
- },
359
- {
360
- role: "assistant",
361
- content: "<read-folder>src/components</read-folder>",
362
- },
363
- {
364
- role: "user",
365
- content:
366
- "Here is the output from read-folder of src/components:\n\nFolder: src/components (5 entries)\n\nFiles:\n Header.tsx\n Footer.tsx\n Button.tsx\n\nSubfolders:\n ui/\n forms/\n\nPlease continue your response based on this output.",
367
- },
368
- {
369
- role: "assistant",
370
- content:
371
- "The `src/components` folder has 3 files — `Header.tsx`, `Footer.tsx`, `Button.tsx` — plus two subfolders: `ui/` and `forms/`.",
372
- },
373
- {
374
- role: "user",
375
- content: "list the files in src/utils",
376
- },
377
- {
378
- role: "assistant",
379
- content: "<read-folder>src/utils</read-folder>",
380
- },
381
- {
382
- role: "user",
383
- content:
384
- "Here is the output from read-folder of src/utils:\n\nFolder: src/utils (3 entries)\n\nFiles:\n api.ts\n helpers.ts\n format.ts\n\nPlease continue your response based on this output.",
385
- },
386
- {
387
- role: "assistant",
388
- content:
389
- "The `src/utils` folder contains 3 files: `api.ts`, `helpers.ts`, and `format.ts`.",
390
- },
391
- {
392
- role: "user",
393
- content: "show me what's in the src directory",
394
- },
395
- {
396
- role: "assistant",
397
- content: "<read-folder>src</read-folder>",
398
- },
399
- {
400
- role: "user",
401
- content:
402
- "Here is the output from read-folder of src:\n\nFolder: src (4 entries)\n\nFiles:\n index.ts\n App.tsx\n\nSubfolders:\n components/\n utils/\n\nPlease continue your response based on this output.",
403
- },
404
- {
405
- role: "assistant",
406
- content:
407
- "The `src` directory has 2 files (`index.ts`, `App.tsx`) and 2 subfolders (`components/`, `utils/`).",
408
- },
409
- {
410
- role: "user",
411
- content: "show me the project structure",
412
- },
413
- {
414
- role: "assistant",
415
- content: "<read-folder>.</read-folder>",
416
- },
417
- {
418
- role: "user",
419
- content:
420
- "Here is the output from read-folder of .:\n\nFolder: . (5 entries)\n\nFiles:\n package.json\n tsconfig.json\n README.md\n\nSubfolders:\n src/\n node_modules/\n\nPlease continue your response based on this output.",
421
- },
422
- {
423
- role: "assistant",
424
- content:
425
- "The project root has 3 files (`package.json`, `tsconfig.json`, `README.md`) and the `src/` folder for source code.",
426
- },
427
- {
428
- role: "user",
429
- content: "what does the project look like?",
430
- },
431
- {
432
- role: "assistant",
433
- content: "<read-folder>.</read-folder>",
434
- },
435
- {
436
- role: "user",
437
- content:
438
- "Here is the output from read-folder of .:\n\nFolder: . (4 entries)\n\nFiles:\n package.json\n README.md\n\nSubfolders:\n src/\n dist/\n\nPlease continue your response based on this output.",
439
- },
440
- {
441
- role: "assistant",
442
- content:
443
- "The repo root contains `package.json` and `README.md`, with source code in `src/` and compiled output in `dist/`.",
444
- },
445
-
446
- // ── fetch → write → done (no auto-verify) ────────────────────────────────
447
- {
448
- role: "user",
449
- content: "fetch https://api.github.com/repos/microsoft/typescript",
450
- },
451
- {
452
- role: "assistant",
453
- content: "<fetch>https://api.github.com/repos/microsoft/typescript</fetch>",
454
- },
455
- {
456
- role: "user",
457
- content:
458
- '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.',
459
- },
460
- {
461
- role: "assistant",
462
- content:
463
- "Found 1 object with fields: name, stargazers_count. Writing to ts-info.json now.",
464
- },
465
- {
466
- role: "user",
467
- content: "ok go ahead",
468
- },
469
- {
470
- role: "assistant",
471
- content:
472
- '<write-file>\n{"path": "ts-info.json", "content": "{\"name\":\"TypeScript\",\"stars\":100000}"}\n</write-file>',
473
- },
474
- {
475
- role: "user",
476
- content:
477
- "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.",
478
- },
479
- {
480
- role: "assistant",
481
- content: "Done — `ts-info.json` has been saved.",
482
- },
483
-
484
- // ── write it to a file → done, nothing else ───────────────────────────────
485
- {
486
- role: "user",
487
- content: "write it to a file called hello.py",
488
- },
489
- {
490
- role: "assistant",
491
- content:
492
- '<write-file>\n{"path": "hello.py", "content": "# hello.py\\nprint(\'hello\')"}\n</write-file>',
493
- },
494
- {
495
- role: "user",
496
- content:
497
- "Here is the output from write-file to hello.py:\n\nWritten: /repo/hello.py (2 lines, 32 bytes)\n\nPlease continue your response based on this output.",
498
- },
499
- {
500
- role: "assistant",
501
- content: "Done — `hello.py` has been written.",
502
- },
503
-
504
- // ── read before write ─────────────────────────────────────────────────────
505
- {
506
- role: "user",
507
- content: "add a logout button to src/components/Header.tsx",
508
- },
509
- {
510
- role: "assistant",
511
- content: "<read-file>src/components/Header.tsx</read-file>",
512
- },
513
- {
514
- role: "user",
515
- content:
516
- "Here is the output from read-file of src/components/Header.tsx:\n\nFile: src/components/Header.tsx (42 lines)\n\nimport React from 'react';\n// ... full file content ...\n\nPlease continue your response based on this output.",
517
- },
518
- {
519
- role: "assistant",
520
- content:
521
- '<changes>\n{"summary": "Add logout button to Header — preserves all existing nav items and imports", "patches": [{"path": "src/components/Header.tsx", "content": "// complete file with logout button added", "isNew": false}]}\n</changes>',
522
- },
523
-
524
- // ── shell ─────────────────────────────────────────────────────────────────
525
- {
526
- role: "user",
527
- content: "what node version am I on",
528
- },
529
- {
530
- role: "assistant",
531
- content: "<shell>node -v</shell>",
532
- },
533
- {
534
- role: "user",
535
- content:
536
- "Here is the output from shell command `node -v`:\n\nv20.11.0\n\nPlease continue your response based on this output.",
537
- },
538
- {
539
- role: "assistant",
540
- content: "You're running Node.js v20.11.0.",
541
- },
542
-
543
- // ── clone ─────────────────────────────────────────────────────────────────
544
- {
545
- role: "user",
546
- content: "clone https://github.com/facebook/react",
547
- },
548
- {
549
- role: "assistant",
550
- content: "<clone>https://github.com/facebook/react</clone>",
551
- },
552
- {
553
- role: "user",
554
- content:
555
- "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.",
556
- },
557
- {
558
- role: "assistant",
559
- content:
560
- "Cloned! The React repo has 2847 files. I can read source files, explain how it works, or suggest improvements — just ask.",
561
- },
562
-
563
- // ── search ────────────────────────────────────────────────────────────────
564
- {
565
- role: "user",
566
- content: "what does the ?? operator do in typescript",
567
- },
568
- {
569
- role: "assistant",
570
- content: "<search>nullish coalescing operator ?? TypeScript</search>",
571
- },
572
- {
573
- role: "user",
574
- content:
575
- '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.',
576
- },
577
- {
578
- role: "assistant",
579
- content:
580
- "The `??` operator is the nullish coalescing operator. It returns the right side only when the left side is `null` or `undefined`.",
581
- },
582
- {
583
- role: "user",
584
- content: "what does this project do?",
585
- },
586
- {
587
- role: "assistant",
588
- content:
589
- "This project is a CLI coding assistant that lets you chat with an AI model about your codebase. It can read and write files, run shell commands, search the web, and propose diffs for your approval.",
590
- },
591
- ];
592
- // ── Response parser ───────────────────────────────────────────────────────────
593
27
 
594
28
  export type ParsedResponse =
595
- | { kind: "text"; content: string }
596
- | { kind: "changes"; content: string; patches: FilePatch[] }
597
- | { kind: "shell"; content: string; command: string }
598
- | { kind: "fetch"; content: string; url: string }
599
- | { kind: "read-file"; content: string; filePath: string }
600
- | { kind: "read-folder"; content: string; folderPath: string }
601
- | { kind: "grep"; content: string; pattern: string; glob: string }
602
- | { kind: "delete-file"; content: string; filePath: string }
603
- | { kind: "delete-folder"; content: string; folderPath: string }
604
- | { kind: "open-url"; content: string; url: string }
29
+ | { kind: "text"; content: string; remainder?: string }
605
30
  | {
606
- kind: "generate-pdf";
31
+ kind: "changes";
607
32
  content: string;
608
- filePath: string;
609
- pdfContent: string;
33
+ patches: FilePatch[];
34
+ remainder?: string;
610
35
  }
36
+ | { kind: "clone"; content: string; repoUrl: string; remainder?: string }
611
37
  | {
612
- kind: "write-file";
38
+ kind: "tool";
39
+ toolName: string;
40
+ input: unknown;
41
+ rawInput: string;
613
42
  content: string;
614
- filePath: string;
615
- fileContent: string;
616
- }
617
- | { kind: "search"; content: string; query: string }
618
- | { kind: "clone"; content: string; repoUrl: string };
43
+ remainder?: string;
44
+ };
619
45
 
620
46
  export function parseResponse(text: string): ParsedResponse {
621
- // Strip fenced code blocks before scanning for tool tags so that tags shown
622
- // as examples inside ``` ... ``` blocks are never executed.
623
47
  const scanText = text.replace(/```[\s\S]*?```/g, (m) => " ".repeat(m.length));
624
48
 
625
49
  type Candidate = {
626
50
  index: number;
627
- kind:
628
- | "changes"
629
- | "shell"
630
- | "fetch"
631
- | "read-file"
632
- | "read-folder"
633
- | "grep"
634
- | "delete-file"
635
- | "delete-folder"
636
- | "open-url"
637
- | "generate-pdf"
638
- | "write-file"
639
- | "search"
640
- | "clone";
51
+ toolName: string;
641
52
  match: RegExpExecArray;
642
53
  };
643
54
  const candidates: Candidate[] = [];
644
55
 
645
- const patterns: { kind: Candidate["kind"]; re: RegExp }[] = [
646
- { kind: "fetch", re: /<fetch>([\s\S]*?)<\/fetch>/g },
647
- { kind: "shell", re: /<shell>([\s\S]*?)<\/shell>/g },
648
- { kind: "read-file", re: /<read-file>([\s\S]*?)<\/read-file>/g },
649
- { kind: "read-folder", re: /<read-folder>([\s\S]*?)<\/read-folder>/g },
650
- { kind: "grep", re: /<grep>([\s\S]*?)<\/grep>/g },
651
- { kind: "delete-file", re: /<delete-file>([\s\S]*?)<\/delete-file>/g },
652
- {
653
- kind: "delete-folder",
654
- re: /<delete-folder>([\s\S]*?)<\/delete-folder>/g,
655
- },
656
- { kind: "open-url", re: /<open-url>([\s\S]*?)<\/open-url>/g },
657
- { kind: "generate-pdf", re: /<generate-pdf>([\s\S]*?)<\/generate-pdf>/g },
658
- { kind: "write-file", re: /<write-file>([\s\S]*?)<\/write-file>/g },
659
- { kind: "search", re: /<search>([\s\S]*?)<\/search>/g },
660
- { kind: "clone", re: /<clone>([\s\S]*?)<\/clone>/g },
661
- { kind: "changes", re: /<changes>([\s\S]*?)<\/changes>/g },
662
- { kind: "fetch", re: /```fetch\r?\n([\s\S]*?)\r?\n```/g },
663
- { kind: "shell", re: /```shell\r?\n([\s\S]*?)\r?\n```/g },
664
- { kind: "read-file", re: /```read-file\r?\n([\s\S]*?)\r?\n```/g },
665
- { kind: "read-folder", re: /```read-folder\r?\n([\s\S]*?)\r?\n```/g },
666
- { kind: "write-file", re: /```write-file\r?\n([\s\S]*?)\r?\n```/g },
667
- { kind: "search", re: /```search\r?\n([\s\S]*?)\r?\n```/g },
668
- { kind: "changes", re: /```changes\r?\n([\s\S]*?)\r?\n```/g },
669
- ];
56
+ for (const toolName of registry.names()) {
57
+ const escaped = toolName.replace(/[-]/g, "\\-");
58
+
59
+ // XML tag
60
+ const xmlRe = new RegExp(`<${escaped}>([\\s\\S]*?)<\\/${escaped}>`, "g");
61
+ xmlRe.lastIndex = 0;
62
+ const xmlM = xmlRe.exec(scanText);
63
+ if (xmlM) {
64
+ const orig = new RegExp(xmlRe.source);
65
+ const origM = orig.exec(text.slice(xmlM.index));
66
+ if (origM) {
67
+ candidates.push({
68
+ index: xmlM.index,
69
+ toolName,
70
+ match: Object.assign(
71
+ [
72
+ text.slice(xmlM.index, xmlM.index + origM[0].length),
73
+ origM[1],
74
+ ] as unknown as RegExpExecArray,
75
+ { index: xmlM.index, input: text, groups: undefined },
76
+ ),
77
+ });
78
+ }
79
+ }
670
80
 
671
- for (const { kind, re } of patterns) {
672
- re.lastIndex = 0;
673
- // Scan against the code-block-stripped text so tags inside ``` are ignored
674
- const m = re.exec(scanText);
675
- if (m) {
676
- // Re-extract the match body from the original text at the same position
677
- const originalRe = new RegExp(re.source, re.flags.replace("g", ""));
678
- const originalMatch = originalRe.exec(text.slice(m.index));
679
- if (originalMatch) {
680
- // Reconstruct a match-like object with the correct index
681
- const fakeMatch = Object.assign(
682
- [
683
- text.slice(m.index, m.index + originalMatch[0].length),
684
- originalMatch[1],
685
- ] as unknown as RegExpExecArray,
686
- { index: m.index, input: text, groups: undefined },
687
- );
688
- candidates.push({ index: m.index, kind, match: fakeMatch });
81
+ // Fenced code block fallback
82
+ const fencedRe = new RegExp(
83
+ `\`\`\`${escaped}\\r?\\n([\\s\\S]*?)\\r?\\n\`\`\``,
84
+ "g",
85
+ );
86
+ fencedRe.lastIndex = 0;
87
+ const fencedM = fencedRe.exec(scanText);
88
+ if (fencedM) {
89
+ const orig = new RegExp(fencedRe.source);
90
+ const origM = orig.exec(text.slice(fencedM.index));
91
+ if (origM) {
92
+ candidates.push({
93
+ index: fencedM.index,
94
+ toolName,
95
+ match: Object.assign(
96
+ [
97
+ text.slice(fencedM.index, fencedM.index + origM[0].length),
98
+ origM[1],
99
+ ] as unknown as RegExpExecArray,
100
+ { index: fencedM.index, input: text, groups: undefined },
101
+ ),
102
+ });
689
103
  }
690
104
  }
691
105
  }
@@ -693,107 +107,56 @@ export function parseResponse(text: string): ParsedResponse {
693
107
  if (candidates.length === 0) return { kind: "text", content: text.trim() };
694
108
 
695
109
  candidates.sort((a, b) => a.index - b.index);
696
- const { kind, match } = candidates[0]!;
110
+ const { toolName, match } = candidates[0]!;
697
111
 
698
- // Strip any leaked tool tags from preamble text
699
- const before = text
700
- .slice(0, match.index)
701
- .replace(
702
- /<(fetch|shell|read-file|read-folder|write-file|search|clone|changes)[^>]*>[\s\S]*?<\/\1>/g,
703
- "",
704
- )
705
- .trim();
112
+ const before = text.slice(0, match.index).trim();
706
113
  const body = (match[1] ?? "").trim();
114
+ const afterMatch = text.slice(match.index + match[0].length).trim();
115
+ const remainder = afterMatch.length > 0 ? afterMatch : undefined;
707
116
 
708
- if (kind === "changes") {
117
+ // Special UI variants
118
+ if (toolName === "changes") {
709
119
  try {
710
120
  const parsed = JSON.parse(body) as {
711
121
  summary: string;
712
122
  patches: FilePatch[];
713
123
  };
714
124
  const display = [before, parsed.summary].filter(Boolean).join("\n\n");
715
- return { kind: "changes", content: display, patches: parsed.patches };
716
- } catch {
717
- // fall through
718
- }
719
- }
720
-
721
- if (kind === "shell")
722
- return { kind: "shell", content: before, command: body };
723
-
724
- if (kind === "fetch") {
725
- const url = body.replace(/^<|>$/g, "").trim();
726
- return { kind: "fetch", content: before, url };
727
- }
728
-
729
- if (kind === "read-file")
730
- return { kind: "read-file", content: before, filePath: body };
731
-
732
- if (kind === "read-folder")
733
- return { kind: "read-folder", content: before, folderPath: body };
734
-
735
- if (kind === "delete-file")
736
- return { kind: "delete-file", content: before, filePath: body };
737
-
738
- if (kind === "delete-folder")
739
- return { kind: "delete-folder", content: before, folderPath: body };
740
-
741
- if (kind === "open-url") {
742
- const url = body.replace(/^<|>$/g, "").trim();
743
- return { kind: "open-url", content: before, url };
744
- }
745
-
746
- if (kind === "generate-pdf") {
747
- try {
748
- const parsed = JSON.parse(body);
749
- return {
750
- kind: "generate-pdf",
751
- content: before,
752
- filePath: parsed.path ?? parsed.filePath ?? "output.pdf",
753
- pdfContent: parsed.content ?? "",
754
- };
755
- } catch {
756
- return { kind: "text", content: text };
757
- }
758
- }
759
-
760
- if (kind === "grep") {
761
- try {
762
- const parsed = JSON.parse(body) as { pattern: string; glob?: string };
763
125
  return {
764
- kind: "grep",
765
- content: before,
766
- pattern: parsed.pattern,
767
- glob: parsed.glob ?? "**/*",
126
+ kind: "changes",
127
+ content: display,
128
+ patches: parsed.patches,
129
+ remainder,
768
130
  };
769
131
  } catch {
770
- return { kind: "grep", content: before, pattern: body, glob: "**/*" };
132
+ return { kind: "text", content: text.trim() };
771
133
  }
772
134
  }
773
135
 
774
- if (kind === "write-file") {
775
- try {
776
- const parsed = JSON.parse(body) as { path: string; content: string };
777
- return {
778
- kind: "write-file",
779
- content: before,
780
- filePath: parsed.path,
781
- fileContent: parsed.content,
782
- };
783
- } catch {
784
- // fall through
785
- }
136
+ if (toolName === "clone") {
137
+ return {
138
+ kind: "clone",
139
+ content: before,
140
+ repoUrl: body.replace(/^<|>$/g, "").trim(),
141
+ remainder,
142
+ };
786
143
  }
787
144
 
788
- if (kind === "search")
789
- return { kind: "search", content: before, query: body };
145
+ // Generic tool
146
+ const tool = registry.get(toolName);
147
+ if (!tool) return { kind: "text", content: text.trim() };
790
148
 
791
- if (kind === "clone") {
792
- const url = body.replace(/^<|>$/g, "").trim();
793
- return { kind: "clone", content: before, repoUrl: url };
794
- }
149
+ const input = tool.parseInput(body);
150
+ if (input === null) return { kind: "text", content: text.trim() };
795
151
 
796
- return { kind: "text", content: text.trim() };
152
+ return {
153
+ kind: "tool",
154
+ toolName,
155
+ input,
156
+ rawInput: body,
157
+ content: before,
158
+ remainder,
159
+ };
797
160
  }
798
161
 
799
162
  // ── Clone tag helper ──────────────────────────────────────────────────────────
@@ -829,31 +192,9 @@ function buildApiMessages(
829
192
  "The tool call was denied by the user. Please respond without using that tool.",
830
193
  };
831
194
  }
832
- const label =
833
- m.toolName === "shell"
834
- ? `shell command \`${m.content}\``
835
- : m.toolName === "fetch"
836
- ? `fetch of ${m.content}`
837
- : m.toolName === "read-file"
838
- ? `read-file of ${m.content}`
839
- : m.toolName === "read-folder"
840
- ? `read-folder of ${m.content}`
841
- : m.toolName === "grep"
842
- ? `grep for "${m.content}"`
843
- : m.toolName === "delete-file"
844
- ? `delete-file of ${m.content}`
845
- : m.toolName === "delete-folder"
846
- ? `delete-folder of ${m.content}`
847
- : m.toolName === "open-url"
848
- ? `open-url ${m.content}`
849
- : m.toolName === "generate-pdf"
850
- ? `generate-pdf to ${m.content}`
851
- : m.toolName === "search"
852
- ? `web search for "${m.content}"`
853
- : `write-file to ${m.content}`;
854
195
  return {
855
196
  role: "user",
856
- content: `Here is the output from the ${label}:\n\n${m.result}\n\nPlease continue your response based on this output.`,
197
+ 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.`,
857
198
  };
858
199
  }
859
200
  return { role: m.role, content: m.content };
@@ -901,7 +242,6 @@ export async function callChat(
901
242
 
902
243
  const controller = new AbortController();
903
244
  const timer = setTimeout(() => controller.abort(), 60_000);
904
-
905
245
  abortSignal?.addEventListener("abort", () => controller.abort());
906
246
 
907
247
  const res = await fetch(url, {
@@ -925,692 +265,3 @@ export async function callChat(
925
265
  return choices[0]?.message.content ?? "";
926
266
  }
927
267
  }
928
-
929
- // ── Clipboard read ────────────────────────────────────────────────────────────
930
-
931
- export function readClipboard(): string {
932
- try {
933
- const platform = process.platform;
934
- if (platform === "win32") {
935
- return execSync("powershell -noprofile -command Get-Clipboard", {
936
- encoding: "utf-8",
937
- timeout: 2000,
938
- })
939
- .replace(/\r\n/g, "\n")
940
- .trimEnd();
941
- }
942
- if (platform === "darwin") {
943
- return execSync("pbpaste", {
944
- encoding: "utf-8",
945
- timeout: 2000,
946
- }).trimEnd();
947
- }
948
- for (const cmd of [
949
- "xclip -selection clipboard -o",
950
- "xsel --clipboard --output",
951
- "wl-paste",
952
- ]) {
953
- try {
954
- return execSync(cmd, { encoding: "utf-8", timeout: 2000 }).trimEnd();
955
- } catch {
956
- continue;
957
- }
958
- }
959
- return "";
960
- } catch {
961
- return "";
962
- }
963
- }
964
-
965
- // ── File system ───────────────────────────────────────────────────────────────
966
-
967
- const SKIP_DIRS = new Set([
968
- "node_modules",
969
- ".git",
970
- "dist",
971
- "build",
972
- ".next",
973
- "out",
974
- "coverage",
975
- "__pycache__",
976
- ".venv",
977
- "venv",
978
- ]);
979
-
980
- export function walkDir(dir: string, base = dir): string[] {
981
- const results: string[] = [];
982
- let entries: string[];
983
- try {
984
- entries = readdirSync(dir, { encoding: "utf-8" });
985
- } catch {
986
- return results;
987
- }
988
- for (const entry of entries) {
989
- if (SKIP_DIRS.has(entry)) continue;
990
- const full = path.join(dir, entry);
991
- const rel = path.relative(base, full).replace(/\\/g, "/");
992
- let isDir = false;
993
- try {
994
- isDir = statSync(full).isDirectory();
995
- } catch {
996
- continue;
997
- }
998
- if (isDir) results.push(...walkDir(full, base));
999
- else results.push(rel);
1000
- }
1001
- return results;
1002
- }
1003
-
1004
- export function applyPatches(repoPath: string, patches: FilePatch[]): void {
1005
- for (const patch of patches) {
1006
- const fullPath = path.join(repoPath, patch.path);
1007
- const dir = path.dirname(fullPath);
1008
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1009
- writeFileSync(fullPath, patch.content, "utf-8");
1010
- }
1011
- }
1012
-
1013
- // ── Tool execution ────────────────────────────────────────────────────────────
1014
-
1015
- export async function runShell(command: string, cwd: string): Promise<string> {
1016
- return new Promise((resolve) => {
1017
- const { spawn } =
1018
- require("child_process") as typeof import("child_process");
1019
- const isWin = process.platform === "win32";
1020
- const shell = isWin ? "cmd.exe" : "/bin/sh";
1021
- const shellFlag = isWin ? "/c" : "-c";
1022
-
1023
- const proc = spawn(shell, [shellFlag, command], {
1024
- cwd,
1025
- env: process.env,
1026
- stdio: ["ignore", "pipe", "pipe"],
1027
- });
1028
-
1029
- const chunks: Buffer[] = [];
1030
- const errChunks: Buffer[] = [];
1031
-
1032
- proc.stdout.on("data", (d: Buffer) => chunks.push(d));
1033
- proc.stderr.on("data", (d: Buffer) => errChunks.push(d));
1034
-
1035
- const killTimer = setTimeout(
1036
- () => {
1037
- proc.kill();
1038
- resolve("(command timed out after 5 minutes)");
1039
- },
1040
- 5 * 60 * 1000,
1041
- );
1042
-
1043
- proc.on("close", (code: number | null) => {
1044
- clearTimeout(killTimer);
1045
- const stdout = Buffer.concat(chunks).toString("utf-8").trim();
1046
- const stderr = Buffer.concat(errChunks).toString("utf-8").trim();
1047
- const combined = [stdout, stderr].filter(Boolean).join("\n");
1048
- resolve(combined || (code === 0 ? "(no output)" : `exit code ${code}`));
1049
- });
1050
-
1051
- proc.on("error", (err: Error) => {
1052
- clearTimeout(killTimer);
1053
- resolve(`Error: ${err.message}`);
1054
- });
1055
- });
1056
- }
1057
-
1058
- // ── HTML table / list extractor ───────────────────────────────────────────────
1059
-
1060
- function stripTags(html: string): string {
1061
- return html
1062
- .replace(/<[^>]+>/g, " ")
1063
- .replace(/&nbsp;/g, " ")
1064
- .replace(/&amp;/g, "&")
1065
- .replace(/&lt;/g, "<")
1066
- .replace(/&gt;/g, ">")
1067
- .replace(/&quot;/g, '"')
1068
- .replace(/&#\d+;/g, " ")
1069
- .replace(/\s+/g, " ")
1070
- .trim();
1071
- }
1072
-
1073
- function extractTables(html: string): string {
1074
- const tables: string[] = [];
1075
- const tableRe = /<table[\s\S]*?<\/table>/gi;
1076
- let tMatch: RegExpExecArray | null;
1077
-
1078
- while ((tMatch = tableRe.exec(html)) !== null) {
1079
- const tableHtml = tMatch[0]!;
1080
- const rows: string[][] = [];
1081
-
1082
- const rowRe = /<tr[\s\S]*?<\/tr>/gi;
1083
- let rMatch: RegExpExecArray | null;
1084
- while ((rMatch = rowRe.exec(tableHtml)) !== null) {
1085
- const cells: string[] = [];
1086
- const cellRe = /<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi;
1087
- let cMatch: RegExpExecArray | null;
1088
- while ((cMatch = cellRe.exec(rMatch[0]!)) !== null) {
1089
- cells.push(stripTags(cMatch[1] ?? ""));
1090
- }
1091
- if (cells.length > 0) rows.push(cells);
1092
- }
1093
-
1094
- if (rows.length < 2) continue;
1095
-
1096
- const cols = Math.max(...rows.map((r) => r.length));
1097
- const padded = rows.map((r) => {
1098
- while (r.length < cols) r.push("");
1099
- return r;
1100
- });
1101
- const widths = Array.from({ length: cols }, (_, ci) =>
1102
- Math.max(...padded.map((r) => (r[ci] ?? "").length), 3),
1103
- );
1104
- const fmt = (r: string[]) =>
1105
- r.map((c, ci) => c.padEnd(widths[ci] ?? 0)).join(" | ");
1106
- const header = fmt(padded[0]!);
1107
- const sep = widths.map((w) => "-".repeat(w)).join("-|-");
1108
- const body = padded.slice(1).map(fmt).join("\n");
1109
- tables.push(`${header}\n${sep}\n${body}`);
1110
- }
1111
-
1112
- return tables.length > 0
1113
- ? `=== TABLES (${tables.length}) ===\n\n${tables.join("\n\n---\n\n")}`
1114
- : "";
1115
- }
1116
-
1117
- function extractLists(html: string): string {
1118
- const lists: string[] = [];
1119
- const listRe = /<[ou]l[\s\S]*?<\/[ou]l>/gi;
1120
- let lMatch: RegExpExecArray | null;
1121
- while ((lMatch = listRe.exec(html)) !== null) {
1122
- const items: string[] = [];
1123
- const itemRe = /<li[^>]*>([\s\S]*?)<\/li>/gi;
1124
- let iMatch: RegExpExecArray | null;
1125
- while ((iMatch = itemRe.exec(lMatch[0]!)) !== null) {
1126
- const text = stripTags(iMatch[1] ?? "");
1127
- if (text.length > 2) items.push(`• ${text}`);
1128
- }
1129
- if (items.length > 1) lists.push(items.join("\n"));
1130
- }
1131
- return lists.length > 0
1132
- ? `=== LISTS ===\n\n${lists.slice(0, 5).join("\n\n")}`
1133
- : "";
1134
- }
1135
-
1136
- export async function fetchUrl(url: string): Promise<string> {
1137
- const res = await fetch(url, {
1138
- headers: {
1139
- "User-Agent":
1140
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
1141
- Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
1142
- "Accept-Language": "en-US,en;q=0.5",
1143
- },
1144
- signal: AbortSignal.timeout(15000),
1145
- });
1146
- if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
1147
-
1148
- const contentType = res.headers.get("content-type") ?? "";
1149
- if (contentType.includes("application/json")) {
1150
- const json = await res.json();
1151
- return JSON.stringify(json, null, 2).slice(0, 8000);
1152
- }
1153
-
1154
- const html = await res.text();
1155
- const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
1156
- const title = titleMatch ? stripTags(titleMatch[1]!) : "No title";
1157
-
1158
- const tables = extractTables(html);
1159
- const lists = extractLists(html);
1160
- const bodyText = stripTags(
1161
- html
1162
- .replace(/<script[\s\S]*?<\/script>/gi, "")
1163
- .replace(/<style[\s\S]*?<\/style>/gi, "")
1164
- .replace(/<nav[\s\S]*?<\/nav>/gi, "")
1165
- .replace(/<footer[\s\S]*?<\/footer>/gi, "")
1166
- .replace(/<header[\s\S]*?<\/header>/gi, ""),
1167
- )
1168
- .replace(/\s{3,}/g, "\n\n")
1169
- .slice(0, 3000);
1170
-
1171
- const parts = [`PAGE: ${title}`, `URL: ${url}`];
1172
- if (tables) parts.push(tables);
1173
- if (lists) parts.push(lists);
1174
- parts.push(`=== TEXT ===\n${bodyText}`);
1175
-
1176
- return parts.join("\n\n");
1177
- }
1178
-
1179
- // ── Web search ────────────────────────────────────────────────────────────────
1180
-
1181
- export async function searchWeb(query: string): Promise<string> {
1182
- const encoded = encodeURIComponent(query);
1183
-
1184
- const ddgUrl = `https://api.duckduckgo.com/?q=${encoded}&format=json&no_html=1&skip_disambig=1`;
1185
- try {
1186
- const res = await fetch(ddgUrl, {
1187
- headers: { "User-Agent": "Lens/1.0" },
1188
- signal: AbortSignal.timeout(8000),
1189
- });
1190
- if (res.ok) {
1191
- const data = (await res.json()) as {
1192
- AbstractText?: string;
1193
- AbstractURL?: string;
1194
- RelatedTopics?: { Text?: string; FirstURL?: string }[];
1195
- Answer?: string;
1196
- Infobox?: { content?: { label: string; value: string }[] };
1197
- };
1198
-
1199
- const parts: string[] = [`Search: ${query}`];
1200
- if (data.Answer) parts.push(`Answer: ${data.Answer}`);
1201
- if (data.AbstractText) {
1202
- parts.push(`Summary: ${data.AbstractText}`);
1203
- if (data.AbstractURL) parts.push(`Source: ${data.AbstractURL}`);
1204
- }
1205
- if (data.Infobox?.content?.length) {
1206
- const fields = data.Infobox.content
1207
- .slice(0, 8)
1208
- .map((f) => ` ${f.label}: ${f.value}`)
1209
- .join("\n");
1210
- parts.push(`Info:\n${fields}`);
1211
- }
1212
- if (data.RelatedTopics?.length) {
1213
- const topics = (data.RelatedTopics as { Text?: string }[])
1214
- .filter((t) => t.Text)
1215
- .slice(0, 5)
1216
- .map((t) => ` - ${t.Text}`)
1217
- .join("\n");
1218
- if (topics) parts.push(`Related:\n${topics}`);
1219
- }
1220
-
1221
- const result = parts.join("\n\n");
1222
- if (result.length > 60) return result;
1223
- }
1224
- } catch {
1225
- // fall through to HTML scrape
1226
- }
1227
-
1228
- try {
1229
- const htmlUrl = `https://html.duckduckgo.com/html/?q=${encoded}`;
1230
- const res = await fetch(htmlUrl, {
1231
- headers: {
1232
- "User-Agent":
1233
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
1234
- Accept: "text/html",
1235
- },
1236
- signal: AbortSignal.timeout(10000),
1237
- });
1238
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
1239
- const html = await res.text();
1240
-
1241
- const snippets: string[] = [];
1242
- const snippetRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
1243
- let m: RegExpExecArray | null;
1244
- while ((m = snippetRe.exec(html)) !== null && snippets.length < 6) {
1245
- const text = m[1]!
1246
- .replace(/<[^>]+>/g, " ")
1247
- .replace(/&nbsp;/g, " ")
1248
- .replace(/&amp;/g, "&")
1249
- .replace(/&lt;/g, "<")
1250
- .replace(/&gt;/g, ">")
1251
- .replace(/&quot;/g, '"')
1252
- .replace(/\s+/g, " ")
1253
- .trim();
1254
- if (text.length > 20) snippets.push(`- ${text}`);
1255
- }
1256
-
1257
- const links: string[] = [];
1258
- const linkRe = /class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
1259
- while ((m = linkRe.exec(html)) !== null && links.length < 5) {
1260
- const title = m[2]!.replace(/<[^>]+>/g, "").trim();
1261
- const href = m[1]!;
1262
- if (title && href) links.push(` ${title} \u2014 ${href}`);
1263
- }
1264
-
1265
- if (snippets.length === 0 && links.length === 0) {
1266
- return `No results found for: ${query}`;
1267
- }
1268
-
1269
- const parts = [`Search results for: ${query}`];
1270
- if (snippets.length > 0) parts.push(`Snippets:\n${snippets.join("\n")}`);
1271
- if (links.length > 0) parts.push(`Links:\n${links.join("\n")}`);
1272
- return parts.join("\n\n");
1273
- } catch (err) {
1274
- return `Search failed: ${err instanceof Error ? err.message : String(err)}`;
1275
- }
1276
- }
1277
-
1278
- // ── File tools ────────────────────────────────────────────────────────────────
1279
-
1280
- export function readFile(filePath: string, repoPath: string): string {
1281
- const candidates = path.isAbsolute(filePath)
1282
- ? [filePath]
1283
- : [filePath, path.join(repoPath, filePath)];
1284
- for (const candidate of candidates) {
1285
- if (existsSync(candidate)) {
1286
- try {
1287
- const content = readFileSync(candidate, "utf-8");
1288
- const lines = content.split("\n").length;
1289
- return `File: ${candidate} (${lines} lines)\n\n${content.slice(0, 8000)}${
1290
- content.length > 8000 ? "\n\n… (truncated)" : ""
1291
- }`;
1292
- } catch (err) {
1293
- return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
1294
- }
1295
- }
1296
- }
1297
- return `File not found: ${filePath}. If reading from a cloned repo, use the full absolute path e.g. C:\\Users\\...\\repo\\file.ts`;
1298
- }
1299
-
1300
- export function readFolder(folderPath: string, repoPath: string): string {
1301
- // Strip any command prefixes the model may have included (e.g. "ls src" → "src", "read src" → "src")
1302
- const sanitized = folderPath
1303
- .replace(/^(ls|dir|find|tree|cat|read|ls -la?|ls -al?)\s+/i, "")
1304
- .trim();
1305
-
1306
- const candidates = path.isAbsolute(sanitized)
1307
- ? [sanitized]
1308
- : [sanitized, path.join(repoPath, sanitized)];
1309
-
1310
- for (const candidate of candidates) {
1311
- if (!existsSync(candidate)) continue;
1312
- let stat: ReturnType<typeof statSync>;
1313
- try {
1314
- stat = statSync(candidate);
1315
- } catch {
1316
- continue;
1317
- }
1318
- if (!stat.isDirectory()) {
1319
- return `Not a directory: ${candidate}. Use read-file to read a file.`;
1320
- }
1321
-
1322
- let entries: string[];
1323
- try {
1324
- entries = readdirSync(candidate, { encoding: "utf-8" });
1325
- } catch (err) {
1326
- return `Error reading folder: ${err instanceof Error ? err.message : String(err)}`;
1327
- }
1328
-
1329
- const files: string[] = [];
1330
- const subfolders: string[] = [];
1331
-
1332
- for (const entry of entries) {
1333
- if (entry.startsWith(".") && entry !== ".env") continue;
1334
- const full = path.join(candidate, entry);
1335
- try {
1336
- if (statSync(full).isDirectory()) {
1337
- subfolders.push(`${entry}/`);
1338
- } else {
1339
- files.push(entry);
1340
- }
1341
- } catch {
1342
- // skip unreadable entries
1343
- }
1344
- }
1345
-
1346
- const total = files.length + subfolders.length;
1347
- const lines: string[] = [`Folder: ${candidate} (${total} entries)`, ""];
1348
-
1349
- if (files.length > 0) {
1350
- lines.push("Files:");
1351
- files.forEach((f) => lines.push(` ${f}`));
1352
- }
1353
-
1354
- if (subfolders.length > 0) {
1355
- if (files.length > 0) lines.push("");
1356
- lines.push("Subfolders:");
1357
- subfolders.forEach((d) => lines.push(` ${d}`));
1358
- }
1359
-
1360
- if (total === 0) {
1361
- lines.push("(empty folder)");
1362
- }
1363
-
1364
- return lines.join("\n");
1365
- }
1366
-
1367
- return `Folder not found: ${sanitized}`;
1368
- }
1369
-
1370
- export function grepFiles(
1371
- pattern: string,
1372
- glob: string,
1373
- repoPath: string,
1374
- ): string {
1375
- let regex: RegExp;
1376
- try {
1377
- regex = new RegExp(pattern, "i");
1378
- } catch {
1379
- regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
1380
- }
1381
-
1382
- const globToFilter = (g: string): ((rel: string) => boolean) => {
1383
- const cleaned = g.replace(/^\*\*\//, "");
1384
- const parts = cleaned.split("/");
1385
- const ext = parts[parts.length - 1];
1386
- const prefix = parts.slice(0, -1).join("/");
1387
-
1388
- return (rel: string) => {
1389
- if (ext?.startsWith("*.")) {
1390
- const extSuffix = ext.slice(1);
1391
- if (!rel.endsWith(extSuffix)) return false;
1392
- } else if (ext && !ext.includes("*")) {
1393
- if (!rel.endsWith(ext)) return false;
1394
- }
1395
- if (prefix && !prefix.includes("*")) {
1396
- if (!rel.startsWith(prefix)) return false;
1397
- }
1398
- return true;
1399
- };
1400
- };
1401
-
1402
- const filter = globToFilter(glob);
1403
- const allFiles = walkDir(repoPath);
1404
- const matchedFiles = allFiles.filter(filter);
1405
-
1406
- if (matchedFiles.length === 0) {
1407
- return `No files matched glob: ${glob}`;
1408
- }
1409
-
1410
- const results: string[] = [];
1411
- let totalMatches = 0;
1412
-
1413
- for (const relPath of matchedFiles) {
1414
- const fullPath = path.join(repoPath, relPath);
1415
- let content: string;
1416
- try {
1417
- content = readFileSync(fullPath, "utf-8");
1418
- } catch {
1419
- continue;
1420
- }
1421
-
1422
- const lines = content.split("\n");
1423
- const fileMatches: string[] = [];
1424
-
1425
- lines.forEach((line, i) => {
1426
- if (regex.test(line)) {
1427
- fileMatches.push(` ${i + 1}: ${line.trimEnd()}`);
1428
- totalMatches++;
1429
- }
1430
- });
1431
-
1432
- if (fileMatches.length > 0) {
1433
- results.push(`${relPath}\n${fileMatches.join("\n")}`);
1434
- }
1435
-
1436
- if (totalMatches >= 200) {
1437
- results.push("(truncated — too many matches)");
1438
- break;
1439
- }
1440
- }
1441
-
1442
- if (results.length === 0) {
1443
- return `No matches for /${pattern}/ in ${matchedFiles.length} file(s) matching ${glob}`;
1444
- }
1445
-
1446
- return `grep /${pattern}/ ${glob} — ${totalMatches} match(es) in ${results.length} file(s)\n\n${results.join("\n\n")}`;
1447
- }
1448
-
1449
- export function writeFile(
1450
- filePath: string,
1451
- content: string,
1452
- repoPath: string,
1453
- ): string {
1454
- const fullPath = path.isAbsolute(filePath)
1455
- ? filePath
1456
- : path.join(repoPath, filePath);
1457
- try {
1458
- const dir = path.dirname(fullPath);
1459
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1460
- writeFileSync(fullPath, content, "utf-8");
1461
- const lines = content.split("\n").length;
1462
- return `Written: ${fullPath} (${lines} lines, ${content.length} bytes)`;
1463
- } catch (err) {
1464
- return `Error writing file: ${err instanceof Error ? err.message : String(err)}`;
1465
- }
1466
- }
1467
-
1468
- export function deleteFile(filePath: string, repoPath: string): string {
1469
- const fullPath = path.isAbsolute(filePath)
1470
- ? filePath
1471
- : path.join(repoPath, filePath);
1472
- try {
1473
- if (!existsSync(fullPath)) return `File not found: ${fullPath}`;
1474
- const { unlinkSync } = require("fs") as typeof import("fs");
1475
- unlinkSync(fullPath);
1476
- return `Deleted: ${fullPath}`;
1477
- } catch (err) {
1478
- return `Error deleting file: ${err instanceof Error ? err.message : String(err)}`;
1479
- }
1480
- }
1481
-
1482
- export function deleteFolder(folderPath: string, repoPath: string): string {
1483
- const fullPath = path.isAbsolute(folderPath)
1484
- ? folderPath
1485
- : path.join(repoPath, folderPath);
1486
- try {
1487
- if (!existsSync(fullPath)) return `Folder not found: ${fullPath}`;
1488
- const { rmSync } = require("fs") as typeof import("fs");
1489
- rmSync(fullPath, { recursive: true, force: true });
1490
- return `Deleted folder: ${fullPath}`;
1491
- } catch (err) {
1492
- return `Error deleting folder: ${err instanceof Error ? err.message : String(err)}`;
1493
- }
1494
- }
1495
-
1496
- export function openUrl(url: string): string {
1497
- try {
1498
- const { execSync } =
1499
- require("child_process") as typeof import("child_process");
1500
- const platform = process.platform;
1501
- if (platform === "win32") {
1502
- execSync(`start "" "${url}"`, { stdio: "ignore" });
1503
- } else if (platform === "darwin") {
1504
- execSync(`open "${url}"`, { stdio: "ignore" });
1505
- } else {
1506
- execSync(`xdg-open "${url}"`, { stdio: "ignore" });
1507
- }
1508
- return `Opened: ${url}`;
1509
- } catch (err) {
1510
- return `Error opening URL: ${err instanceof Error ? err.message : String(err)}`;
1511
- }
1512
- }
1513
-
1514
- export function generatePdf(
1515
- filePath: string,
1516
- content: string,
1517
- repoPath: string,
1518
- ): string {
1519
- const fullPath = path.isAbsolute(filePath)
1520
- ? filePath
1521
- : path.join(repoPath, filePath);
1522
-
1523
- try {
1524
- const dir = path.dirname(fullPath);
1525
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1526
-
1527
- const escaped = content
1528
- .replace(/\\/g, "\\\\")
1529
- .replace(/"""/g, '\\"\\"\\"')
1530
- .replace(/\r/g, "");
1531
-
1532
- const script = `
1533
- import sys
1534
- try:
1535
- from reportlab.lib.pagesizes import letter
1536
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
1537
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
1538
- from reportlab.lib.units import inch
1539
- from reportlab.lib import colors
1540
- except ImportError:
1541
- import subprocess
1542
- subprocess.check_call([sys.executable, "-m", "pip", "install", "reportlab", "--break-system-packages", "-q"])
1543
- from reportlab.lib.pagesizes import letter
1544
- from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable
1545
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
1546
- from reportlab.lib.units import inch
1547
- from reportlab.lib import colors
1548
-
1549
- doc = SimpleDocTemplate(
1550
- r"""${fullPath}""",
1551
- pagesize=letter,
1552
- rightMargin=inch,
1553
- leftMargin=inch,
1554
- topMargin=inch,
1555
- bottomMargin=inch,
1556
- )
1557
-
1558
- styles = getSampleStyleSheet()
1559
- styles.add(ParagraphStyle(name="H1", parent=styles["Heading1"], fontSize=22, spaceAfter=10))
1560
- styles.add(ParagraphStyle(name="H2", parent=styles["Heading2"], fontSize=16, spaceAfter=8))
1561
- styles.add(ParagraphStyle(name="H3", parent=styles["Heading3"], fontSize=13, spaceAfter=6))
1562
- styles.add(ParagraphStyle(name="Body", parent=styles["Normal"], fontSize=11, leading=16, spaceAfter=8))
1563
- styles.add(ParagraphStyle(name="Bullet", parent=styles["Normal"], fontSize=11, leading=16, leftIndent=20, spaceAfter=4, bulletIndent=10))
1564
-
1565
- raw = """${escaped}"""
1566
-
1567
- story = []
1568
- for line in raw.split("\\n"):
1569
- s = line.rstrip()
1570
- if s.startswith("### "):
1571
- story.append(Paragraph(s[4:], styles["H3"]))
1572
- elif s.startswith("## "):
1573
- story.append(Spacer(1, 6))
1574
- story.append(Paragraph(s[3:], styles["H2"]))
1575
- story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey, spaceAfter=4))
1576
- elif s.startswith("# "):
1577
- story.append(Paragraph(s[2:], styles["H1"]))
1578
- story.append(HRFlowable(width="100%", thickness=1, color=colors.black, spaceAfter=6))
1579
- elif s.startswith("- ") or s.startswith("* "):
1580
- story.append(Paragraph(u"\\u2022 " + s[2:], styles["Bullet"]))
1581
- elif s.startswith("---"):
1582
- story.append(HRFlowable(width="100%", thickness=0.5, color=colors.grey, spaceAfter=4))
1583
- elif s == "":
1584
- story.append(Spacer(1, 6))
1585
- else:
1586
- import re
1587
- s = re.sub(r"\\*\\*(.+?)\\*\\*", r"<b>\\1</b>", s)
1588
- s = re.sub(r"\\*(.+?)\\*", r"<i>\\1</i>", s)
1589
- s = re.sub(r"\`(.+?)\`", r"<font name='Courier'>\\1</font>", s)
1590
- story.append(Paragraph(s, styles["Body"]))
1591
-
1592
- doc.build(story)
1593
- print("OK")
1594
- `
1595
- .replace("${fullPath}", fullPath.replace(/\\/g, "/"))
1596
- .replace("${escaped}", escaped);
1597
-
1598
- const os = require("os") as typeof import("os");
1599
- const tmpFile = path.join(os.tmpdir(), `lens_pdf_${Date.now()}.py`);
1600
- writeFileSync(tmpFile, script, "utf-8");
1601
-
1602
- const { execSync } =
1603
- require("child_process") as typeof import("child_process");
1604
- execSync(`python "${tmpFile}"`, { stdio: "pipe" });
1605
-
1606
- try {
1607
- require("fs").unlinkSync(tmpFile);
1608
- } catch {
1609
- /* ignore cleanup errors */
1610
- }
1611
-
1612
- return `PDF generated: ${fullPath}`;
1613
- } catch (err) {
1614
- return `Error generating PDF: ${err instanceof Error ? err.message : String(err)}`;
1615
- }
1616
- }