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