@oh-my-pi/pi-coding-agent 14.5.12 → 14.5.14

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 (112) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/commit/pipeline.ts +4 -3
  5. package/src/config/model-equivalence.ts +49 -16
  6. package/src/config/model-registry.ts +100 -25
  7. package/src/config/model-resolver.ts +29 -15
  8. package/src/config/settings-schema.ts +20 -6
  9. package/src/config/settings.ts +9 -8
  10. package/src/config.ts +18 -6
  11. package/src/eval/backend.ts +43 -0
  12. package/src/eval/eval.lark +43 -0
  13. package/src/eval/index.ts +5 -0
  14. package/src/eval/js/context-manager.ts +717 -0
  15. package/src/eval/js/executor.ts +131 -0
  16. package/src/eval/js/index.ts +46 -0
  17. package/src/eval/js/prelude.ts +2 -0
  18. package/src/eval/js/prelude.txt +84 -0
  19. package/src/eval/js/tool-bridge.ts +124 -0
  20. package/src/eval/parse.ts +337 -0
  21. package/src/{ipy → eval/py}/executor.ts +2 -180
  22. package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
  23. package/src/eval/py/index.ts +58 -0
  24. package/src/{ipy → eval/py}/kernel.ts +9 -45
  25. package/src/{ipy → eval/py}/prelude.py +39 -227
  26. package/src/eval/types.ts +48 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +8 -10
  29. package/src/extensibility/extensions/types.ts +2 -3
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/lsp/client.ts +9 -0
  32. package/src/lsp/index.ts +395 -0
  33. package/src/lsp/types.ts +15 -4
  34. package/src/main.ts +35 -14
  35. package/src/mcp/manager.ts +22 -0
  36. package/src/mcp/oauth-flow.ts +1 -1
  37. package/src/memories/index.ts +1 -1
  38. package/src/modes/acp/acp-event-mapper.ts +1 -1
  39. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  40. package/src/modes/components/login-dialog.ts +1 -1
  41. package/src/modes/components/oauth-selector.ts +2 -1
  42. package/src/modes/components/tool-execution.ts +3 -4
  43. package/src/modes/controllers/command-controller.ts +28 -8
  44. package/src/modes/controllers/input-controller.ts +4 -4
  45. package/src/modes/controllers/selector-controller.ts +2 -1
  46. package/src/modes/interactive-mode.ts +4 -5
  47. package/src/modes/rpc/rpc-client.ts +9 -0
  48. package/src/modes/rpc/rpc-mode.ts +6 -0
  49. package/src/modes/rpc/rpc-types.ts +9 -0
  50. package/src/modes/types.ts +3 -3
  51. package/src/modes/utils/ui-helpers.ts +2 -2
  52. package/src/prompts/system/system-prompt.md +3 -3
  53. package/src/prompts/tools/eval.md +92 -0
  54. package/src/prompts/tools/lsp.md +7 -3
  55. package/src/sdk.ts +64 -35
  56. package/src/session/agent-session.ts +152 -46
  57. package/src/session/messages.ts +1 -1
  58. package/src/slash-commands/builtin-registry.ts +1 -1
  59. package/src/system-prompt.ts +34 -66
  60. package/src/task/agents.ts +4 -5
  61. package/src/task/executor.ts +5 -9
  62. package/src/tools/archive-reader.ts +9 -3
  63. package/src/tools/browser/launch.ts +22 -0
  64. package/src/tools/browser/readable.ts +11 -6
  65. package/src/tools/browser/registry.ts +25 -244
  66. package/src/tools/browser/render.ts +1 -1
  67. package/src/tools/browser/tab-protocol.ts +101 -0
  68. package/src/tools/browser/tab-supervisor.ts +429 -0
  69. package/src/tools/browser/tab-worker-entry.ts +21 -0
  70. package/src/tools/browser/tab-worker.ts +1006 -0
  71. package/src/tools/browser.ts +17 -32
  72. package/src/tools/checkpoint.ts +2 -2
  73. package/src/tools/{python.ts → eval.ts} +324 -315
  74. package/src/tools/exit-plan-mode.ts +1 -1
  75. package/src/tools/image-gen.ts +2 -2
  76. package/src/tools/index.ts +62 -100
  77. package/src/tools/read.ts +0 -6
  78. package/src/tools/recipe/runners/pkg.ts +34 -32
  79. package/src/tools/renderers.ts +2 -2
  80. package/src/tools/resolve.ts +7 -2
  81. package/src/tools/todo-write.ts +0 -1
  82. package/src/tools/tool-timeouts.ts +2 -2
  83. package/src/tools/write.ts +8 -1
  84. package/src/utils/markit.ts +15 -7
  85. package/src/utils/tools-manager.ts +5 -5
  86. package/src/web/scrapers/crossref.ts +3 -3
  87. package/src/web/scrapers/devto.ts +1 -1
  88. package/src/web/scrapers/discourse.ts +5 -5
  89. package/src/web/scrapers/firefox-addons.ts +1 -1
  90. package/src/web/scrapers/flathub.ts +2 -2
  91. package/src/web/scrapers/gitlab.ts +1 -1
  92. package/src/web/scrapers/go-pkg.ts +2 -2
  93. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  94. package/src/web/scrapers/mastodon.ts +9 -9
  95. package/src/web/scrapers/mdn.ts +11 -7
  96. package/src/web/scrapers/pub-dev.ts +1 -1
  97. package/src/web/scrapers/rawg.ts +3 -3
  98. package/src/web/scrapers/readthedocs.ts +1 -1
  99. package/src/web/scrapers/spdx.ts +1 -1
  100. package/src/web/scrapers/stackoverflow.ts +2 -2
  101. package/src/web/scrapers/types.ts +53 -39
  102. package/src/web/scrapers/w3c.ts +1 -1
  103. package/src/web/search/index.ts +5 -5
  104. package/src/web/search/provider.ts +121 -39
  105. package/src/web/search/providers/gemini.ts +4 -4
  106. package/src/web/search/render.ts +2 -2
  107. package/src/ipy/modules.ts +0 -144
  108. package/src/prompts/tools/python.md +0 -57
  109. package/src/tools/browser/vm.ts +0 -792
  110. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  111. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  112. /package/src/{ipy → eval/py}/runtime.ts +0 -0
@@ -77,12 +77,12 @@ function formatCategory(topic: DiscourseTopic): string | null {
77
77
  return parts.length ? parts.join(" ") : null;
78
78
  }
79
79
 
80
- function formatPostBody(post: DiscoursePost): string {
80
+ async function formatPostBody(post: DiscoursePost): Promise<string> {
81
81
  const raw = post.raw?.trim();
82
82
  if (raw) return raw;
83
83
  const cooked = post.cooked?.trim();
84
84
  if (!cooked) return "";
85
- return htmlToBasicMarkdown(cooked);
85
+ return await htmlToBasicMarkdown(cooked);
86
86
  }
87
87
 
88
88
  function buildTopicUrl(baseUrl: string, topicId: string): string {
@@ -168,9 +168,9 @@ export const handleDiscourse: SpecialHandler = async (
168
168
  md += "\n";
169
169
 
170
170
  const description = topic.excerpt
171
- ? htmlToBasicMarkdown(topic.excerpt)
171
+ ? await htmlToBasicMarkdown(topic.excerpt)
172
172
  : posts.length
173
- ? formatPostBody(posts[0])
173
+ ? await formatPostBody(posts[0])
174
174
  : "";
175
175
  if (description) {
176
176
  md += `## Description\n\n${description}\n\n`;
@@ -182,7 +182,7 @@ export const handleDiscourse: SpecialHandler = async (
182
182
  const author = formatAuthor({ name: post.name, username: post.username });
183
183
  const date = formatIsoDate(post.created_at);
184
184
  const likes = post.like_count ?? 0;
185
- const content = formatPostBody(post);
185
+ const content = await formatPostBody(post);
186
186
  const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
187
187
 
188
188
  md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
@@ -112,7 +112,7 @@ export const handleFirefoxAddons: SpecialHandler = async (
112
112
  const name = getLocalizedText(data.name, defaultLocale) ?? slug;
113
113
  const summary = getLocalizedText(data.summary, defaultLocale);
114
114
  const descriptionRaw = getLocalizedText(data.description, defaultLocale);
115
- const description = descriptionRaw ? htmlToBasicMarkdown(descriptionRaw) : undefined;
115
+ const description = descriptionRaw ? await htmlToBasicMarkdown(descriptionRaw) : undefined;
116
116
 
117
117
  const authors = (data.authors ?? [])
118
118
  .map(author => author.name ?? "")
@@ -170,7 +170,7 @@ export const handleFlathub: SpecialHandler = async (
170
170
  }
171
171
 
172
172
  if (app.description) {
173
- const description = htmlToBasicMarkdown(app.description);
173
+ const description = await htmlToBasicMarkdown(app.description);
174
174
  if (description) md += `\n## Description\n\n${description}\n`;
175
175
  }
176
176
 
@@ -204,7 +204,7 @@ export const handleFlathub: SpecialHandler = async (
204
204
  md += `${line}\n`;
205
205
 
206
206
  if (release.description) {
207
- const releaseDesc = htmlToBasicMarkdown(release.description).replace(/\n+/g, " ").trim();
207
+ const releaseDesc = (await htmlToBasicMarkdown(release.description)).replace(/\n+/g, " ").trim();
208
208
  if (releaseDesc) md += ` - ${releaseDesc}\n`;
209
209
  }
210
210
  }
@@ -259,7 +259,7 @@ async function renderGitLabIssue(
259
259
  }
260
260
 
261
261
  md += `\n---\n\n## Description\n\n`;
262
- md += issue.description ? htmlToBasicMarkdown(issue.description) : "*No description*";
262
+ md += issue.description ? await htmlToBasicMarkdown(issue.description) : "*No description*";
263
263
 
264
264
  return { content: md, ok: true };
265
265
  }
@@ -159,7 +159,7 @@ export const handleGoPkg: SpecialHandler = async (
159
159
  // Get overview paragraph
160
160
  const overview = docSection.querySelector(".go-Message");
161
161
  if (overview) {
162
- const overviewMd = htmlToBasicMarkdown(overview.innerHTML);
162
+ const overviewMd = await htmlToBasicMarkdown(overview.innerHTML);
163
163
  sections.push(overviewMd);
164
164
  sections.push("");
165
165
  }
@@ -172,7 +172,7 @@ export const handleGoPkg: SpecialHandler = async (
172
172
  const docParts: string[] = [];
173
173
  for (let i = 0; i < Math.min(3, paragraphs.length); i++) {
174
174
  const p = paragraphs[i];
175
- const text = htmlToBasicMarkdown(p.innerHTML).trim();
175
+ const text = (await htmlToBasicMarkdown(p.innerHTML)).trim();
176
176
  if (text) {
177
177
  docParts.push(text);
178
178
  }
@@ -108,7 +108,7 @@ export const handleJetBrainsMarketplace: SpecialHandler = async (
108
108
 
109
109
  const vendorName = plugin.vendor?.name ?? plugin.vendor?.publicName;
110
110
  const descriptionSource = plugin.description ?? plugin.preview ?? "";
111
- const description = descriptionSource ? htmlToBasicMarkdown(descriptionSource) : "";
111
+ const description = descriptionSource ? await htmlToBasicMarkdown(descriptionSource) : "";
112
112
  const tags = (plugin.tags ?? []).map(tag => tag.name).filter(Boolean) as string[];
113
113
  const rating = extractRating(plugin);
114
114
  const buildCompatibility = update ? formatBuildCompatibility(update) : null;
@@ -89,11 +89,11 @@ function formatDate(isoDate: string): string {
89
89
  /**
90
90
  * Format a status/post as markdown
91
91
  */
92
- function formatStatus(status: MastodonStatus, isReblog = false): string {
92
+ async function formatStatus(status: MastodonStatus, isReblog = false): Promise<string> {
93
93
  // Handle reblogs (boosts)
94
94
  if (status.reblog && !isReblog) {
95
95
  let md = `🔁 **${status.account.display_name || status.account.username}** boosted:\n\n`;
96
- md += formatStatus(status.reblog, true);
96
+ md += await formatStatus(status.reblog, true);
97
97
  return md;
98
98
  }
99
99
 
@@ -116,7 +116,7 @@ function formatStatus(status: MastodonStatus, isReblog = false): string {
116
116
  }
117
117
 
118
118
  // Main content (convert HTML to markdown)
119
- const content = htmlToBasicMarkdown(status.content);
119
+ const content = await htmlToBasicMarkdown(status.content);
120
120
  md += `${content}\n\n`;
121
121
 
122
122
  // Poll
@@ -152,7 +152,7 @@ function formatStatus(status: MastodonStatus, isReblog = false): string {
152
152
  /**
153
153
  * Format an account/profile as markdown
154
154
  */
155
- function formatAccount(account: MastodonAccount): string {
155
+ async function formatAccount(account: MastodonAccount): Promise<string> {
156
156
  let md = `# ${account.display_name || account.username}\n\n`;
157
157
 
158
158
  md += `**@${account.acct}**`;
@@ -161,7 +161,7 @@ function formatAccount(account: MastodonAccount): string {
161
161
 
162
162
  // Bio
163
163
  if (account.note) {
164
- const bio = htmlToBasicMarkdown(account.note);
164
+ const bio = await htmlToBasicMarkdown(account.note);
165
165
  if (bio && bio !== account.display_name) {
166
166
  md += `${bio}\n\n`;
167
167
  }
@@ -179,7 +179,7 @@ function formatAccount(account: MastodonAccount): string {
179
179
  if (account.fields && account.fields.length > 0) {
180
180
  md += "\n**Profile Fields:**\n";
181
181
  for (const field of account.fields) {
182
- const value = htmlToBasicMarkdown(field.value);
182
+ const value = await htmlToBasicMarkdown(field.value);
183
183
  md += `- **${field.name}:** ${value}\n`;
184
184
  }
185
185
  }
@@ -228,7 +228,7 @@ export const handleMastodon: SpecialHandler = async (
228
228
  const status = tryParseJson<MastodonStatus>(result.content);
229
229
  if (!status) return null;
230
230
 
231
- const md = formatStatus(status);
231
+ const md = await formatStatus(status);
232
232
 
233
233
  return buildResult(md, {
234
234
  url,
@@ -263,7 +263,7 @@ export const handleMastodon: SpecialHandler = async (
263
263
  signal,
264
264
  });
265
265
 
266
- let md = formatAccount(account);
266
+ let md = await formatAccount(account);
267
267
 
268
268
  if (statusesResult.ok) {
269
269
  const statuses = tryParseJson<MastodonStatus[]>(statusesResult.content);
@@ -271,7 +271,7 @@ export const handleMastodon: SpecialHandler = async (
271
271
  md += "\n---\n\n## Recent Posts\n\n";
272
272
  for (const status of statuses.slice(0, 5)) {
273
273
  md += `### ${formatDate(status.created_at)}\n\n`;
274
- const content = htmlToBasicMarkdown(status.content);
274
+ const content = await htmlToBasicMarkdown(status.content);
275
275
  md += `${content}\n\n`;
276
276
  md += `\uD83D\uDCAC ${status.replies_count} \u00B7 \uD83D\uDD01 ${status.reblogs_count} \u00B7 \u2B50 ${status.favourites_count}\n\n`;
277
277
  }
@@ -29,7 +29,7 @@ interface MDNDoc {
29
29
  /**
30
30
  * Convert MDN body sections to markdown
31
31
  */
32
- function convertMDNBody(sections: MDNSection[]): string {
32
+ async function convertMDNBody(sections: MDNSection[]): Promise<string> {
33
33
  const parts: string[] = [];
34
34
 
35
35
  for (const section of sections) {
@@ -38,7 +38,7 @@ function convertMDNBody(sections: MDNSection[]): string {
38
38
  switch (type) {
39
39
  case "prose":
40
40
  if (value.content) {
41
- const markdown = htmlToBasicMarkdown(value.content);
41
+ const markdown = await htmlToBasicMarkdown(value.content);
42
42
  if (value.title) {
43
43
  const level = value.isH3 ? "###" : "##";
44
44
  parts.push(`${level} ${value.title}\n\n${markdown}`);
@@ -74,7 +74,7 @@ function convertMDNBody(sections: MDNSection[]): string {
74
74
  if (value.items) {
75
75
  for (const item of value.items) {
76
76
  parts.push(`**${item.term}**`);
77
- const desc = htmlToBasicMarkdown(item.description);
77
+ const desc = await htmlToBasicMarkdown(item.description);
78
78
  parts.push(desc);
79
79
  }
80
80
  }
@@ -83,9 +83,13 @@ function convertMDNBody(sections: MDNSection[]): string {
83
83
  case "table":
84
84
  if (value.rows && value.rows.length > 0) {
85
85
  // Simple markdown table
86
- const header = value.rows[0].map(cell => htmlToBasicMarkdown(cell)).join(" | ");
86
+ const header = (await Promise.all(value.rows[0].map(cell => htmlToBasicMarkdown(cell)))).join(" | ");
87
87
  const separator = value.rows[0].map(() => "---").join(" | ");
88
- const bodyRows = value.rows.slice(1).map(row => row.map(cell => htmlToBasicMarkdown(cell)).join(" | "));
88
+ const bodyRows = await Promise.all(
89
+ value.rows
90
+ .slice(1)
91
+ .map(async row => (await Promise.all(row.map(cell => htmlToBasicMarkdown(cell)))).join(" | ")),
92
+ );
89
93
 
90
94
  parts.push(`| ${header} |`);
91
95
  parts.push(`| ${separator} |`);
@@ -144,12 +148,12 @@ export const handleMDN: SpecialHandler = async (url: string, timeout: number, si
144
148
  parts.push(`# ${doc.title}`);
145
149
 
146
150
  if (doc.summary) {
147
- const summary = htmlToBasicMarkdown(doc.summary);
151
+ const summary = await htmlToBasicMarkdown(doc.summary);
148
152
  parts.push(summary);
149
153
  }
150
154
 
151
155
  if (doc.body && doc.body.length > 0) {
152
- const bodyMarkdown = convertMDNBody(doc.body);
156
+ const bodyMarkdown = await convertMDNBody(doc.body);
153
157
  parts.push(bodyMarkdown);
154
158
  }
155
159
 
@@ -125,7 +125,7 @@ export const handlePubDev: SpecialHandler = async (url: string, timeout: number,
125
125
  /<div[^>]*class="[^"]*markdown-body[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
126
126
  );
127
127
  if (readmeMatch) {
128
- const readme = htmlToBasicMarkdown(readmeMatch[1]);
128
+ const readme = await htmlToBasicMarkdown(readmeMatch[1]);
129
129
 
130
130
  if (readme.length > 100) {
131
131
  md += `## README\n\n${readme}\n`;
@@ -63,7 +63,7 @@ export const handleRawg: SpecialHandler = async (
63
63
  md += `**RAWG:** https://rawg.io/games/${encodeURIComponent(slug)}\n`;
64
64
  md += "\n";
65
65
 
66
- const description = extractDescription(game);
66
+ const description = await extractDescription(game);
67
67
  if (description) {
68
68
  md += `## Description\n\n${description}\n`;
69
69
  }
@@ -91,11 +91,11 @@ function requiresApiKey(game: RawgGameResponse): boolean {
91
91
  return detail.includes("api key") || detail.includes("key is required") || detail.includes("apikey");
92
92
  }
93
93
 
94
- function extractDescription(game: RawgGameResponse): string | null {
94
+ async function extractDescription(game: RawgGameResponse): Promise<string | null> {
95
95
  if (game.description_raw) return game.description_raw.trim();
96
96
  if (!game.description) return null;
97
97
 
98
- const markdown = htmlToBasicMarkdown(game.description).trim();
98
+ const markdown = (await htmlToBasicMarkdown(game.description)).trim();
99
99
  return markdown || null;
100
100
  }
101
101
 
@@ -101,7 +101,7 @@ export const handleReadTheDocs: SpecialHandler = async (
101
101
  // If no raw source, convert HTML to markdown
102
102
  if (!content && mainContent) {
103
103
  const html = mainContent.innerHTML;
104
- content = htmlToBasicMarkdown(html);
104
+ content = await htmlToBasicMarkdown(html);
105
105
  }
106
106
 
107
107
  if (!content) {
@@ -94,7 +94,7 @@ export const handleSpdx: SpecialHandler = async (
94
94
  const licenseText = license.licenseText
95
95
  ? license.licenseText
96
96
  : license.licenseTextHtml
97
- ? htmlToBasicMarkdown(license.licenseTextHtml)
97
+ ? await htmlToBasicMarkdown(license.licenseTextHtml)
98
98
  : null;
99
99
 
100
100
  if (licenseText) {
@@ -90,7 +90,7 @@ export const handleStackOverflow: SpecialHandler = async (
90
90
  md += question.is_answered ? " (Answered)" : "";
91
91
  md += `\n**Tags:** ${question.tags.join(", ")}\n`;
92
92
  md += `**Asked by:** ${question.owner.display_name} · ${formatIsoDate(question.creation_date * 1000)}\n\n`;
93
- md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
93
+ md += `---\n\n## Question\n\n${await htmlToBasicMarkdown(question.body)}\n\n`;
94
94
 
95
95
  // Fetch answers
96
96
  const aUrl = `https://api.stackexchange.com/2.3/questions/${questionId}/answers?order=desc&sort=votes&site=${site}&filter=withbody`;
@@ -103,7 +103,7 @@ export const handleStackOverflow: SpecialHandler = async (
103
103
  for (const answer of aData.items.slice(0, 5)) {
104
104
  const accepted = answer.is_accepted ? " (Accepted)" : "";
105
105
  md += `### Score: ${answer.score}${accepted} · by ${answer.owner.display_name}\n\n`;
106
- md += `${htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
106
+ md += `${await htmlToBasicMarkdown(answer.body)}\n\n---\n\n`;
107
107
  }
108
108
  }
109
109
  }
@@ -2,8 +2,8 @@
2
2
  * Shared types and utilities for web-fetch handlers
3
3
  */
4
4
  import { ptree } from "@oh-my-pi/pi-utils";
5
- import TurndownService from "turndown";
6
- import { gfm } from "turndown-plugin-gfm";
5
+ import type TurndownService from "turndown";
6
+
7
7
  import { ToolAbortError } from "../../tools/tool-errors";
8
8
 
9
9
  export { formatNumber } from "@oh-my-pi/pi-utils";
@@ -155,28 +155,8 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
155
155
  return { content: "", contentType: "", finalUrl: url, ok: false };
156
156
  }
157
157
 
158
- /** Module-level Turndown instance — matches markit-ai's configuration. */
159
- const turndown = new TurndownService({
160
- headingStyle: "atx",
161
- codeBlockStyle: "fenced",
162
- bulletListMarker: "-",
163
- });
164
- turndown.use(gfm);
165
- turndown.addRule("strikethrough", {
166
- filter: ["del", "s", "strike"],
167
- replacement(content) {
168
- return `~~${content}~~`;
169
- },
170
- });
171
- turndown.addRule("heading", {
172
- filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
173
- replacement(content, node) {
174
- const level = Number(node.nodeName.charAt(1));
175
- const prefix = "#".repeat(level);
176
- const cleaned = content.replace(/\\([.])/g, "$1").trim();
177
- return `\n\n${prefix} ${cleaned}\n\n`;
178
- },
179
- });
158
+ /** Module-level Turndown instance — built lazily on first use. */
159
+ let turndownPromise: Promise<TurndownService> | undefined;
180
160
 
181
161
  type TurndownListParent = {
182
162
  nodeName: string;
@@ -184,27 +164,61 @@ type TurndownListParent = {
184
164
  children: ArrayLike<unknown>;
185
165
  };
186
166
 
187
- turndown.addRule("listItem", {
188
- filter: "li",
189
- replacement(content, node, options) {
190
- content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
191
- const parent = node.parentNode as unknown as TurndownListParent | null;
192
- let prefix = `${options.bulletListMarker} `;
193
- if (parent?.nodeName === "OL") {
194
- const start = parent.getAttribute("start");
195
- const index = Array.prototype.indexOf.call(parent.children, node);
196
- prefix = `${(start ? Number(start) : 1) + index}. `;
197
- }
198
- return prefix + content + (node.nextSibling ? "\n" : "");
199
- },
200
- });
167
+ function getTurndown(): Promise<TurndownService> {
168
+ turndownPromise ||= initTurndown();
169
+ return turndownPromise;
170
+ }
171
+
172
+ async function initTurndown(): Promise<TurndownService> {
173
+ const [{ default: TurndownService }, { gfm }] = await Promise.all([
174
+ import("turndown"),
175
+ import("turndown-plugin-gfm"),
176
+ ]);
177
+ const turndown = new TurndownService({
178
+ headingStyle: "atx",
179
+ codeBlockStyle: "fenced",
180
+ bulletListMarker: "-",
181
+ });
182
+ turndown.use(gfm);
183
+ turndown.addRule("strikethrough", {
184
+ filter: ["del", "s", "strike"],
185
+ replacement(content) {
186
+ return `~~${content}~~`;
187
+ },
188
+ });
189
+ turndown.addRule("heading", {
190
+ filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
191
+ replacement(content, node) {
192
+ const level = Number(node.nodeName.charAt(1));
193
+ const prefix = "#".repeat(level);
194
+ const cleaned = content.replace(/\\([.])/g, "$1").trim();
195
+ return `\n\n${prefix} ${cleaned}\n\n`;
196
+ },
197
+ });
198
+ turndown.addRule("listItem", {
199
+ filter: "li",
200
+ replacement(content, node, options) {
201
+ content = content.replace(/^\n+/, "").replace(/\n+$/, "\n").replace(/\n/gm, "\n ");
202
+ const parent = node.parentNode as unknown as TurndownListParent | null;
203
+ let prefix = `${options.bulletListMarker} `;
204
+ if (parent?.nodeName === "OL") {
205
+ const start = parent.getAttribute("start");
206
+ const index = Array.prototype.indexOf.call(parent.children, node);
207
+ prefix = `${(start ? Number(start) : 1) + index}. `;
208
+ }
209
+ return prefix + content + (node.nextSibling ? "\n" : "");
210
+ },
211
+ });
212
+ return turndown;
213
+ }
201
214
 
202
215
  /**
203
216
  * Convert HTML to markdown using Turndown with GFM support.
204
217
  * Strips script/style tags before conversion.
205
218
  */
206
- export function htmlToBasicMarkdown(html: string): string {
219
+ export async function htmlToBasicMarkdown(html: string): Promise<string> {
207
220
  const cleaned = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "");
221
+ const turndown = await getTurndown();
208
222
  return turndown.turndown(cleaned).trim();
209
223
  }
210
224
 
@@ -100,7 +100,7 @@ export const handleW3c: SpecialHandler = async (
100
100
  const title = getString(specPayload, "title");
101
101
  const shortnameValue = getString(specPayload, "shortname") ?? shortname;
102
102
  const description = getString(specPayload, "description") ?? getString(specPayload, "abstract");
103
- const abstract = description ? htmlToBasicMarkdown(description) : undefined;
103
+ const abstract = description ? await htmlToBasicMarkdown(description) : undefined;
104
104
 
105
105
  const latestVersionUrl =
106
106
  getString(latestPayload, "uri") ??
@@ -15,7 +15,7 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty
15
15
  import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" };
16
16
  import type { ToolSession } from "../../tools";
17
17
  import { formatAge } from "../../tools/render-utils";
18
- import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider";
18
+ import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
19
19
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
20
20
  import type { SearchProviderId, SearchResponse } from "./types";
21
21
  import { SearchProviderError } from "./types";
@@ -63,7 +63,7 @@ function formatProviderError(error: unknown, provider: SearchProvider): string {
63
63
  if (error.provider === "zai") {
64
64
  return error.message;
65
65
  }
66
- return `${getSearchProvider(error.provider).label} authorization failed (${error.status}). Check API key or base URL.`;
66
+ return `${getSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
67
67
  }
68
68
  return error.message;
69
69
  }
@@ -139,9 +139,9 @@ async function executeSearch(
139
139
  ): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
140
140
  const providers =
141
141
  params.provider && params.provider !== "auto"
142
- ? (await getSearchProvider(params.provider).isAvailable())
143
- ? [getSearchProvider(params.provider)]
144
- : await resolveProviderChain("auto")
142
+ ? await getSearchProvider(params.provider).then(provider =>
143
+ provider.isAvailable() ? [provider] : resolveProviderChain("auto"),
144
+ )
145
145
  : await resolveProviderChain();
146
146
  if (providers.length === 0) {
147
147
  const message = "No web search provider configured.";
@@ -1,39 +1,121 @@
1
- import { AnthropicProvider } from "./providers/anthropic";
1
+ // Lazy registry of web search providers.
2
+ //
3
+ // Each provider is loaded on first use; importing this module loads zero
4
+ // provider implementations. Provider modules are heavy (each pulls in
5
+ // fetch/parse/format helpers) and only one — at most — is needed per session,
6
+ // so eager construction was wasted work at startup.
7
+ //
8
+ // The `label`/`id` metadata is kept inline so callers needing a display name
9
+ // (error formatting, UI listings) do not force a load.
10
+
2
11
  import type { SearchProvider } from "./providers/base";
3
- import { BraveProvider } from "./providers/brave";
4
- import { CodexProvider } from "./providers/codex";
5
- import { ExaProvider } from "./providers/exa";
6
- import { GeminiProvider } from "./providers/gemini";
7
- import { JinaProvider } from "./providers/jina";
8
- import { KagiProvider } from "./providers/kagi";
9
- import { KimiProvider } from "./providers/kimi";
10
- import { ParallelProvider } from "./providers/parallel";
11
- import { PerplexityProvider } from "./providers/perplexity";
12
- import { SearXNGProvider } from "./providers/searxng";
13
- import { SyntheticProvider } from "./providers/synthetic";
14
- import { TavilyProvider } from "./providers/tavily";
15
- import { ZaiProvider } from "./providers/zai";
16
12
  import type { SearchProviderId } from "./types";
17
13
 
18
14
  export type { SearchParams } from "./providers/base";
19
15
  export { SearchProvider } from "./providers/base";
20
16
 
21
- const SEARCH_PROVIDERS: Record<SearchProviderId, SearchProvider> = {
22
- exa: new ExaProvider(),
23
- brave: new BraveProvider(),
24
- jina: new JinaProvider(),
25
- perplexity: new PerplexityProvider(),
26
- kimi: new KimiProvider(),
27
- zai: new ZaiProvider(),
28
- anthropic: new AnthropicProvider(),
29
- gemini: new GeminiProvider(),
30
- codex: new CodexProvider(),
31
- tavily: new TavilyProvider(),
32
- parallel: new ParallelProvider(),
33
- kagi: new KagiProvider(),
34
- synthetic: new SyntheticProvider(),
35
- searxng: new SearXNGProvider(),
36
- } as const;
17
+ interface ProviderMeta {
18
+ id: SearchProviderId;
19
+ label: string;
20
+ load: () => Promise<SearchProvider>;
21
+ }
22
+
23
+ /** Lazy factories. Each `load()` dynamic-imports its provider module on first call. */
24
+ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
25
+ exa: {
26
+ id: "exa",
27
+ label: "Exa",
28
+ load: async () => new (await import("./providers/exa")).ExaProvider(),
29
+ },
30
+ brave: {
31
+ id: "brave",
32
+ label: "Brave",
33
+ load: async () => new (await import("./providers/brave")).BraveProvider(),
34
+ },
35
+ jina: {
36
+ id: "jina",
37
+ label: "Jina",
38
+ load: async () => new (await import("./providers/jina")).JinaProvider(),
39
+ },
40
+ perplexity: {
41
+ id: "perplexity",
42
+ label: "Perplexity",
43
+ load: async () => new (await import("./providers/perplexity")).PerplexityProvider(),
44
+ },
45
+ kimi: {
46
+ id: "kimi",
47
+ label: "Kimi",
48
+ load: async () => new (await import("./providers/kimi")).KimiProvider(),
49
+ },
50
+ zai: {
51
+ id: "zai",
52
+ label: "Z.AI",
53
+ load: async () => new (await import("./providers/zai")).ZaiProvider(),
54
+ },
55
+ anthropic: {
56
+ id: "anthropic",
57
+ label: "Anthropic",
58
+ load: async () => new (await import("./providers/anthropic")).AnthropicProvider(),
59
+ },
60
+ gemini: {
61
+ id: "gemini",
62
+ label: "Gemini",
63
+ load: async () => new (await import("./providers/gemini")).GeminiProvider(),
64
+ },
65
+ codex: {
66
+ id: "codex",
67
+ label: "Codex",
68
+ load: async () => new (await import("./providers/codex")).CodexProvider(),
69
+ },
70
+ tavily: {
71
+ id: "tavily",
72
+ label: "Tavily",
73
+ load: async () => new (await import("./providers/tavily")).TavilyProvider(),
74
+ },
75
+ parallel: {
76
+ id: "parallel",
77
+ label: "Parallel",
78
+ load: async () => new (await import("./providers/parallel")).ParallelProvider(),
79
+ },
80
+ kagi: {
81
+ id: "kagi",
82
+ label: "Kagi",
83
+ load: async () => new (await import("./providers/kagi")).KagiProvider(),
84
+ },
85
+ synthetic: {
86
+ id: "synthetic",
87
+ label: "Synthetic",
88
+ load: async () => new (await import("./providers/synthetic")).SyntheticProvider(),
89
+ },
90
+ searxng: {
91
+ id: "searxng",
92
+ label: "SearXNG",
93
+ load: async () => new (await import("./providers/searxng")).SearXNGProvider(),
94
+ },
95
+ };
96
+
97
+ const instanceCache = new Map<SearchProviderId, SearchProvider>();
98
+
99
+ /** Cheap, sync metadata accessor — never triggers a provider load. */
100
+ export function getSearchProviderLabel(id: SearchProviderId): string {
101
+ return PROVIDER_META[id]?.label ?? id;
102
+ }
103
+
104
+ /**
105
+ * Resolve and cache a provider instance. First call for a given id loads the
106
+ * underlying module; subsequent calls return the cached singleton.
107
+ */
108
+ export async function getSearchProvider(id: SearchProviderId): Promise<SearchProvider> {
109
+ const cached = instanceCache.get(id);
110
+ if (cached) return cached;
111
+ const meta = PROVIDER_META[id];
112
+ if (!meta) {
113
+ throw new Error(`Unknown search provider: ${id}`);
114
+ }
115
+ const provider = await meta.load();
116
+ instanceCache.set(id, provider);
117
+ return provider;
118
+ }
37
119
 
38
120
  export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
39
121
  "tavily",
@@ -52,10 +134,6 @@ export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = [
52
134
  "searxng",
53
135
  ];
54
136
 
55
- export function getSearchProvider(provider: SearchProviderId): SearchProvider {
56
- return SEARCH_PROVIDERS[provider];
57
- }
58
-
59
137
  /** Preferred provider set via settings (default: auto) */
60
138
  let preferredProvId: SearchProviderId | "auto" = "auto";
61
139
 
@@ -64,22 +142,26 @@ export function setPreferredSearchProvider(provider: SearchProviderId | "auto"):
64
142
  preferredProvId = provider;
65
143
  }
66
144
 
67
- /** Determine which providers are configured */
145
+ /**
146
+ * Determine which providers are configured and currently available.
147
+ * Each candidate is loaded (and its `isAvailable()` called) only as the chain
148
+ * is walked, so unconfigured providers never pay the load cost.
149
+ */
68
150
  export async function resolveProviderChain(
69
151
  preferredProvider: SearchProviderId | "auto" = preferredProvId,
70
152
  ): Promise<SearchProvider[]> {
71
153
  const providers: SearchProvider[] = [];
72
154
 
73
155
  if (preferredProvider !== "auto") {
74
- if (await getSearchProvider(preferredProvider).isAvailable()) {
75
- providers.push(getSearchProvider(preferredProvider));
156
+ const provider = await getSearchProvider(preferredProvider);
157
+ if (await provider.isAvailable()) {
158
+ providers.push(provider);
76
159
  }
77
160
  }
78
161
 
79
162
  for (const id of SEARCH_PROVIDER_ORDER) {
80
163
  if (id === preferredProvider) continue;
81
-
82
- const provider = getSearchProvider(id);
164
+ const provider = await getSearchProvider(id);
83
165
  if (await provider.isAvailable()) {
84
166
  providers.push(provider);
85
167
  }