@oh-my-pi/pi-coding-agent 3.25.0 → 3.30.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 (85) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +4 -4
  3. package/src/core/tools/complete.ts +2 -4
  4. package/src/core/tools/jtd-to-json-schema.ts +174 -196
  5. package/src/core/tools/read.ts +4 -4
  6. package/src/core/tools/task/executor.ts +146 -20
  7. package/src/core/tools/task/name-generator.ts +1544 -214
  8. package/src/core/tools/task/types.ts +19 -5
  9. package/src/core/tools/task/worker.ts +103 -13
  10. package/src/core/tools/web-fetch-handlers/academic.test.ts +239 -0
  11. package/src/core/tools/web-fetch-handlers/artifacthub.ts +210 -0
  12. package/src/core/tools/web-fetch-handlers/arxiv.ts +84 -0
  13. package/src/core/tools/web-fetch-handlers/aur.ts +171 -0
  14. package/src/core/tools/web-fetch-handlers/biorxiv.ts +136 -0
  15. package/src/core/tools/web-fetch-handlers/bluesky.ts +277 -0
  16. package/src/core/tools/web-fetch-handlers/brew.ts +173 -0
  17. package/src/core/tools/web-fetch-handlers/business.test.ts +82 -0
  18. package/src/core/tools/web-fetch-handlers/cheatsh.ts +73 -0
  19. package/src/core/tools/web-fetch-handlers/chocolatey.ts +153 -0
  20. package/src/core/tools/web-fetch-handlers/coingecko.ts +179 -0
  21. package/src/core/tools/web-fetch-handlers/crates-io.ts +123 -0
  22. package/src/core/tools/web-fetch-handlers/dev-platforms.test.ts +254 -0
  23. package/src/core/tools/web-fetch-handlers/devto.ts +173 -0
  24. package/src/core/tools/web-fetch-handlers/discogs.ts +303 -0
  25. package/src/core/tools/web-fetch-handlers/dockerhub.ts +156 -0
  26. package/src/core/tools/web-fetch-handlers/documentation.test.ts +85 -0
  27. package/src/core/tools/web-fetch-handlers/finance-media.test.ts +144 -0
  28. package/src/core/tools/web-fetch-handlers/git-hosting.test.ts +272 -0
  29. package/src/core/tools/web-fetch-handlers/github-gist.ts +64 -0
  30. package/src/core/tools/web-fetch-handlers/github.ts +424 -0
  31. package/src/core/tools/web-fetch-handlers/gitlab.ts +444 -0
  32. package/src/core/tools/web-fetch-handlers/go-pkg.ts +271 -0
  33. package/src/core/tools/web-fetch-handlers/hackage.ts +89 -0
  34. package/src/core/tools/web-fetch-handlers/hackernews.ts +208 -0
  35. package/src/core/tools/web-fetch-handlers/hex.ts +121 -0
  36. package/src/core/tools/web-fetch-handlers/huggingface.ts +385 -0
  37. package/src/core/tools/web-fetch-handlers/iacr.ts +82 -0
  38. package/src/core/tools/web-fetch-handlers/index.ts +69 -0
  39. package/src/core/tools/web-fetch-handlers/lobsters.ts +186 -0
  40. package/src/core/tools/web-fetch-handlers/mastodon.ts +302 -0
  41. package/src/core/tools/web-fetch-handlers/maven.ts +147 -0
  42. package/src/core/tools/web-fetch-handlers/mdn.ts +174 -0
  43. package/src/core/tools/web-fetch-handlers/media.test.ts +138 -0
  44. package/src/core/tools/web-fetch-handlers/metacpan.ts +247 -0
  45. package/src/core/tools/web-fetch-handlers/npm.ts +107 -0
  46. package/src/core/tools/web-fetch-handlers/nuget.ts +201 -0
  47. package/src/core/tools/web-fetch-handlers/nvd.ts +238 -0
  48. package/src/core/tools/web-fetch-handlers/opencorporates.ts +273 -0
  49. package/src/core/tools/web-fetch-handlers/openlibrary.ts +313 -0
  50. package/src/core/tools/web-fetch-handlers/osv.ts +184 -0
  51. package/src/core/tools/web-fetch-handlers/package-managers-2.test.ts +199 -0
  52. package/src/core/tools/web-fetch-handlers/package-managers.test.ts +171 -0
  53. package/src/core/tools/web-fetch-handlers/package-registries.test.ts +259 -0
  54. package/src/core/tools/web-fetch-handlers/packagist.ts +170 -0
  55. package/src/core/tools/web-fetch-handlers/pub-dev.ts +185 -0
  56. package/src/core/tools/web-fetch-handlers/pubmed.ts +174 -0
  57. package/src/core/tools/web-fetch-handlers/pypi.ts +125 -0
  58. package/src/core/tools/web-fetch-handlers/readthedocs.ts +122 -0
  59. package/src/core/tools/web-fetch-handlers/reddit.ts +100 -0
  60. package/src/core/tools/web-fetch-handlers/repology.ts +257 -0
  61. package/src/core/tools/web-fetch-handlers/research.test.ts +107 -0
  62. package/src/core/tools/web-fetch-handlers/rfc.ts +205 -0
  63. package/src/core/tools/web-fetch-handlers/rubygems.ts +112 -0
  64. package/src/core/tools/web-fetch-handlers/sec-edgar.ts +269 -0
  65. package/src/core/tools/web-fetch-handlers/security.test.ts +103 -0
  66. package/src/core/tools/web-fetch-handlers/semantic-scholar.ts +190 -0
  67. package/src/core/tools/web-fetch-handlers/social-extended.test.ts +192 -0
  68. package/src/core/tools/web-fetch-handlers/social.test.ts +259 -0
  69. package/src/core/tools/web-fetch-handlers/spotify.ts +218 -0
  70. package/src/core/tools/web-fetch-handlers/stackexchange.test.ts +120 -0
  71. package/src/core/tools/web-fetch-handlers/stackoverflow.ts +123 -0
  72. package/src/core/tools/web-fetch-handlers/standards.test.ts +122 -0
  73. package/src/core/tools/web-fetch-handlers/terraform.ts +296 -0
  74. package/src/core/tools/web-fetch-handlers/tldr.ts +47 -0
  75. package/src/core/tools/web-fetch-handlers/twitter.ts +84 -0
  76. package/src/core/tools/web-fetch-handlers/types.ts +163 -0
  77. package/src/core/tools/web-fetch-handlers/utils.ts +91 -0
  78. package/src/core/tools/web-fetch-handlers/vimeo.ts +152 -0
  79. package/src/core/tools/web-fetch-handlers/wikidata.ts +349 -0
  80. package/src/core/tools/web-fetch-handlers/wikipedia.test.ts +73 -0
  81. package/src/core/tools/web-fetch-handlers/wikipedia.ts +91 -0
  82. package/src/core/tools/web-fetch-handlers/youtube.test.ts +198 -0
  83. package/src/core/tools/web-fetch-handlers/youtube.ts +319 -0
  84. package/src/core/tools/web-fetch.ts +152 -1324
  85. package/src/utils/tools-manager.ts +110 -8
@@ -0,0 +1,84 @@
1
+ import { parse as parseHtml } from "node-html-parser";
2
+ import type { RenderResult, SpecialHandler } from "./types";
3
+ import { finalizeOutput, loadPage } from "./types";
4
+ import { convertWithMarkitdown, fetchBinary } from "./utils";
5
+
6
+ /**
7
+ * Handle arXiv URLs via arXiv API
8
+ */
9
+ export const handleArxiv: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
10
+ try {
11
+ const parsed = new URL(url);
12
+ if (parsed.hostname !== "arxiv.org") return null;
13
+
14
+ // Extract paper ID from various URL formats
15
+ // /abs/1234.56789, /pdf/1234.56789, /abs/cs/0123456
16
+ const match = parsed.pathname.match(/\/(abs|pdf)\/(.+?)(?:\.pdf)?$/);
17
+ if (!match) return null;
18
+
19
+ const paperId = match[2];
20
+ const fetchedAt = new Date().toISOString();
21
+ const notes: string[] = [];
22
+
23
+ // Fetch metadata via arXiv API
24
+ const apiUrl = `https://export.arxiv.org/api/query?id_list=${paperId}`;
25
+ const result = await loadPage(apiUrl, { timeout });
26
+
27
+ if (!result.ok) return null;
28
+
29
+ // Parse the Atom feed response
30
+ const doc = parseHtml(result.content, { parseNoneClosedTags: true });
31
+ const entry = doc.querySelector("entry");
32
+
33
+ if (!entry) return null;
34
+
35
+ const title = entry.querySelector("title")?.text?.trim()?.replace(/\s+/g, " ");
36
+ const summary = entry.querySelector("summary")?.text?.trim();
37
+ const authors = entry
38
+ .querySelectorAll("author name")
39
+ .map((n) => n.text?.trim())
40
+ .filter(Boolean);
41
+ const published = entry.querySelector("published")?.text?.trim()?.split("T")[0];
42
+ const categories = entry
43
+ .querySelectorAll("category")
44
+ .map((c) => c.getAttribute("term"))
45
+ .filter(Boolean);
46
+ const pdfLink = entry.querySelector('link[title="pdf"]')?.getAttribute("href");
47
+
48
+ let md = `# ${title || "arXiv Paper"}\n\n`;
49
+ if (authors.length) md += `**Authors:** ${authors.join(", ")}\n`;
50
+ if (published) md += `**Published:** ${published}\n`;
51
+ if (categories.length) md += `**Categories:** ${categories.join(", ")}\n`;
52
+ md += `**arXiv:** ${paperId}\n\n`;
53
+ md += `---\n\n## Abstract\n\n${summary || "No abstract available."}\n\n`;
54
+
55
+ // If it was a PDF link or we want full content, try to fetch and convert PDF
56
+ if (match[1] === "pdf" || parsed.pathname.includes(".pdf")) {
57
+ if (pdfLink) {
58
+ notes.push("Fetching PDF for full content...");
59
+ const pdfResult = await fetchBinary(pdfLink, timeout);
60
+ if (pdfResult.ok) {
61
+ const converted = await convertWithMarkitdown(pdfResult.buffer, ".pdf", timeout);
62
+ if (converted.ok && converted.content.length > 500) {
63
+ md += `---\n\n## Full Paper\n\n${converted.content}\n`;
64
+ notes.push("PDF converted via markitdown");
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ const output = finalizeOutput(md);
71
+ return {
72
+ url,
73
+ finalUrl: url,
74
+ contentType: "text/markdown",
75
+ method: "arxiv",
76
+ content: output.content,
77
+ fetchedAt,
78
+ truncated: output.truncated,
79
+ notes: notes.length ? notes : ["Fetched via arXiv API"],
80
+ };
81
+ } catch {}
82
+
83
+ return null;
84
+ };
@@ -0,0 +1,171 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface AurPackage {
5
+ Name: string;
6
+ Version: string;
7
+ Description?: string;
8
+ Maintainer?: string;
9
+ NumVotes: number;
10
+ Popularity: number;
11
+ Depends?: string[];
12
+ MakeDepends?: string[];
13
+ OptDepends?: string[];
14
+ CheckDepends?: string[];
15
+ LastModified: number;
16
+ FirstSubmitted: number;
17
+ URL?: string;
18
+ URLPath?: string;
19
+ PackageBase: string;
20
+ OutOfDate?: number | null;
21
+ License?: string[];
22
+ Keywords?: string[];
23
+ Conflicts?: string[];
24
+ Provides?: string[];
25
+ Replaces?: string[];
26
+ }
27
+
28
+ interface AurResponse {
29
+ version: number;
30
+ type: string;
31
+ resultcount: number;
32
+ results: AurPackage[];
33
+ }
34
+
35
+ /**
36
+ * Handle AUR (Arch User Repository) URLs via RPC API
37
+ */
38
+ export const handleAur: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
39
+ try {
40
+ const parsed = new URL(url);
41
+ if (parsed.hostname !== "aur.archlinux.org") return null;
42
+
43
+ // Extract package name from /packages/{name}
44
+ const match = parsed.pathname.match(/^\/packages\/([^/?#]+)/);
45
+ if (!match) return null;
46
+
47
+ const packageName = decodeURIComponent(match[1]);
48
+ const fetchedAt = new Date().toISOString();
49
+
50
+ // Fetch from AUR RPC API
51
+ const apiUrl = `https://aur.archlinux.org/rpc/?v=5&type=info&arg=${encodeURIComponent(packageName)}`;
52
+ const result = await loadPage(apiUrl, { timeout });
53
+
54
+ if (!result.ok) return null;
55
+
56
+ let data: AurResponse;
57
+ try {
58
+ data = JSON.parse(result.content);
59
+ } catch {
60
+ return null;
61
+ }
62
+
63
+ if (data.resultcount === 0 || !data.results[0]) return null;
64
+
65
+ const pkg = data.results[0];
66
+
67
+ let md = `# ${pkg.Name}\n\n`;
68
+ if (pkg.Description) md += `${pkg.Description}\n\n`;
69
+
70
+ // Package info
71
+ md += `**Version:** ${pkg.Version}`;
72
+ if (pkg.OutOfDate) {
73
+ const outOfDateDate = new Date(pkg.OutOfDate * 1000).toISOString().split("T")[0];
74
+ md += ` (flagged out-of-date: ${outOfDateDate})`;
75
+ }
76
+ md += "\n";
77
+
78
+ if (pkg.Maintainer) {
79
+ md += `**Maintainer:** [${pkg.Maintainer}](https://aur.archlinux.org/account/${pkg.Maintainer})\n`;
80
+ } else {
81
+ md += "**Maintainer:** Orphaned\n";
82
+ }
83
+
84
+ md += `**Votes:** ${formatCount(pkg.NumVotes)} · **Popularity:** ${pkg.Popularity.toFixed(2)}\n`;
85
+
86
+ // Timestamps
87
+ const lastModified = new Date(pkg.LastModified * 1000).toISOString().split("T")[0];
88
+ const firstSubmitted = new Date(pkg.FirstSubmitted * 1000).toISOString().split("T")[0];
89
+ md += `**Last Updated:** ${lastModified} · **First Submitted:** ${firstSubmitted}\n`;
90
+
91
+ if (pkg.License?.length) md += `**License:** ${pkg.License.join(", ")}\n`;
92
+ if (pkg.URL) md += `**Upstream:** ${pkg.URL}\n`;
93
+ if (pkg.Keywords?.length) md += `**Keywords:** ${pkg.Keywords.join(", ")}\n`;
94
+
95
+ // Dependencies
96
+ if (pkg.Depends?.length) {
97
+ md += `\n## Dependencies (${pkg.Depends.length})\n\n`;
98
+ for (const dep of pkg.Depends) {
99
+ md += `- ${dep}\n`;
100
+ }
101
+ }
102
+
103
+ if (pkg.MakeDepends?.length) {
104
+ md += `\n## Make Dependencies (${pkg.MakeDepends.length})\n\n`;
105
+ for (const dep of pkg.MakeDepends) {
106
+ md += `- ${dep}\n`;
107
+ }
108
+ }
109
+
110
+ if (pkg.OptDepends?.length) {
111
+ md += `\n## Optional Dependencies\n\n`;
112
+ for (const dep of pkg.OptDepends) {
113
+ md += `- ${dep}\n`;
114
+ }
115
+ }
116
+
117
+ if (pkg.CheckDepends?.length) {
118
+ md += `\n## Check Dependencies\n\n`;
119
+ for (const dep of pkg.CheckDepends) {
120
+ md += `- ${dep}\n`;
121
+ }
122
+ }
123
+
124
+ // Package relationships
125
+ if (pkg.Provides?.length) {
126
+ md += `\n## Provides\n\n`;
127
+ for (const p of pkg.Provides) {
128
+ md += `- ${p}\n`;
129
+ }
130
+ }
131
+
132
+ if (pkg.Conflicts?.length) {
133
+ md += `\n## Conflicts\n\n`;
134
+ for (const c of pkg.Conflicts) {
135
+ md += `- ${c}\n`;
136
+ }
137
+ }
138
+
139
+ if (pkg.Replaces?.length) {
140
+ md += `\n## Replaces\n\n`;
141
+ for (const r of pkg.Replaces) {
142
+ md += `- ${r}\n`;
143
+ }
144
+ }
145
+
146
+ // Installation instructions
147
+ md += `\n---\n\n## Installation\n\n`;
148
+ md += "```bash\n";
149
+ md += `# Using an AUR helper (e.g., yay, paru)\n`;
150
+ md += `yay -S ${pkg.Name}\n\n`;
151
+ md += `# Manual installation\n`;
152
+ md += `git clone https://aur.archlinux.org/${pkg.PackageBase}.git\n`;
153
+ md += `cd ${pkg.PackageBase}\n`;
154
+ md += `makepkg -si\n`;
155
+ md += "```\n";
156
+
157
+ const output = finalizeOutput(md);
158
+ return {
159
+ url,
160
+ finalUrl: url,
161
+ contentType: "text/markdown",
162
+ method: "aur",
163
+ content: output.content,
164
+ fetchedAt,
165
+ truncated: output.truncated,
166
+ notes: ["Fetched via AUR RPC API"],
167
+ };
168
+ } catch {}
169
+
170
+ return null;
171
+ };
@@ -0,0 +1,136 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface BiorxivPaper {
5
+ biorxiv_doi?: string;
6
+ medrxiv_doi?: string;
7
+ title?: string;
8
+ authors?: string;
9
+ author_corresponding?: string;
10
+ author_corresponding_institution?: string;
11
+ abstract?: string;
12
+ date?: string;
13
+ category?: string;
14
+ version?: string;
15
+ type?: string;
16
+ license?: string;
17
+ jatsxml?: string;
18
+ published?: string; // Journal DOI if published
19
+ server?: string;
20
+ }
21
+
22
+ interface BiorxivResponse {
23
+ collection?: BiorxivPaper[];
24
+ messages?: { status: string; count: number }[];
25
+ }
26
+
27
+ /**
28
+ * Handle bioRxiv and medRxiv preprint URLs via their API
29
+ */
30
+ export const handleBiorxiv: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
31
+ try {
32
+ const parsed = new URL(url);
33
+ const hostname = parsed.hostname.toLowerCase();
34
+
35
+ // Check if it's bioRxiv or medRxiv
36
+ const isBiorxiv = hostname === "www.biorxiv.org" || hostname === "biorxiv.org";
37
+ const isMedrxiv = hostname === "www.medrxiv.org" || hostname === "medrxiv.org";
38
+
39
+ if (!isBiorxiv && !isMedrxiv) return null;
40
+
41
+ // Extract DOI from URL path: /content/10.1101/2024.01.01.123456
42
+ const match = parsed.pathname.match(/\/content\/(10\.\d{4,}\/[^\s?#]+)/);
43
+ if (!match) return null;
44
+
45
+ let doi = match[1];
46
+ // Remove version suffix if present (e.g., v1, v2)
47
+ doi = doi.replace(/v\d+$/, "");
48
+ // Remove trailing .full or .full.pdf
49
+ doi = doi.replace(/\.full(\.pdf)?$/, "");
50
+
51
+ const server = isBiorxiv ? "biorxiv" : "medrxiv";
52
+ const apiUrl = `https://api.${server}.org/details/${server}/${doi}/na/json`;
53
+
54
+ const result = await loadPage(apiUrl, {
55
+ timeout,
56
+ headers: { Accept: "application/json" },
57
+ });
58
+
59
+ if (!result.ok) return null;
60
+
61
+ let data: BiorxivResponse;
62
+ try {
63
+ data = JSON.parse(result.content);
64
+ } catch {
65
+ return null;
66
+ }
67
+
68
+ if (!data.collection || data.collection.length === 0) return null;
69
+
70
+ // Get the latest version (last in array)
71
+ const paper = data.collection[data.collection.length - 1];
72
+ if (!paper) return null;
73
+
74
+ const serverName = isBiorxiv ? "bioRxiv" : "medRxiv";
75
+ const paperDoi = paper.biorxiv_doi || paper.medrxiv_doi || doi;
76
+
77
+ // Build markdown output
78
+ let md = `# ${paper.title || "Untitled Preprint"}\n\n`;
79
+
80
+ // Metadata section
81
+ if (paper.authors) {
82
+ md += `**Authors:** ${paper.authors}\n`;
83
+ }
84
+ if (paper.author_corresponding) {
85
+ let correspondingLine = `**Corresponding Author:** ${paper.author_corresponding}`;
86
+ if (paper.author_corresponding_institution) {
87
+ correspondingLine += ` (${paper.author_corresponding_institution})`;
88
+ }
89
+ md += `${correspondingLine}\n`;
90
+ }
91
+ if (paper.date) {
92
+ md += `**Posted:** ${paper.date}\n`;
93
+ }
94
+ if (paper.category) {
95
+ md += `**Category:** ${paper.category}\n`;
96
+ }
97
+ if (paper.version) {
98
+ md += `**Version:** ${paper.version}\n`;
99
+ }
100
+ if (paper.license) {
101
+ md += `**License:** ${paper.license}\n`;
102
+ }
103
+ md += `**DOI:** [${paperDoi}](https://doi.org/${paperDoi})\n`;
104
+ md += `**Server:** ${serverName}\n`;
105
+
106
+ // Published status
107
+ if (paper.published) {
108
+ md += `\n> **Published in journal:** [${paper.published}](https://doi.org/${paper.published})\n`;
109
+ }
110
+
111
+ // Abstract
112
+ md += `\n---\n\n## Abstract\n\n${paper.abstract || "No abstract available."}\n`;
113
+
114
+ // Links section
115
+ md += `\n---\n\n## Links\n\n`;
116
+ md += `- [View on ${serverName}](https://www.${server}.org/content/${paperDoi})\n`;
117
+ md += `- [PDF](https://www.${server}.org/content/${paperDoi}.full.pdf)\n`;
118
+ if (paper.jatsxml) {
119
+ md += `- [JATS XML](${paper.jatsxml})\n`;
120
+ }
121
+
122
+ const output = finalizeOutput(md);
123
+ return {
124
+ url,
125
+ finalUrl: url,
126
+ contentType: "text/markdown",
127
+ method: server,
128
+ content: output.content,
129
+ fetchedAt: new Date().toISOString(),
130
+ truncated: output.truncated,
131
+ notes: [`Fetched via ${serverName} API`],
132
+ };
133
+ } catch {}
134
+
135
+ return null;
136
+ };
@@ -0,0 +1,277 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ const API_BASE = "https://public.api.bsky.app/xrpc";
5
+
6
+ interface BlueskyProfile {
7
+ did: string;
8
+ handle: string;
9
+ displayName?: string;
10
+ description?: string;
11
+ avatar?: string;
12
+ followersCount?: number;
13
+ followsCount?: number;
14
+ postsCount?: number;
15
+ createdAt?: string;
16
+ }
17
+
18
+ interface BlueskyPost {
19
+ uri: string;
20
+ cid: string;
21
+ author: BlueskyProfile;
22
+ record: {
23
+ text: string;
24
+ createdAt: string;
25
+ embed?: {
26
+ $type: string;
27
+ external?: { uri: string; title?: string; description?: string };
28
+ images?: Array<{ alt?: string; image: unknown }>;
29
+ record?: { uri: string };
30
+ };
31
+ facets?: Array<{
32
+ features: Array<{ $type: string; uri?: string; tag?: string; did?: string }>;
33
+ index: { byteStart: number; byteEnd: number };
34
+ }>;
35
+ };
36
+ likeCount?: number;
37
+ repostCount?: number;
38
+ replyCount?: number;
39
+ quoteCount?: number;
40
+ embed?: {
41
+ $type: string;
42
+ external?: { uri: string; title?: string; description?: string };
43
+ images?: Array<{ alt?: string; fullsize?: string; thumb?: string }>;
44
+ record?: { uri: string; value?: { text?: string }; author?: BlueskyProfile };
45
+ };
46
+ }
47
+
48
+ interface ThreadViewPost {
49
+ post: BlueskyPost;
50
+ parent?: ThreadViewPost | { $type: string };
51
+ replies?: Array<ThreadViewPost | { $type: string }>;
52
+ }
53
+
54
+ /**
55
+ * Resolve a handle to DID using the profile API
56
+ */
57
+ async function resolveHandle(handle: string, timeout: number): Promise<string | null> {
58
+ const url = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`;
59
+ const result = await loadPage(url, {
60
+ timeout,
61
+ headers: { Accept: "application/json" },
62
+ });
63
+
64
+ if (!result.ok) return null;
65
+
66
+ try {
67
+ const data = JSON.parse(result.content) as BlueskyProfile;
68
+ return data.did;
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Format a post as markdown
76
+ */
77
+ function formatPost(post: BlueskyPost, isQuote = false): string {
78
+ const author = post.author;
79
+ const name = author.displayName || author.handle;
80
+ const handle = `@${author.handle}`;
81
+ const date = new Date(post.record.createdAt).toLocaleString("en-US", {
82
+ year: "numeric",
83
+ month: "short",
84
+ day: "numeric",
85
+ hour: "2-digit",
86
+ minute: "2-digit",
87
+ });
88
+
89
+ let md = "";
90
+
91
+ if (isQuote) {
92
+ md += `> **${name}** (${handle}) - ${date}\n>\n`;
93
+ md += post.record.text
94
+ .split("\n")
95
+ .map((line) => `> ${line}`)
96
+ .join("\n");
97
+ md += "\n";
98
+ } else {
99
+ md += `**${name}** (${handle})\n`;
100
+ md += `*${date}*\n\n`;
101
+ md += `${post.record.text}\n`;
102
+ }
103
+
104
+ // Handle embeds
105
+ const embed = post.embed;
106
+ if (embed) {
107
+ if (embed.$type === "app.bsky.embed.external#view" && embed.external) {
108
+ const ext = embed.external;
109
+ md += `\n📎 [${ext.title || ext.uri}](${ext.uri})`;
110
+ if (ext.description) md += `\n*${ext.description}*`;
111
+ md += "\n";
112
+ } else if (embed.$type === "app.bsky.embed.images#view" && embed.images) {
113
+ md += `\n🖼️ ${embed.images.length} image(s)`;
114
+ for (const img of embed.images) {
115
+ if (img.alt) md += `\n- Alt: "${img.alt}"`;
116
+ }
117
+ md += "\n";
118
+ } else if (
119
+ (embed.$type === "app.bsky.embed.record#view" || embed.$type === "app.bsky.embed.recordWithMedia#view") &&
120
+ embed.record
121
+ ) {
122
+ const rec = embed.record;
123
+ if (rec.value?.text && rec.author) {
124
+ md += "\n**Quoted post:**\n";
125
+ md += `> **${rec.author.displayName || rec.author.handle}** (@${rec.author.handle})\n`;
126
+ md += rec.value.text
127
+ .split("\n")
128
+ .map((line) => `> ${line}`)
129
+ .join("\n");
130
+ md += "\n";
131
+ }
132
+ }
133
+ }
134
+
135
+ // Stats
136
+ if (!isQuote) {
137
+ const stats: string[] = [];
138
+ if (post.likeCount) stats.push(`❤️ ${formatCount(post.likeCount)}`);
139
+ if (post.repostCount) stats.push(`🔁 ${formatCount(post.repostCount)}`);
140
+ if (post.replyCount) stats.push(`💬 ${formatCount(post.replyCount)}`);
141
+ if (post.quoteCount) stats.push(`📝 ${formatCount(post.quoteCount)}`);
142
+ if (stats.length) md += `\n${stats.join(" • ")}\n`;
143
+ }
144
+
145
+ return md;
146
+ }
147
+
148
+ /**
149
+ * Handle Bluesky post URLs
150
+ */
151
+ export const handleBluesky: SpecialHandler = async (url: string, timeout: number): Promise<RenderResult | null> => {
152
+ try {
153
+ const parsed = new URL(url);
154
+ if (!["bsky.app", "www.bsky.app"].includes(parsed.hostname)) {
155
+ return null;
156
+ }
157
+
158
+ const fetchedAt = new Date().toISOString();
159
+ const pathParts = parsed.pathname.split("/").filter(Boolean);
160
+
161
+ // /profile/{handle}
162
+ if (pathParts[0] === "profile" && pathParts[1]) {
163
+ const handle = pathParts[1];
164
+
165
+ // /profile/{handle}/post/{rkey}
166
+ if (pathParts[2] === "post" && pathParts[3]) {
167
+ const rkey = pathParts[3];
168
+
169
+ // First resolve handle to DID
170
+ const did = await resolveHandle(handle, timeout);
171
+ if (!did) return null;
172
+
173
+ // Construct AT URI and fetch thread
174
+ const atUri = `at://${did}/app.bsky.feed.post/${rkey}`;
175
+ const threadUrl = `${API_BASE}/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=6&parentHeight=3`;
176
+
177
+ const result = await loadPage(threadUrl, {
178
+ timeout,
179
+ headers: { Accept: "application/json" },
180
+ });
181
+
182
+ if (!result.ok) return null;
183
+
184
+ const data = JSON.parse(result.content) as { thread: ThreadViewPost };
185
+ const thread = data.thread;
186
+
187
+ if (!thread.post) return null;
188
+
189
+ let md = `# Bluesky Post\n\n`;
190
+
191
+ // Show parent context if exists
192
+ if (thread.parent && "post" in thread.parent) {
193
+ md += "**Replying to:**\n";
194
+ md += formatPost(thread.parent.post, true);
195
+ md += "\n---\n\n";
196
+ }
197
+
198
+ // Main post
199
+ md += formatPost(thread.post);
200
+
201
+ // Show replies
202
+ if (thread.replies?.length) {
203
+ md += "\n---\n\n## Replies\n\n";
204
+ let replyCount = 0;
205
+ for (const reply of thread.replies) {
206
+ if (replyCount >= 10) break;
207
+ if ("post" in reply) {
208
+ md += formatPost(reply.post);
209
+ md += "\n---\n\n";
210
+ replyCount++;
211
+ }
212
+ }
213
+ }
214
+
215
+ const output = finalizeOutput(md);
216
+ return {
217
+ url,
218
+ finalUrl: url,
219
+ contentType: "text/markdown",
220
+ method: "bluesky-api",
221
+ content: output.content,
222
+ fetchedAt,
223
+ truncated: output.truncated,
224
+ notes: [`AT URI: ${atUri}`],
225
+ };
226
+ }
227
+
228
+ // Profile only
229
+ const profileUrl = `${API_BASE}/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`;
230
+ const result = await loadPage(profileUrl, {
231
+ timeout,
232
+ headers: { Accept: "application/json" },
233
+ });
234
+
235
+ if (!result.ok) return null;
236
+
237
+ const profile = JSON.parse(result.content) as BlueskyProfile;
238
+
239
+ let md = `# ${profile.displayName || profile.handle}\n\n`;
240
+ md += `**@${profile.handle}**\n\n`;
241
+
242
+ if (profile.description) {
243
+ md += `${profile.description}\n\n`;
244
+ }
245
+
246
+ md += "---\n\n";
247
+ md += `- **Followers:** ${formatCount(profile.followersCount || 0)}\n`;
248
+ md += `- **Following:** ${formatCount(profile.followsCount || 0)}\n`;
249
+ md += `- **Posts:** ${formatCount(profile.postsCount || 0)}\n`;
250
+
251
+ if (profile.createdAt) {
252
+ const joined = new Date(profile.createdAt).toLocaleDateString("en-US", {
253
+ year: "numeric",
254
+ month: "long",
255
+ day: "numeric",
256
+ });
257
+ md += `- **Joined:** ${joined}\n`;
258
+ }
259
+
260
+ md += `\n**DID:** \`${profile.did}\`\n`;
261
+
262
+ const output = finalizeOutput(md);
263
+ return {
264
+ url,
265
+ finalUrl: url,
266
+ contentType: "text/markdown",
267
+ method: "bluesky-api",
268
+ content: output.content,
269
+ fetchedAt,
270
+ truncated: output.truncated,
271
+ notes: ["Fetched via AT Protocol API"],
272
+ };
273
+ }
274
+ } catch {}
275
+
276
+ return null;
277
+ };