@oh-my-pi/pi-coding-agent 12.18.3 → 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,10 +1,12 @@
1
1
  import {
2
- finalizeOutput,
3
- formatCount,
2
+ buildResult,
3
+ formatIsoDate,
4
+ formatNumber,
4
5
  htmlToBasicMarkdown,
5
6
  loadPage,
6
7
  type RenderResult,
7
8
  type SpecialHandler,
9
+ tryParseJson,
8
10
  } from "./types";
9
11
 
10
12
  interface GitLabUrl {
@@ -93,12 +95,9 @@ async function getProjectId(gl: GitLabUrl, timeout: number, signal?: AbortSignal
93
95
  const result = await loadPage(apiUrl, { timeout, signal });
94
96
  if (!result.ok) return null;
95
97
 
96
- try {
97
- const data = JSON.parse(result.content) as { id: number };
98
- return data.id;
99
- } catch {
100
- return null;
101
- }
98
+ const data = tryParseJson<{ id: number }>(result.content);
99
+ if (!data) return null;
100
+ return data.id;
102
101
  }
103
102
 
104
103
  /**
@@ -115,42 +114,39 @@ async function renderGitLabRepo(
115
114
  const result = await loadPage(apiUrl, { timeout, signal });
116
115
  if (!result.ok) return { content: "", ok: false };
117
116
 
118
- try {
119
- const repo = JSON.parse(result.content) as {
120
- name: string;
121
- description?: string;
122
- star_count: number;
123
- forks_count: number;
124
- open_issues_count: number;
125
- default_branch: string;
126
- visibility: string;
127
- created_at: string;
128
- last_activity_at: string;
129
- topics?: string[];
130
- readme_url?: string;
131
- };
132
-
133
- let md = `# ${repo.name}\n\n`;
134
- if (repo.description) md += `${repo.description}\n\n`;
135
- md += `**Stars:** ${formatCount(repo.star_count)} · **Forks:** ${formatCount(repo.forks_count)} · **Issues:** ${formatCount(repo.open_issues_count)}\n`;
136
- md += `**Visibility:** ${repo.visibility} · **Default Branch:** ${repo.default_branch}\n`;
137
- if (repo.topics && repo.topics.length > 0) {
138
- md += `**Topics:** ${repo.topics.join(", ")}\n`;
139
- }
140
- md += `**Created:** ${new Date(repo.created_at).toISOString().split("T")[0]} · **Last Activity:** ${new Date(repo.last_activity_at).toISOString().split("T")[0]}\n\n`;
117
+ const repo = tryParseJson<{
118
+ name: string;
119
+ description?: string;
120
+ star_count: number;
121
+ forks_count: number;
122
+ open_issues_count: number;
123
+ default_branch: string;
124
+ visibility: string;
125
+ created_at: string;
126
+ last_activity_at: string;
127
+ topics?: string[];
128
+ readme_url?: string;
129
+ }>(result.content);
130
+ if (!repo) return { content: "", ok: false };
131
+
132
+ let md = `# ${repo.name}\n\n`;
133
+ if (repo.description) md += `${repo.description}\n\n`;
134
+ md += `**Stars:** ${formatNumber(repo.star_count)} · **Forks:** ${formatNumber(repo.forks_count)} · **Issues:** ${formatNumber(repo.open_issues_count)}\n`;
135
+ md += `**Visibility:** ${repo.visibility} · **Default Branch:** ${repo.default_branch}\n`;
136
+ if (repo.topics && repo.topics.length > 0) {
137
+ md += `**Topics:** ${repo.topics.join(", ")}\n`;
138
+ }
139
+ md += `**Created:** ${formatIsoDate(repo.created_at)} · **Last Activity:** ${formatIsoDate(repo.last_activity_at)}\n\n`;
141
140
 
142
- // Try to fetch README
143
- if (repo.readme_url) {
144
- const readmeResult = await loadPage(repo.readme_url, { timeout, signal });
145
- if (readmeResult.ok && readmeResult.content.trim().length > 0) {
146
- md += `---\n\n## README\n\n${readmeResult.content}\n`;
147
- }
141
+ // Try to fetch README
142
+ if (repo.readme_url) {
143
+ const readmeResult = await loadPage(repo.readme_url, { timeout, signal });
144
+ if (readmeResult.ok && readmeResult.content.trim().length > 0) {
145
+ md += `---\n\n## README\n\n${readmeResult.content}\n`;
148
146
  }
149
-
150
- return { content: md, ok: true };
151
- } catch {
152
- return { content: "", ok: false };
153
147
  }
148
+
149
+ return { content: md, ok: true };
154
150
  }
155
151
 
156
152
  /**
@@ -185,40 +181,39 @@ async function renderGitLabTree(
185
181
  const result = await loadPage(apiUrl, { timeout, signal });
186
182
  if (!result.ok) return { content: "", ok: false };
187
183
 
188
- try {
189
- const tree = JSON.parse(result.content) as Array<{
184
+ const tree = tryParseJson<
185
+ Array<{
190
186
  name: string;
191
187
  type: "tree" | "blob";
192
188
  path: string;
193
189
  mode: string;
194
- }>;
190
+ }>
191
+ >(result.content);
192
+ if (!tree) return { content: "", ok: false };
195
193
 
196
- let md = `# Directory: ${gl.path || "/"}\n\n`;
197
- md += `**Ref:** ${gl.ref}\n\n`;
194
+ let md = `# Directory: ${gl.path || "/"}\n\n`;
195
+ md += `**Ref:** ${gl.ref}\n\n`;
198
196
 
199
- // Separate directories and files
200
- const dirs = tree.filter(item => item.type === "tree");
201
- const files = tree.filter(item => item.type === "blob");
197
+ // Separate directories and files
198
+ const dirs = tree.filter(item => item.type === "tree");
199
+ const files = tree.filter(item => item.type === "blob");
202
200
 
203
- if (dirs.length > 0) {
204
- md += `## Directories (${dirs.length})\n\n`;
205
- for (const dir of dirs) {
206
- md += `- 📁 ${dir.name}/\n`;
207
- }
208
- md += `\n`;
201
+ if (dirs.length > 0) {
202
+ md += `## Directories (${dirs.length})\n\n`;
203
+ for (const dir of dirs) {
204
+ md += `- 📁 ${dir.name}/\n`;
209
205
  }
206
+ md += `\n`;
207
+ }
210
208
 
211
- if (files.length > 0) {
212
- md += `## Files (${files.length})\n\n`;
213
- for (const file of files) {
214
- md += `- 📄 ${file.name}\n`;
215
- }
209
+ if (files.length > 0) {
210
+ md += `## Files (${files.length})\n\n`;
211
+ for (const file of files) {
212
+ md += `- 📄 ${file.name}\n`;
216
213
  }
217
-
218
- return { content: md, ok: true };
219
- } catch {
220
- return { content: "", ok: false };
221
214
  }
215
+
216
+ return { content: md, ok: true };
222
217
  }
223
218
 
224
219
  /**
@@ -235,41 +230,38 @@ async function renderGitLabIssue(
235
230
  const result = await loadPage(apiUrl, { timeout, signal });
236
231
  if (!result.ok) return { content: "", ok: false };
237
232
 
238
- try {
239
- const issue = JSON.parse(result.content) as {
240
- title: string;
241
- description?: string;
242
- state: string;
243
- author: { name: string; username: string };
244
- created_at: string;
245
- updated_at: string;
246
- labels: string[];
247
- upvotes: number;
248
- downvotes: number;
249
- user_notes_count: number;
250
- assignees?: Array<{ name: string }>;
251
- };
252
-
253
- let md = `# Issue #${gl.id}: ${issue.title}\n\n`;
254
- md += `**State:** ${issue.state.toUpperCase()} · **Author:** ${issue.author.name} (@${issue.author.username})\n`;
255
- md += `**Created:** ${new Date(issue.created_at).toISOString().split("T")[0]} · **Updated:** ${new Date(issue.updated_at).toISOString().split("T")[0]}\n`;
256
- md += `**Upvotes:** ${issue.upvotes} · **Downvotes:** ${issue.downvotes} · **Comments:** ${issue.user_notes_count}\n`;
257
-
258
- if (issue.labels.length > 0) {
259
- md += `**Labels:** ${issue.labels.join(", ")}\n`;
260
- }
233
+ const issue = tryParseJson<{
234
+ title: string;
235
+ description?: string;
236
+ state: string;
237
+ author: { name: string; username: string };
238
+ created_at: string;
239
+ updated_at: string;
240
+ labels: string[];
241
+ upvotes: number;
242
+ downvotes: number;
243
+ user_notes_count: number;
244
+ assignees?: Array<{ name: string }>;
245
+ }>(result.content);
246
+ if (!issue) return { content: "", ok: false };
247
+
248
+ let md = `# Issue #${gl.id}: ${issue.title}\n\n`;
249
+ md += `**State:** ${issue.state.toUpperCase()} · **Author:** ${issue.author.name} (@${issue.author.username})\n`;
250
+ md += `**Created:** ${formatIsoDate(issue.created_at)} · **Updated:** ${formatIsoDate(issue.updated_at)}\n`;
251
+ md += `**Upvotes:** ${issue.upvotes} · **Downvotes:** ${issue.downvotes} · **Comments:** ${issue.user_notes_count}\n`;
252
+
253
+ if (issue.labels.length > 0) {
254
+ md += `**Labels:** ${issue.labels.join(", ")}\n`;
255
+ }
261
256
 
262
- if (issue.assignees && issue.assignees.length > 0) {
263
- md += `**Assignees:** ${issue.assignees.map(a => a.name).join(", ")}\n`;
264
- }
257
+ if (issue.assignees && issue.assignees.length > 0) {
258
+ md += `**Assignees:** ${issue.assignees.map(a => a.name).join(", ")}\n`;
259
+ }
265
260
 
266
- md += `\n---\n\n## Description\n\n`;
267
- md += issue.description ? htmlToBasicMarkdown(issue.description) : "*No description*";
261
+ md += `\n---\n\n## Description\n\n`;
262
+ md += issue.description ? htmlToBasicMarkdown(issue.description) : "*No description*";
268
263
 
269
- return { content: md, ok: true };
270
- } catch {
271
- return { content: "", ok: false };
272
- }
264
+ return { content: md, ok: true };
273
265
  }
274
266
 
275
267
  /**
@@ -286,47 +278,44 @@ async function renderGitLabMR(
286
278
  const result = await loadPage(apiUrl, { timeout, signal });
287
279
  if (!result.ok) return { content: "", ok: false };
288
280
 
289
- try {
290
- const mr = JSON.parse(result.content) as {
291
- title: string;
292
- description?: string;
293
- state: string;
294
- author: { name: string; username: string };
295
- created_at: string;
296
- updated_at: string;
297
- source_branch: string;
298
- target_branch: string;
299
- labels: string[];
300
- upvotes: number;
301
- downvotes: number;
302
- user_notes_count: number;
303
- assignees?: Array<{ name: string }>;
304
- draft: boolean;
305
- merge_status: string;
306
- };
307
-
308
- let md = `# MR !${gl.id}: ${mr.title}\n\n`;
309
- if (mr.draft) md += `**[DRAFT]** `;
310
- md += `**State:** ${mr.state.toUpperCase()} · **Author:** ${mr.author.name} (@${mr.author.username})\n`;
311
- md += `**Branch:** ${mr.source_branch} → ${mr.target_branch}\n`;
312
- md += `**Created:** ${new Date(mr.created_at).toISOString().split("T")[0]} · **Updated:** ${new Date(mr.updated_at).toISOString().split("T")[0]}\n`;
313
- md += `**Merge Status:** ${mr.merge_status} · **Upvotes:** ${mr.upvotes} · **Downvotes:** ${mr.downvotes} · **Comments:** ${mr.user_notes_count}\n`;
314
-
315
- if (mr.labels.length > 0) {
316
- md += `**Labels:** ${mr.labels.join(", ")}\n`;
317
- }
281
+ const mr = tryParseJson<{
282
+ title: string;
283
+ description?: string;
284
+ state: string;
285
+ author: { name: string; username: string };
286
+ created_at: string;
287
+ updated_at: string;
288
+ source_branch: string;
289
+ target_branch: string;
290
+ labels: string[];
291
+ upvotes: number;
292
+ downvotes: number;
293
+ user_notes_count: number;
294
+ assignees?: Array<{ name: string }>;
295
+ draft: boolean;
296
+ merge_status: string;
297
+ }>(result.content);
298
+ if (!mr) return { content: "", ok: false };
299
+
300
+ let md = `# MR !${gl.id}: ${mr.title}\n\n`;
301
+ if (mr.draft) md += `**[DRAFT]** `;
302
+ md += `**State:** ${mr.state.toUpperCase()} · **Author:** ${mr.author.name} (@${mr.author.username})\n`;
303
+ md += `**Branch:** ${mr.source_branch} → ${mr.target_branch}\n`;
304
+ md += `**Created:** ${formatIsoDate(mr.created_at)} · **Updated:** ${formatIsoDate(mr.updated_at)}\n`;
305
+ md += `**Merge Status:** ${mr.merge_status} · **Upvotes:** ${mr.upvotes} · **Downvotes:** ${mr.downvotes} · **Comments:** ${mr.user_notes_count}\n`;
306
+
307
+ if (mr.labels.length > 0) {
308
+ md += `**Labels:** ${mr.labels.join(", ")}\n`;
309
+ }
318
310
 
319
- if (mr.assignees && mr.assignees.length > 0) {
320
- md += `**Assignees:** ${mr.assignees.map(a => a.name).join(", ")}\n`;
321
- }
311
+ if (mr.assignees && mr.assignees.length > 0) {
312
+ md += `**Assignees:** ${mr.assignees.map(a => a.name).join(", ")}\n`;
313
+ }
322
314
 
323
- md += `\n---\n\n## Description\n\n`;
324
- md += mr.description ? htmlToBasicMarkdown(mr.description) : "*No description*";
315
+ md += `\n---\n\n## Description\n\n`;
316
+ md += mr.description ? htmlToBasicMarkdown(mr.description) : "*No description*";
325
317
 
326
- return { content: md, ok: true };
327
- } catch {
328
- return { content: "", ok: false };
329
- }
318
+ return { content: md, ok: true };
330
319
  }
331
320
 
332
321
  /**
@@ -351,17 +340,13 @@ export const handleGitLab: SpecialHandler = async (
351
340
  notes.push(`Fetched raw file via GitLab API`);
352
341
  const result = await renderGitLabFile(gl, projectId, timeout, signal);
353
342
  if (result.ok) {
354
- const output = finalizeOutput(result.content);
355
- return {
343
+ return buildResult(result.content, {
356
344
  url,
357
- finalUrl: url,
358
- contentType: "text/plain",
359
345
  method: "gitlab-raw",
360
- content: output.content,
361
346
  fetchedAt,
362
- truncated: output.truncated,
363
347
  notes,
364
- };
348
+ contentType: "text/plain",
349
+ });
365
350
  }
366
351
  break;
367
352
  }
@@ -373,17 +358,7 @@ export const handleGitLab: SpecialHandler = async (
373
358
  notes.push(`Fetched directory tree via GitLab API`);
374
359
  const result = await renderGitLabTree(gl, projectId, timeout, signal);
375
360
  if (result.ok) {
376
- const output = finalizeOutput(result.content);
377
- return {
378
- url,
379
- finalUrl: url,
380
- contentType: "text/markdown",
381
- method: "gitlab-tree",
382
- content: output.content,
383
- fetchedAt,
384
- truncated: output.truncated,
385
- notes,
386
- };
361
+ return buildResult(result.content, { url, method: "gitlab-tree", fetchedAt, notes });
387
362
  }
388
363
  break;
389
364
  }
@@ -395,17 +370,7 @@ export const handleGitLab: SpecialHandler = async (
395
370
  notes.push(`Fetched issue via GitLab API`);
396
371
  const result = await renderGitLabIssue(gl, projectId, timeout, signal);
397
372
  if (result.ok) {
398
- const output = finalizeOutput(result.content);
399
- return {
400
- url,
401
- finalUrl: url,
402
- contentType: "text/markdown",
403
- method: "gitlab-issue",
404
- content: output.content,
405
- fetchedAt,
406
- truncated: output.truncated,
407
- notes,
408
- };
373
+ return buildResult(result.content, { url, method: "gitlab-issue", fetchedAt, notes });
409
374
  }
410
375
  break;
411
376
  }
@@ -417,17 +382,7 @@ export const handleGitLab: SpecialHandler = async (
417
382
  notes.push(`Fetched merge request via GitLab API`);
418
383
  const result = await renderGitLabMR(gl, projectId, timeout, signal);
419
384
  if (result.ok) {
420
- const output = finalizeOutput(result.content);
421
- return {
422
- url,
423
- finalUrl: url,
424
- contentType: "text/markdown",
425
- method: "gitlab-mr",
426
- content: output.content,
427
- fetchedAt,
428
- truncated: output.truncated,
429
- notes,
430
- };
385
+ return buildResult(result.content, { url, method: "gitlab-mr", fetchedAt, notes });
431
386
  }
432
387
  break;
433
388
  }
@@ -436,17 +391,7 @@ export const handleGitLab: SpecialHandler = async (
436
391
  notes.push(`Fetched repository via GitLab API`);
437
392
  const result = await renderGitLabRepo(gl, timeout, signal);
438
393
  if (result.ok) {
439
- const output = finalizeOutput(result.content);
440
- return {
441
- url,
442
- finalUrl: url,
443
- contentType: "text/markdown",
444
- method: "gitlab-repo",
445
- content: output.content,
446
- fetchedAt,
447
- truncated: output.truncated,
448
- notes,
449
- };
394
+ return buildResult(result.content, { url, method: "gitlab-repo", fetchedAt, notes });
450
395
  }
451
396
  break;
452
397
  }
@@ -1,6 +1,6 @@
1
1
  import { parse as parseHtml } from "node-html-parser";
2
2
  import type { RenderResult, SpecialHandler } from "./types";
3
- import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+ import { buildResult, htmlToBasicMarkdown, loadPage, tryParseJson } from "./types";
4
4
 
5
5
  interface GoModuleInfo {
6
6
  Version: string;
@@ -26,7 +26,6 @@ export const handleGoPkg: SpecialHandler = async (
26
26
 
27
27
  let modulePath: string;
28
28
  let version = "latest";
29
- let _subpackage = "";
30
29
 
31
30
  // Parse @version if present
32
31
  const atIndex = pathname.indexOf("@");
@@ -38,9 +37,7 @@ export const handleGoPkg: SpecialHandler = async (
38
37
  const slashIndex = afterAt.indexOf("/");
39
38
  if (slashIndex !== -1) {
40
39
  version = afterAt.slice(0, slashIndex);
41
- const remainder = afterAt.slice(slashIndex + 1);
42
40
  modulePath = beforeAt;
43
- _subpackage = remainder;
44
41
  } else {
45
42
  version = afterAt;
46
43
  modulePath = beforeAt;
@@ -65,8 +62,10 @@ export const handleGoPkg: SpecialHandler = async (
65
62
  const proxyResult = await loadPage(proxyUrl, { timeout, signal });
66
63
 
67
64
  if (proxyResult.ok) {
68
- moduleInfo = JSON.parse(proxyResult.content) as GoModuleInfo;
69
- version = moduleInfo.Version;
65
+ moduleInfo = tryParseJson<GoModuleInfo>(proxyResult.content);
66
+ if (moduleInfo) {
67
+ version = moduleInfo.Version;
68
+ }
70
69
  }
71
70
  } catch {
72
71
  // If @latest fails, might be a subpackage - will extract from page
@@ -77,7 +76,7 @@ export const handleGoPkg: SpecialHandler = async (
77
76
  const proxyResult = await loadPage(proxyUrl, { timeout, signal });
78
77
 
79
78
  if (proxyResult.ok) {
80
- moduleInfo = JSON.parse(proxyResult.content) as GoModuleInfo;
79
+ moduleInfo = tryParseJson<GoModuleInfo>(proxyResult.content);
81
80
  }
82
81
  } catch {
83
82
  // Proxy lookup failed, will rely on page data
@@ -87,25 +86,20 @@ export const handleGoPkg: SpecialHandler = async (
87
86
  // Fetch the pkg.go.dev page
88
87
  const pageResult = await loadPage(url, { timeout, signal });
89
88
  if (!pageResult.ok) {
90
- return {
89
+ return buildResult(`Failed to fetch pkg.go.dev page (status: ${pageResult.status ?? "unknown"})`, {
91
90
  url,
92
91
  finalUrl: pageResult.finalUrl,
93
- contentType: "text/plain",
94
92
  method: "go-pkg",
95
- content: `Failed to fetch pkg.go.dev page (status: ${pageResult.status ?? "unknown"})`,
96
93
  fetchedAt: new Date().toISOString(),
97
- truncated: false,
98
94
  notes: ["error"],
99
- };
95
+ contentType: "text/plain",
96
+ });
100
97
  }
101
98
 
102
99
  const doc = parseHtml(pageResult.content);
103
100
 
104
- // Extract module/package information
105
- const breadcrumb = doc.querySelector(".go-Breadcrumb");
106
- const _headerDiv = doc.querySelector(".go-Main-header");
107
-
108
101
  // Extract actual module path from breadcrumb or header
102
+ const breadcrumb = doc.querySelector(".go-Breadcrumb");
109
103
  if (breadcrumb) {
110
104
  const moduleLink = breadcrumb.querySelector("a[href^='/']");
111
105
  if (moduleLink) {
@@ -257,18 +251,14 @@ export const handleGoPkg: SpecialHandler = async (
257
251
  }
258
252
 
259
253
  const content = sections.join("\n");
260
- const { content: finalContent, truncated } = finalizeOutput(content);
261
254
 
262
- return {
255
+ return buildResult(content, {
263
256
  url,
264
257
  finalUrl: pageResult.finalUrl,
265
- contentType: "text/markdown",
266
258
  method: "go-pkg",
267
- content: finalContent,
268
259
  fetchedAt: new Date().toISOString(),
269
- truncated,
270
260
  notes,
271
- };
261
+ });
272
262
  } catch {
273
263
  return null;
274
264
  }
@@ -1,19 +1,71 @@
1
1
  import type { RenderResult, SpecialHandler } from "./types";
2
- import { finalizeOutput, loadPage } from "./types";
3
-
4
- interface HackagePackage {
5
- name: string;
6
- synopsis: string;
7
- description: string;
8
- license: string;
9
- author: string;
10
- maintainer: string;
11
- version: string;
2
+ import { buildResult, loadPage, tryParseJson } from "./types";
3
+
4
+ interface HackageVersionMap {
5
+ [version: string]: string;
6
+ }
7
+
8
+ interface ParsedCabal {
9
+ name?: string;
10
+ version?: string;
11
+ synopsis?: string;
12
+ description?: string;
13
+ license?: string;
14
+ author?: string;
15
+ maintainer?: string;
12
16
  homepage?: string;
13
- "bug-reports"?: string;
17
+ bugReports?: string;
14
18
  category?: string;
15
19
  stability?: string;
16
- dependencies?: Record<string, string>;
20
+ }
21
+
22
+ function compareVersions(a: string, b: string): number {
23
+ const aParts = a.split(".").map(part => Number.parseInt(part, 10) || 0);
24
+ const bParts = b.split(".").map(part => Number.parseInt(part, 10) || 0);
25
+ const max = Math.max(aParts.length, bParts.length);
26
+ for (let i = 0; i < max; i++) {
27
+ const delta = (aParts[i] || 0) - (bParts[i] || 0);
28
+ if (delta !== 0) return delta;
29
+ }
30
+ return 0;
31
+ }
32
+
33
+ function extractCabalField(content: string, fieldName: string): string | undefined {
34
+ const pattern = new RegExp(`^${fieldName}:\\s*(.*)$`, "im");
35
+ const match = content.match(pattern);
36
+ if (!match) return undefined;
37
+ return match[1].trim();
38
+ }
39
+
40
+ function extractCabalDescription(content: string): string | undefined {
41
+ const lines = content.split("\n");
42
+ const start = lines.findIndex(line => line.toLowerCase().startsWith("description:"));
43
+ if (start < 0) return undefined;
44
+ const value = lines[start].replace(/^description:\s*/i, "").trim();
45
+ const chunks: string[] = [value];
46
+ for (let i = start + 1; i < lines.length; i++) {
47
+ const line = lines[i];
48
+ if (!line.startsWith(" ")) break;
49
+ chunks.push(line.trim());
50
+ }
51
+ const description = chunks.join("\n").trim();
52
+ return description || undefined;
53
+ }
54
+
55
+ function parseCabal(content: string): ParsedCabal {
56
+ return {
57
+ name: extractCabalField(content, "name"),
58
+ version: extractCabalField(content, "version"),
59
+ synopsis: extractCabalField(content, "synopsis"),
60
+ description: extractCabalDescription(content),
61
+ license: extractCabalField(content, "license"),
62
+ author: extractCabalField(content, "author"),
63
+ maintainer: extractCabalField(content, "maintainer"),
64
+ homepage: extractCabalField(content, "homepage"),
65
+ bugReports: extractCabalField(content, "bug-reports"),
66
+ category: extractCabalField(content, "category"),
67
+ stability: extractCabalField(content, "stability"),
68
+ };
17
69
  }
18
70
 
19
71
  /**
@@ -35,27 +87,37 @@ export const handleHackage: SpecialHandler = async (
35
87
  const packageId = decodeURIComponent(match[1]);
36
88
  const fetchedAt = new Date().toISOString();
37
89
 
38
- // Fetch package info with JSON accept header
39
- const apiUrl = `https://hackage.haskell.org/package/${encodeURIComponent(packageId)}`;
40
- const result = await loadPage(apiUrl, {
90
+ // Version endpoint returns a map of version -> status.
91
+ const versionUrl = `https://hackage.haskell.org/package/${encodeURIComponent(packageId)}.json`;
92
+ const versionResult = await loadPage(versionUrl, {
41
93
  timeout,
42
94
  headers: { Accept: "application/json" },
43
95
  signal,
44
96
  });
45
97
 
46
- if (!result.ok) return null;
98
+ if (!versionResult.ok) return null;
47
99
 
48
- let pkg: HackagePackage;
49
- try {
50
- pkg = JSON.parse(result.content);
51
- } catch {
52
- return null;
53
- }
100
+ const versionMap = tryParseJson<HackageVersionMap>(versionResult.content);
101
+ if (!versionMap) return null;
102
+ const latestVersion = Object.keys(versionMap).sort(compareVersions).at(-1);
103
+ if (!latestVersion) return null;
104
+
105
+ // Fetch the latest cabal file for package metadata.
106
+ const cabalUrl = `https://hackage.haskell.org/package/${encodeURIComponent(packageId)}-${latestVersion}/${encodeURIComponent(packageId)}.cabal`;
107
+ const cabalResult = await loadPage(cabalUrl, {
108
+ timeout,
109
+ headers: { Accept: "text/plain" },
110
+ signal,
111
+ });
112
+
113
+ if (!cabalResult.ok) return null;
114
+
115
+ const pkg = parseCabal(cabalResult.content);
54
116
 
55
- let md = `# ${pkg.name}\n\n`;
117
+ let md = `# ${pkg.name || packageId}\n\n`;
56
118
  if (pkg.synopsis) md += `${pkg.synopsis}\n\n`;
57
119
 
58
- md += `**Version:** ${pkg.version}`;
120
+ md += `**Version:** ${pkg.version || latestVersion}`;
59
121
  if (pkg.license) md += ` · **License:** ${pkg.license}`;
60
122
  md += "\n";
61
123
 
@@ -64,30 +126,13 @@ export const handleHackage: SpecialHandler = async (
64
126
  if (pkg.category) md += `**Category:** ${pkg.category}\n`;
65
127
  if (pkg.stability) md += `**Stability:** ${pkg.stability}\n`;
66
128
  if (pkg.homepage) md += `**Homepage:** ${pkg.homepage}\n`;
67
- if (pkg["bug-reports"]) md += `**Bug Reports:** ${pkg["bug-reports"]}\n`;
129
+ if (pkg.bugReports) md += `**Bug Reports:** ${pkg.bugReports}\n`;
68
130
 
69
131
  if (pkg.description) {
70
132
  md += `\n## Description\n\n${pkg.description}\n`;
71
133
  }
72
134
 
73
- if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
74
- md += `\n## Dependencies\n\n`;
75
- for (const [dep, version] of Object.entries(pkg.dependencies)) {
76
- md += `- ${dep}: ${version}\n`;
77
- }
78
- }
79
-
80
- const output = finalizeOutput(md);
81
- return {
82
- url,
83
- finalUrl: url,
84
- contentType: "text/markdown",
85
- method: "hackage",
86
- content: output.content,
87
- fetchedAt,
88
- truncated: output.truncated,
89
- notes: ["Fetched via Hackage API"],
90
- };
135
+ return buildResult(md, { url, method: "hackage", fetchedAt, notes: ["Fetched via Hackage API"] });
91
136
  } catch {}
92
137
 
93
138
  return null;