@oh-my-pi/pi-coding-agent 14.5.13 → 14.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/autoresearch/command-resume.md +5 -8
  4. package/src/autoresearch/git.ts +41 -51
  5. package/src/autoresearch/helpers.ts +43 -359
  6. package/src/autoresearch/index.ts +281 -273
  7. package/src/autoresearch/prompt-setup.md +43 -0
  8. package/src/autoresearch/prompt.md +52 -193
  9. package/src/autoresearch/resume-message.md +2 -8
  10. package/src/autoresearch/state.ts +59 -166
  11. package/src/autoresearch/storage.ts +687 -0
  12. package/src/autoresearch/tools/init-experiment.ts +201 -290
  13. package/src/autoresearch/tools/log-experiment.ts +304 -517
  14. package/src/autoresearch/tools/run-experiment.ts +117 -296
  15. package/src/autoresearch/tools/update-notes.ts +116 -0
  16. package/src/autoresearch/types.ts +16 -66
  17. package/src/commit/pipeline.ts +4 -3
  18. package/src/config/settings-schema.ts +1 -1
  19. package/src/config/settings.ts +20 -1
  20. package/src/config.ts +9 -6
  21. package/src/cursor.ts +1 -1
  22. package/src/edit/index.ts +9 -31
  23. package/src/edit/line-hash.ts +70 -43
  24. package/src/edit/modes/hashline.lark +26 -0
  25. package/src/edit/modes/hashline.ts +898 -1099
  26. package/src/edit/modes/patch.ts +0 -7
  27. package/src/edit/modes/replace.ts +0 -4
  28. package/src/edit/renderer.ts +22 -20
  29. package/src/edit/streaming.ts +8 -28
  30. package/src/eval/eval.lark +24 -30
  31. package/src/eval/js/context-manager.ts +5 -162
  32. package/src/eval/js/prelude.txt +0 -12
  33. package/src/eval/parse.ts +129 -129
  34. package/src/eval/py/kernel.ts +4 -4
  35. package/src/eval/py/prelude.py +1 -219
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +2 -2
  38. package/src/internal-urls/docs-index.generated.ts +1 -1
  39. package/src/main.ts +10 -0
  40. package/src/mcp/manager.ts +22 -0
  41. package/src/modes/components/session-observer-overlay.ts +5 -2
  42. package/src/modes/components/status-line/segments.ts +1 -1
  43. package/src/modes/components/status-line.ts +3 -5
  44. package/src/modes/components/tree-selector.ts +4 -5
  45. package/src/modes/components/welcome.ts +11 -1
  46. package/src/modes/controllers/command-controller.ts +2 -6
  47. package/src/modes/controllers/event-controller.ts +1 -2
  48. package/src/modes/controllers/extension-ui-controller.ts +3 -15
  49. package/src/modes/controllers/input-controller.ts +0 -1
  50. package/src/modes/controllers/selector-controller.ts +1 -1
  51. package/src/modes/interactive-mode.ts +5 -7
  52. package/src/modes/rpc/rpc-client.ts +9 -0
  53. package/src/modes/rpc/rpc-mode.ts +6 -0
  54. package/src/modes/rpc/rpc-types.ts +9 -0
  55. package/src/prompts/system/system-prompt.md +14 -38
  56. package/src/prompts/tools/ast-edit.md +8 -8
  57. package/src/prompts/tools/ast-grep.md +10 -10
  58. package/src/prompts/tools/eval.md +13 -31
  59. package/src/prompts/tools/find.md +2 -1
  60. package/src/prompts/tools/hashline.md +66 -57
  61. package/src/prompts/tools/search.md +2 -2
  62. package/src/sdk.ts +19 -4
  63. package/src/session/agent-session.ts +110 -4
  64. package/src/session/session-manager.ts +17 -13
  65. package/src/task/agents.ts +4 -5
  66. package/src/tools/archive-reader.ts +9 -3
  67. package/src/tools/ast-edit.ts +141 -44
  68. package/src/tools/ast-grep.ts +112 -36
  69. package/src/tools/browser/readable.ts +11 -6
  70. package/src/tools/browser/tab-supervisor.ts +2 -2
  71. package/src/tools/browser.ts +5 -3
  72. package/src/tools/eval.ts +2 -53
  73. package/src/tools/find.ts +16 -15
  74. package/src/tools/image-gen.ts +2 -2
  75. package/src/tools/path-utils.ts +36 -196
  76. package/src/tools/search.ts +56 -35
  77. package/src/tools/write.ts +8 -1
  78. package/src/utils/edit-mode.ts +2 -11
  79. package/src/utils/file-display-mode.ts +1 -1
  80. package/src/utils/git.ts +17 -0
  81. package/src/utils/session-color.ts +0 -12
  82. package/src/utils/title-generator.ts +22 -38
  83. package/src/web/scrapers/crossref.ts +3 -3
  84. package/src/web/scrapers/devto.ts +1 -1
  85. package/src/web/scrapers/discourse.ts +5 -5
  86. package/src/web/scrapers/firefox-addons.ts +1 -1
  87. package/src/web/scrapers/flathub.ts +2 -2
  88. package/src/web/scrapers/gitlab.ts +1 -1
  89. package/src/web/scrapers/go-pkg.ts +2 -2
  90. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  91. package/src/web/scrapers/mastodon.ts +9 -9
  92. package/src/web/scrapers/mdn.ts +11 -7
  93. package/src/web/scrapers/pub-dev.ts +1 -1
  94. package/src/web/scrapers/rawg.ts +3 -3
  95. package/src/web/scrapers/readthedocs.ts +1 -1
  96. package/src/web/scrapers/spdx.ts +1 -1
  97. package/src/web/scrapers/stackoverflow.ts +2 -2
  98. package/src/web/scrapers/types.ts +53 -39
  99. package/src/web/scrapers/w3c.ts +1 -1
  100. package/src/web/search/providers/gemini.ts +2 -2
  101. package/src/autoresearch/apply-contract-to-state.ts +0 -24
  102. package/src/autoresearch/contract.ts +0 -288
  103. package/src/edit/modes/atom.lark +0 -29
  104. package/src/edit/modes/atom.ts +0 -1773
  105. package/src/prompts/tools/atom.md +0 -150
@@ -2,15 +2,13 @@
2
2
  * Generate session titles using a smol, fast model.
3
3
  */
4
4
  import * as path from "node:path";
5
- import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
6
- import type { Api, Model } from "@oh-my-pi/pi-ai";
7
- import { completeSimple } from "@oh-my-pi/pi-ai";
5
+
6
+ import { type Api, completeSimple, type Model } from "@oh-my-pi/pi-ai";
8
7
  import { logger, prompt } from "@oh-my-pi/pi-utils";
9
8
  import type { ModelRegistry } from "../config/model-registry";
10
9
  import { resolveRoleSelection } from "../config/model-resolver";
11
10
  import type { Settings } from "../config/settings";
12
11
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
13
- import { toReasoningEffort } from "../thinking";
14
12
 
15
13
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
16
14
 
@@ -19,22 +17,14 @@ const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
19
17
 
20
18
  const MAX_INPUT_CHARS = 2000;
21
19
 
22
- function getTitleModel(
23
- registry: ModelRegistry,
24
- settings: Settings,
25
- currentModel?: Model<Api>,
26
- ): { model: Model<Api>; thinkingLevel?: ThinkingLevel } | undefined {
20
+ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
27
21
  const availableModels = registry.getAvailable();
28
22
  if (availableModels.length === 0) return undefined;
29
23
 
30
- const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry);
31
- if (titleModel) {
32
- return { model: titleModel.model, thinkingLevel: titleModel.thinkingLevel };
33
- }
24
+ const titleModel = resolveRoleSelection(["commit", "smol"], settings, availableModels, registry)?.model;
25
+ if (titleModel) return titleModel;
34
26
 
35
- if (currentModel) {
36
- return { model: currentModel };
37
- }
27
+ if (currentModel) return currentModel;
38
28
 
39
29
  return undefined;
40
30
  }
@@ -44,7 +34,7 @@ function getTitleModel(
44
34
  *
45
35
  * @param firstMessage The first user message
46
36
  * @param registry Model registry
47
- * @param settings Settings used to resolve the smol role, including per-role thinking
37
+ * @param settings Settings used to resolve the smol role
48
38
  * @param sessionId Optional session id for sticky API key selection
49
39
  */
50
40
  export async function generateSessionTitle(
@@ -54,8 +44,8 @@ export async function generateSessionTitle(
54
44
  sessionId?: string,
55
45
  currentModel?: Model<Api>,
56
46
  ): Promise<string | null> {
57
- const candidate = getTitleModel(registry, settings, currentModel);
58
- if (!candidate) {
47
+ const model = getTitleModel(registry, settings, currentModel);
48
+ if (!model) {
59
49
  logger.debug("title-generator: no title model found");
60
50
  return null;
61
51
  }
@@ -67,17 +57,20 @@ export async function generateSessionTitle(
67
57
  ${truncatedMessage}
68
58
  </user-message>`;
69
59
 
70
- const apiKey = await registry.getApiKey(candidate.model, sessionId);
60
+ const apiKey = await registry.getApiKey(model, sessionId);
71
61
  if (!apiKey) {
72
62
  logger.debug("title-generator: no API key for smol model", {
73
- provider: candidate.model.provider,
74
- id: candidate.model.id,
63
+ provider: model.provider,
64
+ id: model.id,
75
65
  });
76
66
  return null;
77
67
  }
78
68
 
69
+ // Title generation is a 3-6 word task; force reasoning off so reasoning models
70
+ // don't burn the entire output budget on internal thinking and return an empty
71
+ // string. With reasoning disabled, 30 tokens of output is plenty.
79
72
  const request = {
80
- model: `${candidate.model.provider}/${candidate.model.id}`,
73
+ model: `${model.provider}/${model.id}`,
81
74
  systemPrompt: TITLE_SYSTEM_PROMPT,
82
75
  userMessage,
83
76
  maxTokens: 30,
@@ -86,7 +79,7 @@ ${truncatedMessage}
86
79
 
87
80
  try {
88
81
  const response = await completeSimple(
89
- candidate.model,
82
+ model,
90
83
  {
91
84
  systemPrompt: request.systemPrompt,
92
85
  messages: [{ role: "user", content: request.userMessage, timestamp: Date.now() }],
@@ -94,7 +87,7 @@ ${truncatedMessage}
94
87
  {
95
88
  apiKey,
96
89
  maxTokens: 30,
97
- reasoning: toReasoningEffort(candidate.thinkingLevel),
90
+ disableReasoning: true,
98
91
  },
99
92
  );
100
93
 
@@ -153,13 +146,8 @@ function getFallbackTerminalTitle(cwd: string | undefined): string | undefined {
153
146
  return sanitizeTerminalTitlePart(baseName);
154
147
  }
155
148
 
156
- export function formatSessionTerminalTitle(
157
- sessionName: string | undefined,
158
- cwd?: string,
159
- titleSource?: "auto" | "user" | undefined,
160
- ): string {
161
- const label =
162
- sanitizeTerminalTitlePart(titleSource === "auto" ? undefined : sessionName) ?? getFallbackTerminalTitle(cwd);
149
+ export function formatSessionTerminalTitle(sessionName: string | undefined, cwd?: string): string {
150
+ const label = sanitizeTerminalTitlePart(sessionName) ?? getFallbackTerminalTitle(cwd);
163
151
  return label ? `${DEFAULT_TERMINAL_TITLE}: ${label}` : DEFAULT_TERMINAL_TITLE;
164
152
  }
165
153
 
@@ -170,12 +158,8 @@ export function setTerminalTitle(title: string): void {
170
158
  process.stdout.write(`\x1b]0;${sanitizeTerminalTitlePart(title) ?? DEFAULT_TERMINAL_TITLE}\x07`);
171
159
  }
172
160
 
173
- export function setSessionTerminalTitle(
174
- sessionName: string | undefined,
175
- cwd?: string,
176
- titleSource?: "auto" | "user" | undefined,
177
- ): void {
178
- setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd, titleSource));
161
+ export function setSessionTerminalTitle(sessionName: string | undefined, cwd?: string): void {
162
+ setTerminalTitle(formatSessionTerminalTitle(sessionName, cwd));
179
163
  }
180
164
 
181
165
  /**
@@ -66,10 +66,10 @@ function formatDate(date?: CrossrefDate): string | null {
66
66
  return formatted.join("-");
67
67
  }
68
68
 
69
- function formatAbstract(abstract?: string): string | null {
69
+ async function formatAbstract(abstract?: string): Promise<string | null> {
70
70
  if (!abstract) return null;
71
71
  const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, match => (match.startsWith("</") ? "</p>" : "<p>"));
72
- const markdown = htmlToBasicMarkdown(normalized);
72
+ const markdown = await htmlToBasicMarkdown(normalized);
73
73
  return markdown.trim().length > 0 ? markdown : null;
74
74
  }
75
75
 
@@ -114,7 +114,7 @@ export const handleCrossref: SpecialHandler = async (
114
114
  formatDate(message.issued) ||
115
115
  formatDate(message.created);
116
116
  const doiValue = message.DOI || doi;
117
- const abstract = formatAbstract(message.abstract);
117
+ const abstract = await formatAbstract(message.abstract);
118
118
  const type = message.type?.replace(/-/g, " ");
119
119
 
120
120
  let md = `# ${title}\n\n`;
@@ -133,7 +133,7 @@ export const handleDevTo: SpecialHandler = async (
133
133
  if (article.body_markdown) {
134
134
  md += article.body_markdown;
135
135
  } else if (article.body_html) {
136
- md += htmlToBasicMarkdown(article.body_html);
136
+ md += await htmlToBasicMarkdown(article.body_html);
137
137
  }
138
138
 
139
139
  notes.push("Fetched via dev.to API");
@@ -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") ??
@@ -8,7 +8,7 @@
8
8
  import {
9
9
  ANTIGRAVITY_SYSTEM_INSTRUCTION,
10
10
  extractRetryDelay,
11
- getAntigravityHeaders,
11
+ getAntigravityUserAgent,
12
12
  getGeminiCliHeaders,
13
13
  } from "@oh-my-pi/pi-ai";
14
14
  import { refreshAntigravityToken } from "@oh-my-pi/pi-ai/utils/oauth/google-antigravity";
@@ -248,7 +248,7 @@ async function callGeminiSearch(
248
248
  usage?: { inputTokens: number; outputTokens: number; totalTokens: number };
249
249
  }> {
250
250
  const endpoints = auth.isAntigravity ? ANTIGRAVITY_ENDPOINT_FALLBACKS : [DEFAULT_ENDPOINT];
251
- const headers = auth.isAntigravity ? getAntigravityHeaders() : getGeminiCliHeaders();
251
+ const headers = auth.isAntigravity ? { "User-Agent": getAntigravityUserAgent() } : getGeminiCliHeaders();
252
252
 
253
253
  const requestMetadata = auth.isAntigravity
254
254
  ? {
@@ -1,24 +0,0 @@
1
- import { inferMetricUnitFromName } from "./helpers";
2
- import type { AutoresearchContract, ExperimentState } from "./types";
3
-
4
- /**
5
- * Updates session fields from a validated `autoresearch.md` parse (same fields as `init_experiment`).
6
- * Does not touch `name`, `currentSegment`, `results`, `bestMetric`, `confidence`, or `maxExperiments`.
7
- */
8
- export function applyAutoresearchContractToExperimentState(
9
- contract: AutoresearchContract,
10
- state: ExperimentState,
11
- ): void {
12
- const benchmarkContract = contract.benchmark;
13
- state.metricName = benchmarkContract.primaryMetric ?? state.metricName;
14
- state.metricUnit = benchmarkContract.metricUnit;
15
- state.bestDirection = benchmarkContract.direction ?? "lower";
16
- state.secondaryMetrics = benchmarkContract.secondaryMetrics.map(name => ({
17
- name,
18
- unit: inferMetricUnitFromName(name),
19
- }));
20
- state.benchmarkCommand = benchmarkContract.command?.trim() ?? state.benchmarkCommand;
21
- state.scopePaths = [...contract.scopePaths];
22
- state.offLimits = [...contract.offLimits];
23
- state.constraints = [...contract.constraints];
24
- }