@oh-my-pi/pi-coding-agent 12.18.1 → 12.19.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 (231) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/package.json +7 -7
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +341 -0
  5. package/src/cli/file-processor.ts +3 -3
  6. package/src/cli/list-models.ts +3 -17
  7. package/src/cli/stats-cli.ts +3 -22
  8. package/src/cli/web-search-cli.ts +8 -16
  9. package/src/commit/agentic/agent.ts +6 -9
  10. package/src/commit/agentic/index.ts +44 -50
  11. package/src/commit/agentic/state.ts +0 -9
  12. package/src/commit/agentic/tools/propose-commit.ts +1 -30
  13. package/src/commit/agentic/tools/schemas.ts +31 -0
  14. package/src/commit/agentic/tools/split-commit.ts +1 -30
  15. package/src/commit/agentic/validation.ts +1 -18
  16. package/src/commit/analysis/conventional.ts +3 -50
  17. package/src/commit/analysis/summary.ts +2 -13
  18. package/src/commit/changelog/detect.ts +4 -1
  19. package/src/commit/changelog/generate.ts +2 -25
  20. package/src/commit/changelog/index.ts +1 -2
  21. package/src/commit/cli.ts +4 -12
  22. package/src/commit/map-reduce/reduce-phase.ts +2 -43
  23. package/src/commit/pipeline.ts +7 -15
  24. package/src/commit/utils.ts +44 -0
  25. package/src/config/prompt-templates.ts +1 -81
  26. package/src/config/settings-schema.ts +20 -1
  27. package/src/config.ts +2 -3
  28. package/src/debug/index.ts +1 -6
  29. package/src/debug/system-info.ts +2 -6
  30. package/src/discovery/builtin.ts +5 -9
  31. package/src/discovery/helpers.ts +0 -26
  32. package/src/discovery/ssh.ts +1 -8
  33. package/src/exa/company.ts +8 -39
  34. package/src/exa/factory.ts +64 -0
  35. package/src/exa/index.ts +0 -16
  36. package/src/exa/linkedin.ts +8 -39
  37. package/src/exa/mcp-client.ts +0 -64
  38. package/src/exa/researcher.ts +17 -59
  39. package/src/exa/search.ts +30 -154
  40. package/src/extensibility/custom-tools/loader.ts +3 -41
  41. package/src/extensibility/extensions/loader.ts +2 -9
  42. package/src/extensibility/hooks/loader.ts +3 -20
  43. package/src/extensibility/hooks/runner.ts +3 -19
  44. package/src/extensibility/plugins/installer.ts +2 -1
  45. package/src/extensibility/plugins/loader.ts +29 -117
  46. package/src/extensibility/skills.ts +2 -89
  47. package/src/extensibility/slash-commands.ts +1 -63
  48. package/src/extensibility/utils.ts +38 -0
  49. package/src/index.ts +9 -25
  50. package/src/internal-urls/index.ts +1 -0
  51. package/src/internal-urls/jobs-protocol.ts +118 -0
  52. package/src/ipy/kernel.ts +2 -0
  53. package/src/lsp/config.ts +1 -5
  54. package/src/lsp/lspmux.ts +0 -17
  55. package/src/lsp/utils.ts +2 -24
  56. package/src/main.ts +16 -24
  57. package/src/mcp/client.ts +1 -46
  58. package/src/mcp/render.ts +8 -1
  59. package/src/mcp/tool-cache.ts +1 -5
  60. package/src/mcp/transports/http.ts +2 -7
  61. package/src/mcp/transports/stdio.ts +2 -7
  62. package/src/modes/components/bash-execution.ts +2 -16
  63. package/src/modes/components/extensions/inspector-panel.ts +8 -18
  64. package/src/modes/components/footer.ts +10 -50
  65. package/src/modes/components/model-selector.ts +2 -21
  66. package/src/modes/components/python-execution.ts +2 -16
  67. package/src/modes/components/settings-selector.ts +1 -10
  68. package/src/modes/components/status-line/segments.ts +8 -25
  69. package/src/modes/components/status-line.ts +14 -31
  70. package/src/modes/components/tool-execution.ts +8 -2
  71. package/src/modes/controllers/command-controller.ts +71 -30
  72. package/src/modes/controllers/event-controller.ts +34 -4
  73. package/src/modes/controllers/mcp-command-controller.ts +3 -34
  74. package/src/modes/controllers/selector-controller.ts +2 -2
  75. package/src/modes/controllers/ssh-command-controller.ts +3 -34
  76. package/src/modes/interactive-mode.ts +6 -2
  77. package/src/modes/rpc/rpc-client.ts +1 -5
  78. package/src/modes/shared.ts +73 -0
  79. package/src/modes/types.ts +1 -0
  80. package/src/modes/utils/ui-helpers.ts +26 -2
  81. package/src/patch/index.ts +4 -4
  82. package/src/patch/normalize.ts +22 -65
  83. package/src/patch/shared.ts +16 -16
  84. package/src/prompts/system/custom-system-prompt.md +0 -10
  85. package/src/prompts/system/system-prompt.md +69 -89
  86. package/src/prompts/tools/async-result.md +5 -0
  87. package/src/prompts/tools/bash.md +5 -0
  88. package/src/prompts/tools/cancel-job.md +7 -0
  89. package/src/prompts/tools/poll-jobs.md +7 -0
  90. package/src/prompts/tools/task.md +4 -0
  91. package/src/sdk.ts +70 -6
  92. package/src/session/agent-session.ts +40 -6
  93. package/src/session/agent-storage.ts +69 -278
  94. package/src/session/auth-storage.ts +14 -1430
  95. package/src/session/session-manager.ts +69 -5
  96. package/src/session/session-storage.ts +1 -5
  97. package/src/session/streaming-output.ts +637 -76
  98. package/src/slash-commands/builtin-registry.ts +8 -0
  99. package/src/ssh/connection-manager.ts +4 -12
  100. package/src/ssh/sshfs-mount.ts +3 -7
  101. package/src/ssh/utils.ts +8 -0
  102. package/src/system-prompt.ts +24 -90
  103. package/src/task/executor.ts +11 -1
  104. package/src/task/index.ts +258 -13
  105. package/src/task/parallel.ts +32 -0
  106. package/src/task/render.ts +15 -7
  107. package/src/task/types.ts +5 -0
  108. package/src/tools/ask.ts +4 -7
  109. package/src/tools/bash-interactive.ts +4 -5
  110. package/src/tools/bash.ts +125 -41
  111. package/src/tools/cancel-job.ts +93 -0
  112. package/src/tools/fetch.ts +7 -27
  113. package/src/tools/find.ts +3 -3
  114. package/src/tools/gemini-image.ts +15 -14
  115. package/src/tools/grep.ts +3 -3
  116. package/src/tools/index.ts +13 -29
  117. package/src/tools/json-tree.ts +12 -1
  118. package/src/tools/jtd-to-json-schema.ts +10 -74
  119. package/src/tools/jtd-to-typescript.ts +10 -72
  120. package/src/tools/jtd-utils.ts +102 -0
  121. package/src/tools/notebook.ts +4 -9
  122. package/src/tools/output-meta.ts +52 -26
  123. package/src/tools/path-utils.ts +13 -7
  124. package/src/tools/poll-jobs.ts +178 -0
  125. package/src/tools/python.ts +32 -35
  126. package/src/tools/read.ts +61 -82
  127. package/src/tools/render-utils.ts +8 -159
  128. package/src/tools/ssh.ts +7 -20
  129. package/src/tools/submit-result.ts +1 -1
  130. package/src/tools/tool-errors.ts +0 -30
  131. package/src/tools/tool-result.ts +1 -2
  132. package/src/tools/write.ts +8 -10
  133. package/src/tui/code-cell.ts +8 -3
  134. package/src/tui/status-line.ts +4 -4
  135. package/src/tui/types.ts +0 -1
  136. package/src/tui/utils.ts +1 -14
  137. package/src/utils/command-args.ts +76 -0
  138. package/src/utils/file-mentions.ts +15 -19
  139. package/src/utils/frontmatter.ts +5 -10
  140. package/src/utils/shell-snapshot.ts +0 -11
  141. package/src/utils/title-generator.ts +0 -12
  142. package/src/web/scrapers/artifacthub.ts +7 -16
  143. package/src/web/scrapers/arxiv.ts +3 -8
  144. package/src/web/scrapers/aur.ts +8 -22
  145. package/src/web/scrapers/biorxiv.ts +5 -14
  146. package/src/web/scrapers/bluesky.ts +13 -36
  147. package/src/web/scrapers/brew.ts +5 -10
  148. package/src/web/scrapers/cheatsh.ts +2 -12
  149. package/src/web/scrapers/chocolatey.ts +63 -26
  150. package/src/web/scrapers/choosealicense.ts +3 -18
  151. package/src/web/scrapers/cisa-kev.ts +4 -18
  152. package/src/web/scrapers/clojars.ts +6 -33
  153. package/src/web/scrapers/coingecko.ts +25 -33
  154. package/src/web/scrapers/crates-io.ts +7 -26
  155. package/src/web/scrapers/crossref.ts +4 -18
  156. package/src/web/scrapers/devto.ts +11 -41
  157. package/src/web/scrapers/discogs.ts +7 -10
  158. package/src/web/scrapers/discourse.ts +6 -31
  159. package/src/web/scrapers/dockerhub.ts +12 -35
  160. package/src/web/scrapers/fdroid.ts +8 -33
  161. package/src/web/scrapers/firefox-addons.ts +10 -34
  162. package/src/web/scrapers/flathub.ts +7 -24
  163. package/src/web/scrapers/github-gist.ts +2 -12
  164. package/src/web/scrapers/github.ts +9 -47
  165. package/src/web/scrapers/gitlab.ts +130 -185
  166. package/src/web/scrapers/go-pkg.ts +12 -22
  167. package/src/web/scrapers/hackage.ts +88 -43
  168. package/src/web/scrapers/hackernews.ts +25 -45
  169. package/src/web/scrapers/hex.ts +19 -36
  170. package/src/web/scrapers/huggingface.ts +26 -91
  171. package/src/web/scrapers/iacr.ts +3 -8
  172. package/src/web/scrapers/jetbrains-marketplace.ts +9 -20
  173. package/src/web/scrapers/lemmy.ts +5 -23
  174. package/src/web/scrapers/lobsters.ts +16 -28
  175. package/src/web/scrapers/mastodon.ts +24 -43
  176. package/src/web/scrapers/maven.ts +6 -21
  177. package/src/web/scrapers/mdn.ts +7 -11
  178. package/src/web/scrapers/metacpan.ts +9 -41
  179. package/src/web/scrapers/musicbrainz.ts +4 -28
  180. package/src/web/scrapers/npm.ts +8 -25
  181. package/src/web/scrapers/nuget.ts +14 -37
  182. package/src/web/scrapers/nvd.ts +6 -28
  183. package/src/web/scrapers/ollama.ts +7 -34
  184. package/src/web/scrapers/open-vsx.ts +5 -19
  185. package/src/web/scrapers/opencorporates.ts +30 -14
  186. package/src/web/scrapers/openlibrary.ts +49 -33
  187. package/src/web/scrapers/orcid.ts +4 -18
  188. package/src/web/scrapers/osv.ts +7 -24
  189. package/src/web/scrapers/packagist.ts +9 -24
  190. package/src/web/scrapers/pub-dev.ts +7 -50
  191. package/src/web/scrapers/pubmed.ts +54 -21
  192. package/src/web/scrapers/pypi.ts +8 -26
  193. package/src/web/scrapers/rawg.ts +11 -19
  194. package/src/web/scrapers/readthedocs.ts +4 -9
  195. package/src/web/scrapers/reddit.ts +5 -15
  196. package/src/web/scrapers/repology.ts +8 -20
  197. package/src/web/scrapers/rfc.ts +5 -14
  198. package/src/web/scrapers/rubygems.ts +6 -21
  199. package/src/web/scrapers/searchcode.ts +8 -36
  200. package/src/web/scrapers/sec-edgar.ts +4 -18
  201. package/src/web/scrapers/semantic-scholar.ts +15 -35
  202. package/src/web/scrapers/snapcraft.ts +5 -19
  203. package/src/web/scrapers/sourcegraph.ts +5 -43
  204. package/src/web/scrapers/spdx.ts +4 -18
  205. package/src/web/scrapers/spotify.ts +4 -23
  206. package/src/web/scrapers/stackoverflow.ts +8 -13
  207. package/src/web/scrapers/terraform.ts +9 -37
  208. package/src/web/scrapers/tldr.ts +3 -7
  209. package/src/web/scrapers/twitter.ts +3 -7
  210. package/src/web/scrapers/types.ts +105 -27
  211. package/src/web/scrapers/utils.ts +97 -103
  212. package/src/web/scrapers/vimeo.ts +7 -27
  213. package/src/web/scrapers/vscode-marketplace.ts +8 -17
  214. package/src/web/scrapers/w3c.ts +6 -14
  215. package/src/web/scrapers/wikidata.ts +5 -19
  216. package/src/web/scrapers/wikipedia.ts +2 -12
  217. package/src/web/scrapers/youtube.ts +5 -34
  218. package/src/web/search/index.ts +0 -9
  219. package/src/web/search/providers/anthropic.ts +3 -2
  220. package/src/web/search/providers/brave.ts +3 -18
  221. package/src/web/search/providers/exa.ts +1 -12
  222. package/src/web/search/providers/kimi.ts +5 -44
  223. package/src/web/search/providers/perplexity.ts +1 -12
  224. package/src/web/search/providers/synthetic.ts +3 -26
  225. package/src/web/search/providers/utils.ts +36 -0
  226. package/src/web/search/providers/zai.ts +9 -50
  227. package/src/web/search/types.ts +0 -28
  228. package/src/web/search/utils.ts +17 -0
  229. package/src/tools/output-utils.ts +0 -63
  230. package/src/tools/truncate.ts +0 -385
  231. package/src/web/search/auth.ts +0 -178
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, formatCount, loadPage } from "./types";
2
+ import { buildResult, formatNumber, loadPage, tryParseJson } from "./types";
3
3
 
4
4
  interface SnapcraftPublisher {
5
5
  "display-name"?: string;
@@ -122,12 +122,8 @@ export const handleSnapcraft: SpecialHandler = async (
122
122
  });
123
123
  if (!result.ok) return null;
124
124
 
125
- let data: SnapcraftResponse;
126
- try {
127
- data = JSON.parse(result.content) as SnapcraftResponse;
128
- } catch {
129
- return null;
130
- }
125
+ const data = tryParseJson<SnapcraftResponse>(result.content);
126
+ if (!data) return null;
131
127
 
132
128
  const snapInfo = data.snap ?? data;
133
129
  const name = snapInfo.title ?? snapInfo.name ?? data.name ?? snapName;
@@ -163,7 +159,7 @@ export const handleSnapcraft: SpecialHandler = async (
163
159
  if (base) md += ` · **Base:** ${base}`;
164
160
  md += "\n";
165
161
  if (publisher) md += `**Publisher:** ${publisher}\n`;
166
- if (downloads !== null) md += `**Downloads:** ${formatCount(downloads)}\n`;
162
+ if (downloads !== null) md += `**Downloads:** ${formatNumber(downloads)}\n`;
167
163
  md += "\n";
168
164
 
169
165
  if (channels.size > 0) {
@@ -183,17 +179,7 @@ export const handleSnapcraft: SpecialHandler = async (
183
179
  md += `## Description\n\n${descriptionText}\n`;
184
180
  }
185
181
 
186
- const output = finalizeOutput(md);
187
- return {
188
- url,
189
- finalUrl: url,
190
- contentType: "text/markdown",
191
- method: "snapcraft",
192
- content: output.content,
193
- fetchedAt,
194
- truncated: output.truncated,
195
- notes: ["Fetched via Snapcraft API"],
196
- };
182
+ return buildResult(md, { url, method: "snapcraft", fetchedAt, notes: ["Fetched via Snapcraft API"] });
197
183
  } catch {}
198
184
 
199
185
  return null;
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, loadPage } from "./types";
2
+ import { buildResult, loadPage, tryParseJson } from "./types";
3
3
 
4
4
  const GRAPHQL_ENDPOINT = "https://sourcegraph.com/.api/graphql";
5
5
  const GRAPHQL_HEADERS = {
@@ -160,14 +160,6 @@ function parseSourcegraphUrl(url: string): SourcegraphTarget | null {
160
160
  }
161
161
  }
162
162
 
163
- function safeParseJson<T>(content: string): T | null {
164
- try {
165
- return JSON.parse(content) as T;
166
- } catch {
167
- return null;
168
- }
169
- }
170
-
171
163
  async function fetchGraphql<T>(
172
164
  query: string,
173
165
  variables: Record<string, unknown>,
@@ -184,7 +176,7 @@ async function fetchGraphql<T>(
184
176
  });
185
177
  if (!result.ok) return null;
186
178
 
187
- const parsed = safeParseJson<{ data?: T; errors?: unknown }>(result.content);
179
+ const parsed = tryParseJson<{ data?: T; errors?: unknown }>(result.content);
188
180
  if (!parsed?.data) return null;
189
181
  if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return null;
190
182
  return parsed.data;
@@ -323,48 +315,18 @@ export const handleSourcegraph: SpecialHandler = async (
323
315
  case "search": {
324
316
  const result = await renderSearch(target.query, timeout, signal);
325
317
  if (!result.ok) return null;
326
- const output = finalizeOutput(result.content);
327
- return {
328
- url,
329
- finalUrl: url,
330
- contentType: "text/markdown",
331
- method: "sourcegraph-search",
332
- content: output.content,
333
- fetchedAt,
334
- truncated: output.truncated,
335
- notes,
336
- };
318
+ return buildResult(result.content, { url, method: "sourcegraph-search", fetchedAt, notes });
337
319
  }
338
320
  case "file": {
339
321
  const rev = target.rev ?? "HEAD";
340
322
  const result = await renderFile(target.repoName, target.filePath, rev, timeout, signal);
341
323
  if (!result.ok) return null;
342
- const output = finalizeOutput(result.content);
343
- return {
344
- url,
345
- finalUrl: url,
346
- contentType: "text/markdown",
347
- method: "sourcegraph-file",
348
- content: output.content,
349
- fetchedAt,
350
- truncated: output.truncated,
351
- notes,
352
- };
324
+ return buildResult(result.content, { url, method: "sourcegraph-file", fetchedAt, notes });
353
325
  }
354
326
  case "repo": {
355
327
  const result = await renderRepo(target.repoName, timeout, signal);
356
328
  if (!result.ok) return null;
357
- const output = finalizeOutput(result.content);
358
- return {
359
- url,
360
- finalUrl: url,
361
- contentType: "text/markdown",
362
- method: "sourcegraph-repo",
363
- content: output.content,
364
- fetchedAt,
365
- truncated: output.truncated,
366
- notes,
367
- };
329
+ return buildResult(result.content, { url, method: "sourcegraph-repo", fetchedAt, notes });
368
330
  }
369
331
  }
370
332
  } catch {}
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
2
+ import { buildResult, htmlToBasicMarkdown, loadPage, tryParseJson } from "./types";
3
3
 
4
4
  interface SpdxCrossRef {
5
5
  url?: string;
@@ -67,12 +67,8 @@ export const handleSpdx: SpecialHandler = async (
67
67
 
68
68
  if (!result.ok) return null;
69
69
 
70
- let license: SpdxLicense;
71
- try {
72
- license = JSON.parse(result.content);
73
- } catch {
74
- return null;
75
- }
70
+ const license = tryParseJson<SpdxLicense>(result.content);
71
+ if (!license) return null;
76
72
 
77
73
  const title = license.name || license.licenseId || licenseId;
78
74
  let md = `# ${title}\n\n`;
@@ -104,17 +100,7 @@ export const handleSpdx: SpecialHandler = async (
104
100
  md += `\n## License Text\n\n\`\`\`\n${licenseText}\n\`\`\`\n`;
105
101
  }
106
102
 
107
- const output = finalizeOutput(md);
108
- return {
109
- url,
110
- finalUrl: url,
111
- contentType: "text/markdown",
112
- method: "spdx-api",
113
- content: output.content,
114
- fetchedAt,
115
- truncated: output.truncated,
116
- notes: ["Fetched via SPDX license API"],
117
- };
103
+ return buildResult(md, { url, method: "spdx-api", fetchedAt, notes: ["Fetched via SPDX license API"] });
118
104
  } catch {}
119
105
 
120
106
  return null;
@@ -5,7 +5,7 @@
5
5
  * from Spotify URLs without requiring authentication.
6
6
  */
7
7
  import type { SpecialHandler } from "./types";
8
- import { finalizeOutput, loadPage } from "./types";
8
+ import { buildResult, formatMediaDuration, loadPage } from "./types";
9
9
 
10
10
  interface SpotifyOEmbedResponse {
11
11
  title?: string;
@@ -71,21 +71,13 @@ function getContentType(url: string): string | null {
71
71
  }
72
72
 
73
73
  /**
74
- * Format duration from seconds
74
+ * Format duration from seconds string
75
75
  */
76
76
  function formatDuration(seconds: string | undefined): string | null {
77
77
  if (!seconds) return null;
78
78
  const num = parseInt(seconds, 10);
79
79
  if (Number.isNaN(num)) return null;
80
-
81
- const hours = Math.floor(num / 3600);
82
- const minutes = Math.floor((num % 3600) / 60);
83
- const secs = num % 60;
84
-
85
- if (hours > 0) {
86
- return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
87
- }
88
- return `${minutes}:${secs.toString().padStart(2, "0")}`;
80
+ return formatMediaDuration(num);
89
81
  }
90
82
 
91
83
  /**
@@ -202,16 +194,5 @@ export const handleSpotify: SpecialHandler = async (url: string, timeout: number
202
194
 
203
195
  // Format output
204
196
  const output = formatOutput(contentType, oEmbedData, ogData, url);
205
- const { content, truncated } = finalizeOutput(output);
206
-
207
- return {
208
- url,
209
- finalUrl: url,
210
- contentType: "text/markdown",
211
- method: "spotify",
212
- content,
213
- fetchedAt: new Date().toISOString(),
214
- truncated,
215
- notes,
216
- };
197
+ return buildResult(output, { url, method: "spotify", fetchedAt: new Date().toISOString(), notes });
217
198
  };
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
2
+ import { buildResult, formatIsoDate, htmlToBasicMarkdown, loadPage, tryParseJson } from "./types";
3
3
 
4
4
  interface SOQuestion {
5
5
  title: string;
@@ -79,8 +79,8 @@ export const handleStackOverflow: SpecialHandler = async (
79
79
 
80
80
  if (!qResult.ok) return null;
81
81
 
82
- const qData = JSON.parse(qResult.content) as { items: SOQuestion[] };
83
- if (!qData.items?.length) return null;
82
+ const qData = tryParseJson<{ items: SOQuestion[] }>(qResult.content);
83
+ if (!qData?.items?.length) return null;
84
84
 
85
85
  const question = qData.items[0];
86
86
 
@@ -88,7 +88,7 @@ export const handleStackOverflow: SpecialHandler = async (
88
88
  md += `**Score:** ${question.score} · **Answers:** ${question.answer_count}`;
89
89
  md += question.is_answered ? " (Answered)" : "";
90
90
  md += `\n**Tags:** ${question.tags.join(", ")}\n`;
91
- md += `**Asked by:** ${question.owner.display_name} · ${new Date(question.creation_date * 1000).toISOString().split("T")[0]}\n\n`;
91
+ md += `**Asked by:** ${question.owner.display_name} · ${formatIsoDate(question.creation_date * 1000)}\n\n`;
92
92
  md += `---\n\n## Question\n\n${htmlToBasicMarkdown(question.body)}\n\n`;
93
93
 
94
94
  // Fetch answers
@@ -96,8 +96,8 @@ export const handleStackOverflow: SpecialHandler = async (
96
96
  const aResult = await loadPage(aUrl, { timeout, signal });
97
97
 
98
98
  if (aResult.ok) {
99
- const aData = JSON.parse(aResult.content) as { items: SOAnswer[] };
100
- if (aData.items?.length) {
99
+ const aData = tryParseJson<{ items: SOAnswer[] }>(aResult.content);
100
+ if (aData?.items?.length) {
101
101
  md += `---\n\n## Answers\n\n`;
102
102
  for (const answer of aData.items.slice(0, 5)) {
103
103
  const accepted = answer.is_accepted ? " (Accepted)" : "";
@@ -107,17 +107,12 @@ export const handleStackOverflow: SpecialHandler = async (
107
107
  }
108
108
  }
109
109
 
110
- const output = finalizeOutput(md);
111
- return {
110
+ return buildResult(md, {
112
111
  url,
113
- finalUrl: url,
114
- contentType: "text/markdown",
115
112
  method: "stackexchange",
116
- content: output.content,
117
113
  fetchedAt,
118
- truncated: output.truncated,
119
114
  notes: [`Fetched via Stack Exchange API (site=${site})`],
120
- };
115
+ });
121
116
  } catch {}
122
117
 
123
118
  return null;
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, formatCount, loadPage } from "./types";
2
+ import { buildResult, formatNumber, loadPage, tryParseJson } from "./types";
3
3
 
4
4
  interface TerraformModule {
5
5
  id: string;
@@ -113,12 +113,8 @@ async function handleModuleUrl(
113
113
 
114
114
  if (!result.ok) return null;
115
115
 
116
- let mod: TerraformModule;
117
- try {
118
- mod = JSON.parse(result.content);
119
- } catch {
120
- return null;
121
- }
116
+ const mod = tryParseJson<TerraformModule>(result.content);
117
+ if (!mod) return null;
122
118
 
123
119
  let md = `# ${mod.namespace}/${mod.name}/${mod.provider}\n\n`;
124
120
 
@@ -128,7 +124,7 @@ async function handleModuleUrl(
128
124
  md += `**Version:** ${mod.version}`;
129
125
  if (mod.verified) md += " ✓ Verified";
130
126
  md += `\n`;
131
- md += `**Downloads:** ${formatCount(mod.downloads)}\n`;
127
+ md += `**Downloads:** ${formatNumber(mod.downloads)}\n`;
132
128
  if (mod.published_at) {
133
129
  md += `**Published:** ${new Date(mod.published_at).toLocaleDateString()}\n`;
134
130
  }
@@ -212,17 +208,7 @@ async function handleModuleUrl(
212
208
  }
213
209
  }
214
210
 
215
- const output = finalizeOutput(md);
216
- return {
217
- url,
218
- finalUrl: url,
219
- contentType: "text/markdown",
220
- method: "terraform",
221
- content: output.content,
222
- fetchedAt,
223
- truncated: output.truncated,
224
- notes: ["Fetched via Terraform Registry API"],
225
- };
211
+ return buildResult(md, { url, method: "terraform", fetchedAt, notes: ["Fetched via Terraform Registry API"] });
226
212
  }
227
213
 
228
214
  async function handleProviderUrl(
@@ -242,12 +228,8 @@ async function handleProviderUrl(
242
228
 
243
229
  if (!result.ok) return null;
244
230
 
245
- let provider: TerraformProvider;
246
- try {
247
- provider = JSON.parse(result.content);
248
- } catch {
249
- return null;
250
- }
231
+ const provider = tryParseJson<TerraformProvider>(result.content);
232
+ if (!provider) return null;
251
233
 
252
234
  let md = `# ${provider.namespace}/${provider.name}\n\n`;
253
235
 
@@ -256,7 +238,7 @@ async function handleProviderUrl(
256
238
  // Metadata
257
239
  md += `**Version:** ${provider.version}\n`;
258
240
  if (provider.tier) md += `**Tier:** ${provider.tier}\n`;
259
- md += `**Downloads:** ${formatCount(provider.downloads)}\n`;
241
+ md += `**Downloads:** ${formatNumber(provider.downloads)}\n`;
260
242
  if (provider.published_at) {
261
243
  md += `**Published:** ${new Date(provider.published_at).toLocaleDateString()}\n`;
262
244
  }
@@ -290,15 +272,5 @@ async function handleProviderUrl(
290
272
  }
291
273
  }
292
274
 
293
- const output = finalizeOutput(md);
294
- return {
295
- url,
296
- finalUrl: url,
297
- contentType: "text/markdown",
298
- method: "terraform",
299
- content: output.content,
300
- fetchedAt,
301
- truncated: output.truncated,
302
- notes: ["Fetched via Terraform Registry API"],
303
- };
275
+ return buildResult(md, { url, method: "terraform", fetchedAt, notes: ["Fetched via Terraform Registry API"] });
304
276
  }
@@ -1,5 +1,5 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, loadPage } from "./types";
2
+ import { buildResult, loadPage } from "./types";
3
3
 
4
4
  const TLDR_BASE = "https://raw.githubusercontent.com/tldr-pages/tldr/main/pages";
5
5
  const PLATFORMS = ["common", "linux", "osx"] as const;
@@ -30,17 +30,13 @@ export const handleTldr: SpecialHandler = async (
30
30
  const result = await loadPage(rawUrl, { timeout, signal });
31
31
 
32
32
  if (result.ok && result.content.trim()) {
33
- const output = finalizeOutput(result.content);
34
- return {
33
+ return buildResult(result.content, {
35
34
  url,
36
35
  finalUrl: rawUrl,
37
- contentType: "text/markdown",
38
36
  method: "tldr",
39
- content: output.content,
40
37
  fetchedAt,
41
- truncated: output.truncated,
42
38
  notes: [`Fetched from tldr-pages (${platform})`],
43
- };
39
+ });
44
40
  }
45
41
  }
46
42
 
@@ -1,7 +1,7 @@
1
1
  import { parse as parseHtml } from "node-html-parser";
2
2
  import { ToolAbortError } from "../../tools/tool-errors";
3
3
  import type { RenderResult, SpecialHandler } from "./types";
4
- import { finalizeOutput, loadPage } from "./types";
4
+ import { buildResult, loadPage } from "./types";
5
5
 
6
6
  const NITTER_INSTANCES = [
7
7
  "nitter.privacyredirect.com",
@@ -58,17 +58,13 @@ export const handleTwitter: SpecialHandler = async (
58
58
  }
59
59
  }
60
60
 
61
- const output = finalizeOutput(md);
62
- return {
61
+ return buildResult(md, {
63
62
  url,
64
63
  finalUrl: nitterUrl,
65
- contentType: "text/markdown",
66
64
  method: "twitter-nitter",
67
- content: output.content,
68
65
  fetchedAt,
69
- truncated: output.truncated,
70
66
  notes: [`Via Nitter: ${instance}`],
71
- };
67
+ });
72
68
  }
73
69
  }
74
70
  }
@@ -4,6 +4,8 @@
4
4
  import { ptree } from "@oh-my-pi/pi-utils";
5
5
  import { ToolAbortError } from "../../tools/tool-errors";
6
6
 
7
+ export { formatNumber } from "@oh-my-pi/pi-utils";
8
+
7
9
  export interface RenderResult {
8
10
  url: string;
9
11
  finalUrl: string;
@@ -18,7 +20,7 @@ export interface RenderResult {
18
20
  export type SpecialHandler = (url: string, timeout: number, signal?: AbortSignal) => Promise<RenderResult | null>;
19
21
 
20
22
  export const MAX_OUTPUT_CHARS = 500_000;
21
- const MAX_BYTES = 50 * 1024 * 1024;
23
+ export const MAX_BYTES = 50 * 1024 * 1024;
22
24
 
23
25
  const USER_AGENTS = [
24
26
  "curl/8.0",
@@ -151,50 +153,126 @@ export async function loadPage(url: string, options: LoadPageOptions = {}): Prom
151
153
  return { content: "", contentType: "", finalUrl: url, ok: false };
152
154
  }
153
155
 
154
- /**
155
- * Format large numbers (1000 -> 1K, 1000000 -> 1M)
156
- */
157
- export function formatCount(n: number): string {
158
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
159
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
160
- return String(n);
161
- }
162
-
163
156
  /**
164
157
  * Convert basic HTML to markdown
165
158
  */
166
159
  export function htmlToBasicMarkdown(html: string): string {
167
- return html
168
- .replace(/<pre><code[^>]*>/g, "\n```\n")
160
+ const stripped = html
161
+ .replace(/<pre[^>]*><code[^>]*>/g, "\n```\n")
169
162
  .replace(/<\/code><\/pre>/g, "\n```\n")
170
- .replace(/<code>/g, "`")
163
+ .replace(/<code[^>]*>/g, "`")
171
164
  .replace(/<\/code>/g, "`")
172
- .replace(/<strong>/g, "**")
165
+ .replace(/<strong[^>]*>/g, "**")
173
166
  .replace(/<\/strong>/g, "**")
174
- .replace(/<b>/g, "**")
167
+ .replace(/<b[^>]*>/g, "**")
175
168
  .replace(/<\/b>/g, "**")
176
- .replace(/<em>/g, "*")
169
+ .replace(/<em[^>]*>/g, "*")
177
170
  .replace(/<\/em>/g, "*")
178
- .replace(/<i>/g, "*")
171
+ .replace(/<i[^>]*>/g, "*")
179
172
  .replace(/<\/i>/g, "*")
180
- .replace(/<a href="([^"]+)"[^>]*>([^<]+)<\/a>/g, "[$2]($1)")
181
- .replace(/<p>/g, "\n\n")
173
+ .replace(
174
+ /<a[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g,
175
+ (_, href, text) => `[${text.replace(/<[^>]+>/g, "").trim()}](${href})`,
176
+ )
177
+ .replace(/<p[^>]*>/g, "\n\n")
182
178
  .replace(/<\/p>/g, "")
183
179
  .replace(/<br\s*\/?>/g, "\n")
184
- .replace(/<li>/g, "- ")
180
+ .replace(/<li[^>]*>/g, "- ")
185
181
  .replace(/<\/li>/g, "\n")
186
- .replace(/<\/?[uo]l>/g, "\n")
187
- .replace(/<h(\d)>/g, (_, n) => `\n${"#".repeat(parseInt(n, 10))} `)
182
+ .replace(/<\/?[uo]l[^>]*>/g, "\n")
183
+ .replace(/<h(\d)[^>]*>/g, (_, n) => `\n${"#".repeat(parseInt(n, 10))} `)
188
184
  .replace(/<\/h\d>/g, "\n")
189
- .replace(/<blockquote>/g, "\n> ")
185
+ .replace(/<blockquote[^>]*>/g, "\n> ")
190
186
  .replace(/<\/blockquote>/g, "\n")
191
187
  .replace(/<[^>]+>/g, "")
188
+ .replace(/\n{3,}/g, "\n\n")
189
+ .trim();
190
+ return decodeHtmlEntities(stripped);
191
+ }
192
+
193
+ /**
194
+ * Try to parse JSON, returning null on failure.
195
+ */
196
+ export function tryParseJson<T = unknown>(content: string): T | null {
197
+ try {
198
+ return JSON.parse(content) as T;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Build a RenderResult from markdown content. Calls finalizeOutput internally.
206
+ */
207
+ export function buildResult(
208
+ md: string,
209
+ opts: { url: string; finalUrl?: string; method: string; fetchedAt: string; notes?: string[]; contentType?: string },
210
+ ): RenderResult {
211
+ const output = finalizeOutput(md);
212
+ return {
213
+ url: opts.url,
214
+ finalUrl: opts.finalUrl ?? opts.url,
215
+ contentType: opts.contentType ?? "text/markdown",
216
+ method: opts.method,
217
+ content: output.content,
218
+ fetchedAt: opts.fetchedAt,
219
+ truncated: output.truncated,
220
+ notes: opts.notes ?? [],
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Format a date value as YYYY-MM-DD. Returns empty string on invalid input.
226
+ */
227
+ export function formatIsoDate(value?: string | number | Date): string {
228
+ if (value == null) return "";
229
+ if (typeof value === "string") {
230
+ const datePrefix = value.match(/^\d{4}-\d{2}-\d{2}/);
231
+ if (datePrefix) return datePrefix[0];
232
+ }
233
+ try {
234
+ return new Date(value).toISOString().split("T")[0];
235
+ } catch {
236
+ return "";
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Decode common HTML entities.
242
+ */
243
+ export function decodeHtmlEntities(text: string): string {
244
+ return text
192
245
  .replace(/&lt;/g, "<")
193
246
  .replace(/&gt;/g, ">")
194
247
  .replace(/&amp;/g, "&")
195
248
  .replace(/&quot;/g, '"')
196
- .replace(/&#39;/g, "'")
197
- .replace(/&nbsp;/g, " ")
198
- .replace(/\n{3,}/g, "\n\n")
199
- .trim();
249
+ .replace(/&#0?39;/g, "'")
250
+ .replace(/&#x27;/g, "'")
251
+ .replace(/&#x2F;/g, "/")
252
+ .replace(/&nbsp;/g, " ");
253
+ }
254
+
255
+ /**
256
+ * Format seconds into HH:MM:SS or MM:SS.
257
+ */
258
+ export function formatMediaDuration(totalSeconds: number): string {
259
+ const hours = Math.floor(totalSeconds / 3600);
260
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
261
+ const secs = Math.floor(totalSeconds % 60);
262
+ if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
263
+ return `${minutes}:${String(secs).padStart(2, "0")}`;
264
+ }
265
+
266
+ /**
267
+ * Extract localized text, preferring en-US/en.
268
+ */
269
+ export type LocalizedText = string | Record<string, string | null> | null | undefined;
270
+
271
+ export function getLocalizedText(value: LocalizedText, defaultLocale?: string): string | undefined {
272
+ if (value == null) return undefined;
273
+ if (typeof value === "string") return value;
274
+ if (defaultLocale && value[defaultLocale]) return value[defaultLocale];
275
+ return (
276
+ value["en-US"] ?? value.en_US ?? value.en ?? Object.values(value).find(v => typeof v === "string") ?? undefined
277
+ );
200
278
  }