@oh-my-pi/pi-coding-agent 3.30.0 → 3.31.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 (155) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/package.json +5 -5
  3. package/src/cli/args.ts +4 -0
  4. package/src/core/agent-session.ts +29 -2
  5. package/src/core/bash-executor.ts +2 -1
  6. package/src/core/custom-commands/bundled/review/index.ts +369 -14
  7. package/src/core/custom-commands/bundled/wt/index.ts +1 -1
  8. package/src/core/session-manager.ts +158 -246
  9. package/src/core/session-storage.ts +379 -0
  10. package/src/core/settings-manager.ts +155 -4
  11. package/src/core/system-prompt.ts +62 -64
  12. package/src/core/tools/ask.ts +5 -4
  13. package/src/core/tools/bash-interceptor.ts +26 -61
  14. package/src/core/tools/bash.ts +13 -8
  15. package/src/core/tools/edit-diff.ts +11 -4
  16. package/src/core/tools/edit.ts +7 -13
  17. package/src/core/tools/find.ts +111 -50
  18. package/src/core/tools/gemini-image.ts +128 -147
  19. package/src/core/tools/grep.ts +397 -415
  20. package/src/core/tools/index.test.ts +5 -1
  21. package/src/core/tools/index.ts +6 -8
  22. package/src/core/tools/ls.ts +12 -10
  23. package/src/core/tools/lsp/client.ts +58 -9
  24. package/src/core/tools/lsp/config.ts +205 -656
  25. package/src/core/tools/lsp/defaults.json +465 -0
  26. package/src/core/tools/lsp/index.ts +55 -32
  27. package/src/core/tools/lsp/rust-analyzer.ts +49 -10
  28. package/src/core/tools/lsp/types.ts +1 -0
  29. package/src/core/tools/lsp/utils.ts +1 -1
  30. package/src/core/tools/read.ts +150 -74
  31. package/src/core/tools/render-utils.ts +70 -10
  32. package/src/core/tools/review.ts +38 -126
  33. package/src/core/tools/task/artifacts.ts +5 -4
  34. package/src/core/tools/task/executor.ts +94 -83
  35. package/src/core/tools/task/index.ts +129 -92
  36. package/src/core/tools/task/parallel.ts +30 -3
  37. package/src/core/tools/task/render.ts +85 -39
  38. package/src/core/tools/task/types.ts +15 -6
  39. package/src/core/tools/task/worker.ts +124 -89
  40. package/src/core/tools/web-fetch.ts +112 -377
  41. package/src/core/tools/{web-fetch-handlers → web-scrapers}/artifacthub.ts +6 -1
  42. package/src/core/tools/{web-fetch-handlers → web-scrapers}/arxiv.ts +8 -4
  43. package/src/core/tools/{web-fetch-handlers → web-scrapers}/aur.ts +6 -2
  44. package/src/core/tools/{web-fetch-handlers → web-scrapers}/biorxiv.ts +6 -1
  45. package/src/core/tools/{web-fetch-handlers → web-scrapers}/bluesky.ts +10 -3
  46. package/src/core/tools/{web-fetch-handlers → web-scrapers}/brew.ts +6 -2
  47. package/src/core/tools/{web-fetch-handlers → web-scrapers}/cheatsh.ts +6 -1
  48. package/src/core/tools/{web-fetch-handlers → web-scrapers}/chocolatey.ts +6 -1
  49. package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
  50. package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
  51. package/src/core/tools/web-scrapers/clojars.ts +180 -0
  52. package/src/core/tools/{web-fetch-handlers → web-scrapers}/coingecko.ts +6 -1
  53. package/src/core/tools/{web-fetch-handlers → web-scrapers}/crates-io.ts +7 -2
  54. package/src/core/tools/web-scrapers/crossref.ts +149 -0
  55. package/src/core/tools/{web-fetch-handlers → web-scrapers}/devto.ts +8 -4
  56. package/src/core/tools/{web-fetch-handlers → web-scrapers}/discogs.ts +6 -1
  57. package/src/core/tools/web-scrapers/discourse.ts +221 -0
  58. package/src/core/tools/{web-fetch-handlers → web-scrapers}/dockerhub.ts +7 -3
  59. package/src/core/tools/web-scrapers/fdroid.ts +158 -0
  60. package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
  61. package/src/core/tools/web-scrapers/flathub.ts +239 -0
  62. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github-gist.ts +6 -2
  63. package/src/core/tools/{web-fetch-handlers → web-scrapers}/github.ts +63 -32
  64. package/src/core/tools/{web-fetch-handlers → web-scrapers}/gitlab.ts +31 -19
  65. package/src/core/tools/{web-fetch-handlers → web-scrapers}/go-pkg.ts +8 -4
  66. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackage.ts +6 -1
  67. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hackernews.ts +18 -18
  68. package/src/core/tools/{web-fetch-handlers → web-scrapers}/hex.ts +3 -3
  69. package/src/core/tools/{web-fetch-handlers → web-scrapers}/huggingface.ts +10 -10
  70. package/src/core/tools/{web-fetch-handlers → web-scrapers}/iacr.ts +8 -4
  71. package/src/core/tools/web-scrapers/index.ts +250 -0
  72. package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
  73. package/src/core/tools/web-scrapers/lemmy.ts +220 -0
  74. package/src/core/tools/{web-fetch-handlers → web-scrapers}/lobsters.ts +3 -3
  75. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mastodon.ts +11 -3
  76. package/src/core/tools/{web-fetch-handlers → web-scrapers}/maven.ts +6 -1
  77. package/src/core/tools/{web-fetch-handlers → web-scrapers}/mdn.ts +2 -2
  78. package/src/core/tools/{web-fetch-handlers → web-scrapers}/metacpan.ts +13 -7
  79. package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
  80. package/src/core/tools/{web-fetch-handlers → web-scrapers}/npm.ts +12 -5
  81. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nuget.ts +9 -5
  82. package/src/core/tools/{web-fetch-handlers → web-scrapers}/nvd.ts +6 -1
  83. package/src/core/tools/web-scrapers/ollama.ts +267 -0
  84. package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
  85. package/src/core/tools/{web-fetch-handlers → web-scrapers}/opencorporates.ts +2 -0
  86. package/src/core/tools/{web-fetch-handlers → web-scrapers}/openlibrary.ts +18 -12
  87. package/src/core/tools/web-scrapers/orcid.ts +299 -0
  88. package/src/core/tools/{web-fetch-handlers → web-scrapers}/osv.ts +6 -1
  89. package/src/core/tools/{web-fetch-handlers → web-scrapers}/packagist.ts +6 -2
  90. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pub-dev.ts +3 -3
  91. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pubmed.ts +8 -4
  92. package/src/core/tools/{web-fetch-handlers → web-scrapers}/pypi.ts +7 -3
  93. package/src/core/tools/web-scrapers/rawg.ts +124 -0
  94. package/src/core/tools/{web-fetch-handlers → web-scrapers}/readthedocs.ts +7 -3
  95. package/src/core/tools/{web-fetch-handlers → web-scrapers}/reddit.ts +6 -2
  96. package/src/core/tools/{web-fetch-handlers → web-scrapers}/repology.ts +6 -1
  97. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rfc.ts +7 -3
  98. package/src/core/tools/{web-fetch-handlers → web-scrapers}/rubygems.ts +6 -1
  99. package/src/core/tools/web-scrapers/searchcode.ts +217 -0
  100. package/src/core/tools/{web-fetch-handlers → web-scrapers}/sec-edgar.ts +6 -1
  101. package/src/core/tools/{web-fetch-handlers → web-scrapers}/semantic-scholar.ts +2 -2
  102. package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
  103. package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
  104. package/src/core/tools/web-scrapers/spdx.ts +121 -0
  105. package/src/core/tools/{web-fetch-handlers → web-scrapers}/spotify.ts +3 -3
  106. package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackoverflow.ts +3 -2
  107. package/src/core/tools/{web-fetch-handlers → web-scrapers}/terraform.ts +11 -3
  108. package/src/core/tools/{web-fetch-handlers → web-scrapers}/tldr.ts +6 -2
  109. package/src/core/tools/{web-fetch-handlers → web-scrapers}/twitter.ts +15 -3
  110. package/src/core/tools/{web-fetch-handlers → web-scrapers}/types.ts +98 -27
  111. package/src/core/tools/web-scrapers/utils.ts +162 -0
  112. package/src/core/tools/{web-fetch-handlers → web-scrapers}/vimeo.ts +3 -3
  113. package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
  114. package/src/core/tools/web-scrapers/w3c.ts +163 -0
  115. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikidata.ts +13 -5
  116. package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.ts +7 -3
  117. package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.ts +72 -20
  118. package/src/core/tools/write.ts +21 -18
  119. package/src/core/voice.ts +3 -2
  120. package/src/lib/worktree/collapse.ts +2 -1
  121. package/src/lib/worktree/git.ts +2 -18
  122. package/src/main.ts +59 -3
  123. package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
  124. package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
  125. package/src/modes/interactive/components/hook-editor.ts +2 -1
  126. package/src/modes/interactive/components/model-selector.ts +19 -4
  127. package/src/modes/interactive/interactive-mode.ts +41 -38
  128. package/src/modes/interactive/theme/theme.ts +58 -58
  129. package/src/modes/rpc/rpc-mode.ts +10 -9
  130. package/src/prompts/review-request.md +27 -0
  131. package/src/prompts/reviewer.md +64 -68
  132. package/src/prompts/tools/output.md +22 -3
  133. package/src/prompts/tools/task.md +32 -33
  134. package/src/utils/clipboard.ts +2 -1
  135. package/examples/extensions/subagent/agents/reviewer.md +0 -35
  136. package/src/core/tools/web-fetch-handlers/index.ts +0 -69
  137. package/src/core/tools/web-fetch-handlers/utils.ts +0 -91
  138. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/academic.test.ts +0 -0
  139. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/business.test.ts +0 -0
  140. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/dev-platforms.test.ts +0 -0
  141. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/documentation.test.ts +0 -0
  142. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/finance-media.test.ts +0 -0
  143. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/git-hosting.test.ts +0 -0
  144. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/media.test.ts +0 -0
  145. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers-2.test.ts +0 -0
  146. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-managers.test.ts +0 -0
  147. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/package-registries.test.ts +0 -0
  148. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/research.test.ts +0 -0
  149. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/security.test.ts +0 -0
  150. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social-extended.test.ts +0 -0
  151. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/social.test.ts +0 -0
  152. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/stackexchange.test.ts +0 -0
  153. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/standards.test.ts +0 -0
  154. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/wikipedia.test.ts +0 -0
  155. /package/src/core/tools/{web-fetch-handlers → web-scrapers}/youtube.test.ts +0 -0
@@ -0,0 +1,149 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface CrossrefAuthor {
5
+ given?: string;
6
+ family?: string;
7
+ name?: string;
8
+ }
9
+
10
+ interface CrossrefDate {
11
+ "date-parts"?: number[][];
12
+ }
13
+
14
+ interface CrossrefMessage {
15
+ title?: string[];
16
+ author?: CrossrefAuthor[];
17
+ "container-title"?: string[];
18
+ "short-container-title"?: string[];
19
+ publisher?: string;
20
+ published?: CrossrefDate;
21
+ "published-print"?: CrossrefDate;
22
+ "published-online"?: CrossrefDate;
23
+ issued?: CrossrefDate;
24
+ created?: CrossrefDate;
25
+ DOI?: string;
26
+ abstract?: string;
27
+ type?: string;
28
+ }
29
+
30
+ interface CrossrefResponse {
31
+ message?: CrossrefMessage;
32
+ }
33
+
34
+ const DOI_HOSTS = new Set(["doi.org", "dx.doi.org", "www.doi.org"]);
35
+
36
+ function extractDoi(pathname: string): string | null {
37
+ const raw = pathname.replace(/^\/+/, "");
38
+ if (!raw) return null;
39
+ return decodeURIComponent(raw);
40
+ }
41
+
42
+ function formatAuthors(authors?: CrossrefAuthor[]): string | null {
43
+ if (!authors || authors.length === 0) return null;
44
+ const names = authors
45
+ .map((author) => {
46
+ if (author.name) return author.name;
47
+ const parts = [author.given, author.family].filter(Boolean);
48
+ return parts.length > 0 ? parts.join(" ") : null;
49
+ })
50
+ .filter((name): name is string => Boolean(name));
51
+ if (names.length === 0) return null;
52
+ return names.join(", ");
53
+ }
54
+
55
+ function formatDate(date?: CrossrefDate): string | null {
56
+ const parts = date?.["date-parts"]?.[0];
57
+ if (!parts || parts.length === 0) return null;
58
+ const [year, month, day] = parts;
59
+ if (!year) return null;
60
+ const formatted = [
61
+ String(year),
62
+ month ? String(month).padStart(2, "0") : "",
63
+ day ? String(day).padStart(2, "0") : "",
64
+ ].filter(Boolean);
65
+ return formatted.join("-");
66
+ }
67
+
68
+ function formatAbstract(abstract?: string): string | null {
69
+ if (!abstract) return null;
70
+ const normalized = abstract.replace(/<\/?jats:p[^>]*>/g, (match) => (match.startsWith("</") ? "</p>" : "<p>"));
71
+ const markdown = htmlToBasicMarkdown(normalized);
72
+ return markdown.trim().length > 0 ? markdown : null;
73
+ }
74
+
75
+ export const handleCrossref: SpecialHandler = async (
76
+ url: string,
77
+ timeout: number,
78
+ signal?: AbortSignal,
79
+ ): Promise<RenderResult | null> => {
80
+ try {
81
+ const parsed = new URL(url);
82
+ if (!DOI_HOSTS.has(parsed.hostname.toLowerCase())) return null;
83
+
84
+ const doi = extractDoi(parsed.pathname);
85
+ if (!doi) return null;
86
+
87
+ const fetchedAt = new Date().toISOString();
88
+ const apiUrl = `https://api.crossref.org/works/${encodeURIComponent(doi)}`;
89
+ const result = await loadPage(apiUrl, {
90
+ timeout,
91
+ signal,
92
+ headers: {
93
+ Accept: "application/json",
94
+ },
95
+ });
96
+
97
+ if (!result.ok) return null;
98
+
99
+ let data: CrossrefResponse;
100
+ try {
101
+ data = JSON.parse(result.content);
102
+ } catch {
103
+ return null;
104
+ }
105
+
106
+ const message = data.message;
107
+ if (!message) return null;
108
+
109
+ const title = message.title?.[0]?.trim() || "CrossRef Record";
110
+ const authors = formatAuthors(message.author);
111
+ const journal = message["container-title"]?.[0] || message["short-container-title"]?.[0];
112
+ const publisher = message.publisher;
113
+ const published =
114
+ formatDate(message.published) ||
115
+ formatDate(message["published-print"]) ||
116
+ formatDate(message["published-online"]) ||
117
+ formatDate(message.issued) ||
118
+ formatDate(message.created);
119
+ const doiValue = message.DOI || doi;
120
+ const abstract = formatAbstract(message.abstract);
121
+ const type = message.type?.replace(/-/g, " ");
122
+
123
+ let md = `# ${title}\n\n`;
124
+ if (authors) md += `**Authors:** ${authors}\n`;
125
+ if (journal) md += `**Journal:** ${journal}\n`;
126
+ if (publisher) md += `**Publisher:** ${publisher}\n`;
127
+ if (published) md += `**Published:** ${published}\n`;
128
+ md += `**DOI:** ${doiValue}\n`;
129
+ if (type) md += `**Type:** ${type}\n`;
130
+ md += "\n---\n\n";
131
+ md += "## Abstract\n\n";
132
+ md += abstract || "No abstract available.";
133
+ md += "\n";
134
+
135
+ const output = finalizeOutput(md);
136
+ return {
137
+ url,
138
+ finalUrl: url,
139
+ contentType: "text/markdown",
140
+ method: "crossref",
141
+ content: output.content,
142
+ fetchedAt,
143
+ truncated: output.truncated,
144
+ notes: ["Fetched via CrossRef API"],
145
+ };
146
+ } catch {}
147
+
148
+ return null;
149
+ };
@@ -23,7 +23,11 @@ interface DevToArticle {
23
23
  /**
24
24
  * Handle dev.to URLs via API
25
25
  */
26
- export const handleDevTo: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
26
+ export const handleDevTo: SpecialHandler = async (
27
+ url: string,
28
+ timeout: number,
29
+ signal?: AbortSignal,
30
+ ): Promise<RenderResult | null> => {
27
31
  try {
28
32
  const parsed = new URL(url);
29
33
  if (parsed.hostname !== "dev.to") return null;
@@ -39,7 +43,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number):
39
43
  const tag = pathParts[1];
40
44
  const apiUrl = `https://dev.to/api/articles?tag=${encodeURIComponent(tag)}&per_page=20`;
41
45
 
42
- const result = await loadPage(apiUrl, { timeout });
46
+ const result = await loadPage(apiUrl, { timeout, signal });
43
47
  if (!result.ok) return null;
44
48
 
45
49
  const articles = JSON.parse(result.content) as DevToArticle[];
@@ -82,7 +86,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number):
82
86
  const username = pathParts[0];
83
87
  const apiUrl = `https://dev.to/api/articles?username=${encodeURIComponent(username)}&per_page=20`;
84
88
 
85
- const result = await loadPage(apiUrl, { timeout });
89
+ const result = await loadPage(apiUrl, { timeout, signal });
86
90
  if (!result.ok) return null;
87
91
 
88
92
  const articles = JSON.parse(result.content) as DevToArticle[];
@@ -125,7 +129,7 @@ export const handleDevTo: SpecialHandler = async (url: string, timeout: number):
125
129
  const slug = pathParts[1];
126
130
  const apiUrl = `https://dev.to/api/articles/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`;
127
131
 
128
- const result = await loadPage(apiUrl, { timeout });
132
+ const result = await loadPage(apiUrl, { timeout, signal });
129
133
  if (!result.ok) return null;
130
134
 
131
135
  const article = JSON.parse(result.content) as DevToArticle;
@@ -248,7 +248,11 @@ function buildMasterMarkdown(master: DiscogsMaster): string {
248
248
  return sections.join("\n");
249
249
  }
250
250
 
251
- export const handleDiscogs: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
251
+ export const handleDiscogs: SpecialHandler = async (
252
+ url: string,
253
+ timeout: number,
254
+ signal?: AbortSignal,
255
+ ): Promise<RenderResult | null> => {
252
256
  try {
253
257
  const parsed = new URL(url);
254
258
  if (!parsed.hostname.includes("discogs.com")) return null;
@@ -269,6 +273,7 @@ export const handleDiscogs: SpecialHandler = async (url: string, timeout: number
269
273
 
270
274
  const result = await loadPage(apiUrl, {
271
275
  timeout,
276
+ signal,
272
277
  headers: {
273
278
  Accept: "application/json",
274
279
  "User-Agent": "CodingAgent/1.0 +https://github.com/can1357/oh-my-pi",
@@ -0,0 +1,221 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ interface DiscourseUser {
5
+ username?: string;
6
+ name?: string;
7
+ }
8
+
9
+ interface DiscoursePost {
10
+ id: number;
11
+ username?: string;
12
+ name?: string;
13
+ created_at?: string;
14
+ cooked?: string;
15
+ raw?: string;
16
+ like_count?: number;
17
+ post_number?: number;
18
+ }
19
+
20
+ interface DiscoursePostResponse extends DiscoursePost {
21
+ topic_id?: number;
22
+ }
23
+
24
+ interface DiscourseTopic {
25
+ id?: number;
26
+ title?: string;
27
+ fancy_title?: string;
28
+ posts_count?: number;
29
+ created_at?: string;
30
+ views?: number;
31
+ like_count?: number;
32
+ tags?: string[];
33
+ category_id?: number;
34
+ category_slug?: string;
35
+ category?: { id?: number; name?: string; slug?: string };
36
+ excerpt?: string;
37
+ details?: { created_by?: DiscourseUser };
38
+ post_stream?: { posts?: DiscoursePost[] };
39
+ }
40
+
41
+ const MAX_POSTS = 20;
42
+
43
+ function normalizeBasePath(basePath: string): string {
44
+ if (!basePath || basePath === "/") return "";
45
+ return basePath.replace(/\/$/, "");
46
+ }
47
+
48
+ function parseTopicPath(pathname: string): { basePath: string; topicId: string } | null {
49
+ const match = pathname.match(/^(.*?)(?:\/t\/)(?:[^/]+\/)?(\d+)(?:\.json)?(?:\/|$)/);
50
+ if (!match) return null;
51
+ return { basePath: match[1] ?? "", topicId: match[2] };
52
+ }
53
+
54
+ function parsePostPath(pathname: string): { basePath: string; postId: string } | null {
55
+ const match = pathname.match(/^(.*?)(?:\/posts\/)(\d+)(?:\.json)?(?:\/|$)/);
56
+ if (!match) return null;
57
+ return { basePath: match[1] ?? "", postId: match[2] };
58
+ }
59
+
60
+ function formatAuthor(user?: DiscourseUser | null): string {
61
+ if (!user) return "unknown";
62
+ const name = user.name?.trim();
63
+ const username = user.username?.trim();
64
+ if (name && username && name !== username) return `${name} (@${username})`;
65
+ if (username) return `@${username}`;
66
+ if (name) return name;
67
+ return "unknown";
68
+ }
69
+
70
+ function formatIsoDate(value?: string): string {
71
+ if (!value) return "unknown";
72
+ const date = new Date(value);
73
+ if (Number.isNaN(date.getTime())) return value;
74
+ return date.toISOString().split("T")[0];
75
+ }
76
+
77
+ function formatCategory(topic: DiscourseTopic): string | null {
78
+ const parts: string[] = [];
79
+ const name = topic.category?.name ?? topic.category_slug;
80
+ if (name) parts.push(name);
81
+ const id = topic.category?.id ?? topic.category_id;
82
+ if (id != null) parts.push(`#${id}`);
83
+ return parts.length ? parts.join(" ") : null;
84
+ }
85
+
86
+ function formatPostBody(post: DiscoursePost): string {
87
+ const raw = post.raw?.trim();
88
+ if (raw) return raw;
89
+ const cooked = post.cooked?.trim();
90
+ if (!cooked) return "";
91
+ return htmlToBasicMarkdown(cooked);
92
+ }
93
+
94
+ function buildTopicUrl(baseUrl: string, topicId: string): string {
95
+ const topicUrl = new URL(`${baseUrl}/t/${topicId}.json`);
96
+ topicUrl.searchParams.set("include_raw", "1");
97
+ return topicUrl.toString();
98
+ }
99
+
100
+ function buildPostUrl(baseUrl: string, postId: string): string {
101
+ const postUrl = new URL(`${baseUrl}/posts/${postId}.json`);
102
+ postUrl.searchParams.set("include_raw", "1");
103
+ return postUrl.toString();
104
+ }
105
+
106
+ /**
107
+ * Handle Discourse forum URLs via API
108
+ */
109
+ export const handleDiscourse: SpecialHandler = async (
110
+ url: string,
111
+ timeout: number,
112
+ signal?: AbortSignal,
113
+ ): Promise<RenderResult | null> => {
114
+ try {
115
+ const parsed = new URL(url);
116
+ const topicMatch = parseTopicPath(parsed.pathname);
117
+ const postMatch = topicMatch ? null : parsePostPath(parsed.pathname);
118
+ if (!topicMatch && !postMatch) return null;
119
+
120
+ const basePath = normalizeBasePath(topicMatch?.basePath ?? postMatch?.basePath ?? "");
121
+ const baseUrl = `${parsed.origin}${basePath}`;
122
+
123
+ let requestedPost: DiscoursePost | null = null;
124
+ let topicId = topicMatch?.topicId ?? null;
125
+
126
+ if (!topicId && postMatch) {
127
+ const postResult = await loadPage(buildPostUrl(baseUrl, postMatch.postId), { timeout, signal });
128
+ if (!postResult.ok) return null;
129
+
130
+ let postData: DiscoursePostResponse;
131
+ try {
132
+ postData = JSON.parse(postResult.content) as DiscoursePostResponse;
133
+ } catch {
134
+ return null;
135
+ }
136
+
137
+ if (!postData.topic_id) return null;
138
+ topicId = String(postData.topic_id);
139
+ requestedPost = postData;
140
+ }
141
+
142
+ if (!topicId) return null;
143
+
144
+ const topicResult = await loadPage(buildTopicUrl(baseUrl, topicId), { timeout, signal });
145
+ if (!topicResult.ok) return null;
146
+
147
+ let topic: DiscourseTopic;
148
+ try {
149
+ topic = JSON.parse(topicResult.content) as DiscourseTopic;
150
+ } catch {
151
+ return null;
152
+ }
153
+
154
+ const title = topic.title || topic.fancy_title;
155
+ if (!title) return null;
156
+
157
+ const fetchedAt = new Date().toISOString();
158
+
159
+ const posts: DiscoursePost[] = [...(topic.post_stream?.posts ?? [])];
160
+ if (requestedPost && !posts.some((post) => post.id === requestedPost?.id)) {
161
+ posts.unshift(requestedPost);
162
+ }
163
+
164
+ let md = `# ${title}\n\n`;
165
+
166
+ const metaParts: string[] = [];
167
+ if (topic.id != null) metaParts.push(`**Topic ID:** ${topic.id}`);
168
+ if (topic.posts_count != null) metaParts.push(`**Posts:** ${topic.posts_count}`);
169
+ if (topic.views != null) metaParts.push(`**Views:** ${topic.views}`);
170
+ if (topic.like_count != null) metaParts.push(`**Likes:** ${topic.like_count}`);
171
+ if (metaParts.length) md += `${metaParts.join(" | ")}\n`;
172
+
173
+ const categoryLabel = formatCategory(topic);
174
+ if (categoryLabel) md += `**Category:** ${categoryLabel}\n`;
175
+ if (topic.tags?.length) md += `**Tags:** ${topic.tags.join(", ")}\n`;
176
+
177
+ const createdBy = formatAuthor(topic.details?.created_by ?? null);
178
+ if (createdBy !== "unknown" || topic.created_at) {
179
+ md += `**Created by:** ${createdBy} - ${formatIsoDate(topic.created_at)}\n`;
180
+ }
181
+
182
+ md += "\n";
183
+
184
+ const description = topic.excerpt
185
+ ? htmlToBasicMarkdown(topic.excerpt)
186
+ : posts.length
187
+ ? formatPostBody(posts[0])
188
+ : "";
189
+ if (description) {
190
+ md += `## Description\n\n${description}\n\n`;
191
+ }
192
+
193
+ if (posts.length) {
194
+ md += "## Posts\n\n";
195
+ for (const post of posts.slice(0, MAX_POSTS)) {
196
+ const author = formatAuthor({ name: post.name, username: post.username });
197
+ const date = formatIsoDate(post.created_at);
198
+ const likes = post.like_count ?? 0;
199
+ const content = formatPostBody(post);
200
+ const postLabel = post.post_number != null ? `Post ${post.post_number}` : `Post ${post.id}`;
201
+
202
+ md += `### ${postLabel} - ${author} - ${date} - Likes: ${likes}\n\n`;
203
+ md += content ? `${content}\n\n---\n\n` : "_No content available._\n\n---\n\n";
204
+ }
205
+ }
206
+
207
+ const output = finalizeOutput(md);
208
+ return {
209
+ url,
210
+ finalUrl: url,
211
+ contentType: "text/markdown",
212
+ method: "discourse-api",
213
+ content: output.content,
214
+ fetchedAt,
215
+ truncated: output.truncated,
216
+ notes: ["Fetched via Discourse API"],
217
+ };
218
+ } catch {}
219
+
220
+ return null;
221
+ };
@@ -39,7 +39,11 @@ function formatSize(bytes: number): string {
39
39
  /**
40
40
  * Handle Docker Hub URLs via API
41
41
  */
42
- export const handleDockerHub: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
42
+ export const handleDockerHub: SpecialHandler = async (
43
+ url: string,
44
+ timeout: number,
45
+ signal?: AbortSignal,
46
+ ): Promise<RenderResult | null> => {
43
47
  try {
44
48
  const parsed = new URL(url);
45
49
  if (!parsed.hostname.includes("hub.docker.com")) return null;
@@ -67,8 +71,8 @@ export const handleDockerHub: SpecialHandler = async (url: string, timeout: numb
67
71
  const tagsUrl = `https://hub.docker.com/v2/repositories/${namespace}/${repository}/tags/?page_size=10`;
68
72
 
69
73
  const [repoResult, tagsResult] = await Promise.all([
70
- loadPage(repoUrl, { timeout, headers: { Accept: "application/json" } }),
71
- loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" } }),
74
+ loadPage(repoUrl, { timeout, headers: { Accept: "application/json" }, signal }),
75
+ loadPage(tagsUrl, { timeout: Math.min(timeout, 10), headers: { Accept: "application/json" }, signal }),
72
76
  ]);
73
77
 
74
78
  if (!repoResult.ok) return null;
@@ -0,0 +1,158 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ type LocalizedText = string | Record<string, string>;
5
+
6
+ type FdroidPackage = {
7
+ packageName?: string;
8
+ name?: LocalizedText;
9
+ summary?: LocalizedText;
10
+ description?: LocalizedText;
11
+ author?: string | { name?: string; email?: string };
12
+ authorName?: string;
13
+ authorEmail?: string;
14
+ license?: string;
15
+ categories?: string[];
16
+ antiFeatures?: string[];
17
+ sourceCode?: string;
18
+ packages?: Array<{
19
+ versionName?: string;
20
+ versionCode?: number;
21
+ added?: number;
22
+ antiFeatures?: string[];
23
+ }>;
24
+ suggestedVersionCode?: number;
25
+ suggestedVersionName?: string;
26
+ };
27
+
28
+ function pickLocalizedText(value?: LocalizedText): string | undefined {
29
+ if (!value) return undefined;
30
+ if (typeof value === "string") return value;
31
+ const preferred = value["en-US"] ?? value.en_US ?? value.en;
32
+ if (preferred) return preferred;
33
+ const first = Object.values(value).find((entry) => typeof entry === "string");
34
+ return first;
35
+ }
36
+
37
+ function normalizeAuthor(data: FdroidPackage): string | undefined {
38
+ if (data.authorName) return data.authorName;
39
+ if (typeof data.author === "string") return data.author;
40
+ if (data.author && typeof data.author !== "string" && typeof data.author.name === "string") return data.author.name;
41
+ if (data.authorEmail) return data.authorEmail;
42
+ return undefined;
43
+ }
44
+
45
+ function normalizeAuthorEmail(data: FdroidPackage): string | undefined {
46
+ if (data.authorEmail) return data.authorEmail;
47
+ if (data.author && typeof data.author !== "string" && typeof data.author.email === "string")
48
+ return data.author.email;
49
+ return undefined;
50
+ }
51
+
52
+ function collectAntiFeatures(data: FdroidPackage): string[] {
53
+ const values = new Set<string>();
54
+ for (const feature of data.antiFeatures ?? []) values.add(feature);
55
+ for (const pkg of data.packages ?? []) {
56
+ for (const feature of pkg.antiFeatures ?? []) values.add(feature);
57
+ }
58
+ return Array.from(values);
59
+ }
60
+
61
+ function resolveSuggestedVersion(data: FdroidPackage): string | undefined {
62
+ if (data.suggestedVersionName) return data.suggestedVersionName;
63
+ if (data.suggestedVersionCode) {
64
+ const match = data.packages?.find((pkg) => pkg.versionCode === data.suggestedVersionCode);
65
+ if (match?.versionName) return match.versionName;
66
+ }
67
+ return data.packages?.[0]?.versionName;
68
+ }
69
+
70
+ /**
71
+ * Handle F-Droid URLs via API
72
+ */
73
+ export const handleFdroid: SpecialHandler = async (
74
+ url: string,
75
+ timeout: number,
76
+ signal?: AbortSignal,
77
+ ): Promise<RenderResult | null> => {
78
+ try {
79
+ const parsed = new URL(url);
80
+ if (parsed.hostname !== "f-droid.org" && parsed.hostname !== "www.f-droid.org") return null;
81
+
82
+ // Extract package name from /packages/{packageName} or /en/packages/{packageName}
83
+ const match = parsed.pathname.match(/^\/(?:en\/)?packages\/([^/]+)/);
84
+ if (!match) return null;
85
+
86
+ const packageName = decodeURIComponent(match[1]);
87
+ const fetchedAt = new Date().toISOString();
88
+ const apiUrl = `https://f-droid.org/api/v1/packages/${encodeURIComponent(packageName)}`;
89
+
90
+ const result = await loadPage(apiUrl, {
91
+ timeout,
92
+ headers: { Accept: "application/json" },
93
+ signal,
94
+ });
95
+
96
+ if (!result.ok) return null;
97
+
98
+ let data: FdroidPackage;
99
+ try {
100
+ data = JSON.parse(result.content) as FdroidPackage;
101
+ } catch {
102
+ return null;
103
+ }
104
+
105
+ const displayName = pickLocalizedText(data.name) ?? packageName;
106
+ const summary = pickLocalizedText(data.summary);
107
+ const description = pickLocalizedText(data.description);
108
+ const author = normalizeAuthor(data);
109
+ const authorEmail = normalizeAuthorEmail(data);
110
+ const antiFeatures = collectAntiFeatures(data);
111
+ const latestVersion = resolveSuggestedVersion(data);
112
+
113
+ let md = `# ${displayName}\n\n`;
114
+ if (summary) md += `${summary}\n\n`;
115
+
116
+ md += `**Package:** ${packageName}`;
117
+ if (latestVersion) md += ` · **Latest:** ${latestVersion}`;
118
+ if (data.license) md += ` · **License:** ${data.license}`;
119
+ md += "\n";
120
+
121
+ if (author) {
122
+ md += `**Author:** ${author}`;
123
+ if (authorEmail && authorEmail !== author) md += ` <${authorEmail}>`;
124
+ md += "\n";
125
+ }
126
+
127
+ if (data.sourceCode) md += `**Source Code:** ${data.sourceCode}\n`;
128
+ if (data.categories?.length) md += `**Categories:** ${data.categories.join(", ")}\n`;
129
+ if (antiFeatures.length) md += `**Anti-Features:** ${antiFeatures.join(", ")}\n`;
130
+
131
+ if (description) {
132
+ md += `\n## Description\n\n${description}\n`;
133
+ }
134
+
135
+ if (data.packages?.length) {
136
+ md += "\n## Version History\n\n";
137
+ for (const version of data.packages.slice(0, 10)) {
138
+ const label = version.versionName ?? "unknown";
139
+ const code = version.versionCode ? ` (${version.versionCode})` : "";
140
+ md += `- ${label}${code}\n`;
141
+ }
142
+ }
143
+
144
+ const output = finalizeOutput(md);
145
+ return {
146
+ url,
147
+ finalUrl: url,
148
+ contentType: "text/markdown",
149
+ method: "fdroid",
150
+ content: output.content,
151
+ fetchedAt,
152
+ truncated: output.truncated,
153
+ notes: ["Fetched via F-Droid API"],
154
+ };
155
+ } catch {}
156
+
157
+ return null;
158
+ };