@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,195 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface MarketplaceProperty {
5
+ key?: string;
6
+ value?: string;
7
+ }
8
+
9
+ interface MarketplaceVersion {
10
+ version?: string;
11
+ properties?: MarketplaceProperty[];
12
+ }
13
+
14
+ interface MarketplaceStatistic {
15
+ statisticName?: string;
16
+ value?: number;
17
+ }
18
+
19
+ interface MarketplacePublisher {
20
+ publisherName?: string;
21
+ displayName?: string;
22
+ }
23
+
24
+ interface MarketplaceExtension {
25
+ extensionName?: string;
26
+ displayName?: string;
27
+ shortDescription?: string;
28
+ description?: string;
29
+ publisher?: MarketplacePublisher;
30
+ versions?: MarketplaceVersion[];
31
+ statistics?: MarketplaceStatistic[];
32
+ categories?: string[];
33
+ tags?: string[];
34
+ properties?: MarketplaceProperty[];
35
+ }
36
+
37
+ interface MarketplaceResponse {
38
+ results?: Array<{ extensions?: MarketplaceExtension[] }>;
39
+ }
40
+
41
+ const MARKETPLACE_HOSTS = new Set(["marketplace.visualstudio.com", "www.marketplace.visualstudio.com"]);
42
+
43
+ function getItemName(parsed: URL): string | null {
44
+ if (!parsed.pathname.startsWith("/items")) return null;
45
+ const itemName = parsed.searchParams.get("itemName");
46
+ if (!itemName) return null;
47
+ const decoded = decodeURIComponent(itemName);
48
+ if (!decoded.includes(".")) return null;
49
+ return decoded;
50
+ }
51
+
52
+ function toStatMap(stats: MarketplaceStatistic[] | undefined): Map<string, number> {
53
+ const map = new Map<string, number>();
54
+ if (!stats) return map;
55
+ for (const stat of stats) {
56
+ if (!stat.statisticName || typeof stat.value !== "number") continue;
57
+ map.set(stat.statisticName.trim().toLowerCase(), stat.value);
58
+ }
59
+ return map;
60
+ }
61
+
62
+ function formatRating(averageRating?: number, ratingCount?: number): string | null {
63
+ if (averageRating === undefined && ratingCount === undefined) return null;
64
+ if (averageRating !== undefined) {
65
+ const formatted = averageRating.toFixed(2).replace(/\.0+$/, "").replace(/\.$/, "");
66
+ if (ratingCount !== undefined) {
67
+ return `${formatted} (${formatCount(ratingCount)} ratings)`;
68
+ }
69
+ return formatted;
70
+ }
71
+ if (ratingCount !== undefined) {
72
+ return `${formatCount(ratingCount)} ratings`;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function extractRepoLink(properties: MarketplaceProperty[] | undefined): string | null {
78
+ if (!properties) return null;
79
+ for (const prop of properties) {
80
+ const key = prop.key?.trim().toLowerCase();
81
+ const value = prop.value?.trim();
82
+ if (!key || !value) continue;
83
+ if (!value.startsWith("http")) continue;
84
+ if (key.includes("links.source") || key.includes("repository")) return value;
85
+ }
86
+ for (const prop of properties) {
87
+ const key = prop.key?.trim().toLowerCase();
88
+ const value = prop.value?.trim();
89
+ if (!key || !value) continue;
90
+ if (!value.startsWith("http")) continue;
91
+ if (key === "source" || key.endsWith(".source")) return value;
92
+ }
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Handle VS Code Marketplace URLs via extension query API
98
+ */
99
+ export const handleVscodeMarketplace: SpecialHandler = async (
100
+ url: string,
101
+ timeout: number,
102
+ signal?: AbortSignal,
103
+ ): Promise<RenderResult | null> => {
104
+ try {
105
+ const parsed = new URL(url);
106
+ if (!MARKETPLACE_HOSTS.has(parsed.hostname)) return null;
107
+
108
+ const itemName = getItemName(parsed);
109
+ if (!itemName) return null;
110
+
111
+ const [publisherFromUrl, ...nameParts] = itemName.split(".");
112
+ const extensionFromUrl = nameParts.join(".");
113
+
114
+ const fetchedAt = new Date().toISOString();
115
+ const apiUrl = "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery";
116
+ const payload = JSON.stringify({
117
+ filters: [
118
+ {
119
+ criteria: [{ filterType: 7, value: itemName }],
120
+ },
121
+ ],
122
+ flags: 950,
123
+ });
124
+
125
+ const result = await loadPage(apiUrl, {
126
+ timeout,
127
+ signal,
128
+ method: "POST",
129
+ body: payload,
130
+ headers: {
131
+ "Content-Type": "application/json",
132
+ Accept: "application/json;api-version=7.2-preview.1",
133
+ },
134
+ });
135
+
136
+ if (!result.ok) return null;
137
+
138
+ let data: MarketplaceResponse;
139
+ try {
140
+ data = JSON.parse(result.content) as MarketplaceResponse;
141
+ } catch {
142
+ return null;
143
+ }
144
+
145
+ const extension = data.results?.[0]?.extensions?.[0];
146
+ if (!extension) return null;
147
+
148
+ const extensionName = extension.extensionName ?? extensionFromUrl;
149
+ const displayName = extension.displayName ?? extensionName ?? itemName;
150
+ const description = extension.shortDescription ?? extension.description;
151
+
152
+ const publisherName = extension.publisher?.publisherName ?? publisherFromUrl;
153
+ const publisherDisplayName = extension.publisher?.displayName;
154
+ const publisherLabel =
155
+ publisherDisplayName && publisherName && publisherDisplayName !== publisherName
156
+ ? `${publisherDisplayName} (${publisherName})`
157
+ : (publisherDisplayName ?? publisherName);
158
+
159
+ const version = extension.versions?.[0]?.version;
160
+ const statMap = toStatMap(extension.statistics);
161
+ const installs = statMap.get("install") ?? statMap.get("installs");
162
+ const averageRating = statMap.get("averagerating");
163
+ const ratingCount = statMap.get("ratingcount");
164
+ const ratingLabel = formatRating(averageRating, ratingCount);
165
+
166
+ const repoLink = extractRepoLink(extension.versions?.[0]?.properties) ?? extractRepoLink(extension.properties);
167
+
168
+ const identifier = publisherName && extensionName ? `${publisherName}.${extensionName}` : itemName;
169
+
170
+ let md = `# ${displayName}\n\n`;
171
+ if (description) md += `${description}\n\n`;
172
+ md += `**Identifier:** ${identifier}\n`;
173
+ if (publisherLabel) md += `**Publisher:** ${publisherLabel}\n`;
174
+ if (version) md += `**Version:** ${version}\n`;
175
+ if (installs !== undefined) md += `**Installs:** ${formatCount(installs)}\n`;
176
+ if (ratingLabel) md += `**Rating:** ${ratingLabel}\n`;
177
+ if (extension.categories?.length) md += `**Categories:** ${extension.categories.join(", ")}\n`;
178
+ if (extension.tags?.length) md += `**Tags:** ${extension.tags.join(", ")}\n`;
179
+ if (repoLink) md += `**Repository:** ${repoLink}\n`;
180
+
181
+ const output = finalizeOutput(md);
182
+ return {
183
+ url,
184
+ finalUrl: url,
185
+ contentType: "text/markdown",
186
+ method: "vscode-marketplace",
187
+ content: output.content,
188
+ fetchedAt,
189
+ truncated: output.truncated,
190
+ notes: ["Fetched via VS Code Marketplace API"],
191
+ };
192
+ } catch {}
193
+
194
+ return null;
195
+ };
@@ -0,0 +1,163 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
3
+
4
+ type JsonRecord = Record<string, unknown>;
5
+
6
+ function asRecord(value: unknown): JsonRecord | null {
7
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
8
+ return value as JsonRecord;
9
+ }
10
+
11
+ function getString(record: JsonRecord | null, key: string): string | undefined {
12
+ if (!record) return undefined;
13
+ const value = record[key];
14
+ return typeof value === "string" ? value : undefined;
15
+ }
16
+
17
+ function getRecord(record: JsonRecord | null, key: string): JsonRecord | null {
18
+ if (!record) return null;
19
+ return asRecord(record[key]);
20
+ }
21
+
22
+ function getArray(record: JsonRecord | null, key: string): unknown[] | undefined {
23
+ if (!record) return undefined;
24
+ const value = record[key];
25
+ return Array.isArray(value) ? value : undefined;
26
+ }
27
+
28
+ function extractShortname(pathname: string): string | null {
29
+ const trimmed = pathname.replace(/\/+$/g, "");
30
+ const segments = trimmed.split("/").filter(Boolean);
31
+
32
+ if (segments.length < 2 || segments[0] !== "TR") return null;
33
+
34
+ if (segments.length === 2) {
35
+ const shortname = segments[1];
36
+ if (/^\d{4}$/.test(shortname)) return null;
37
+ return decodeURIComponent(shortname);
38
+ }
39
+
40
+ if (segments.length >= 3 && /^\d{4}$/.test(segments[1])) {
41
+ const version = segments[2];
42
+ const match = version.match(/^[A-Za-z]+-(.+)-\d{8}$/);
43
+ if (match?.[1]) return decodeURIComponent(match[1]);
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ function normalizeStatus(status?: string): { code?: string; label?: string } {
50
+ if (!status) return {};
51
+ const lower = status.toLowerCase();
52
+
53
+ if (lower.includes("working draft")) return { code: "WD", label: status };
54
+ if (lower.includes("candidate recommendation")) return { code: "CR", label: status };
55
+ if (lower.includes("proposed recommendation")) return { code: "PR", label: status };
56
+ if (lower.includes("recommendation")) return { code: "REC", label: status };
57
+
58
+ return { label: status };
59
+ }
60
+
61
+ function extractEditors(editorsPayload: JsonRecord | null): string[] {
62
+ const links = getRecord(editorsPayload, "_links");
63
+ const editors = getArray(links, "editors") ?? [];
64
+ const names: string[] = [];
65
+
66
+ for (const entry of editors) {
67
+ const record = asRecord(entry);
68
+ const title = getString(record, "title");
69
+ if (title) names.push(title);
70
+ }
71
+
72
+ return names;
73
+ }
74
+
75
+ export const handleW3c: 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 (parsed.hostname !== "www.w3.org" && parsed.hostname !== "w3.org") return null;
83
+
84
+ const shortname = extractShortname(parsed.pathname);
85
+ if (!shortname) return null;
86
+
87
+ const fetchedAt = new Date().toISOString();
88
+
89
+ const specUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}`;
90
+ const latestUrl = `https://api.w3.org/specifications/${encodeURIComponent(shortname)}/versions/latest`;
91
+
92
+ const [specResult, latestResult] = await Promise.all([
93
+ loadPage(specUrl, { timeout, signal, headers: { Accept: "application/json" } }),
94
+ loadPage(latestUrl, { timeout, signal, headers: { Accept: "application/json" } }),
95
+ ]);
96
+
97
+ if (!specResult.ok || !latestResult.ok) return null;
98
+
99
+ const specPayload = asRecord(JSON.parse(specResult.content));
100
+ const latestPayload = asRecord(JSON.parse(latestResult.content));
101
+ if (!specPayload || !latestPayload) return null;
102
+
103
+ const title = getString(specPayload, "title");
104
+ const shortnameValue = getString(specPayload, "shortname") ?? shortname;
105
+ const description = getString(specPayload, "description") ?? getString(specPayload, "abstract");
106
+ const abstract = description ? htmlToBasicMarkdown(description) : undefined;
107
+
108
+ const latestVersionUrl =
109
+ getString(latestPayload, "uri") ??
110
+ getString(latestPayload, "shortlink") ??
111
+ getString(specPayload, "shortlink");
112
+
113
+ const latestStatus = getString(latestPayload, "status");
114
+ const normalizedStatus = normalizeStatus(latestStatus);
115
+
116
+ const specLinks = getRecord(specPayload, "_links");
117
+ const historyUrl = getString(getRecord(specLinks, "version-history"), "href");
118
+
119
+ const latestLinks = getRecord(latestPayload, "_links");
120
+ const editorsUrl = getString(getRecord(latestLinks, "editors"), "href");
121
+
122
+ let editors: string[] = [];
123
+ if (editorsUrl) {
124
+ const editorsResult = await loadPage(editorsUrl, { timeout: Math.min(timeout, 10), signal });
125
+ if (editorsResult.ok) {
126
+ try {
127
+ const editorsPayload = asRecord(JSON.parse(editorsResult.content));
128
+ editors = editorsPayload ? extractEditors(editorsPayload) : [];
129
+ } catch {}
130
+ }
131
+ }
132
+
133
+ let md = `# ${title ?? shortnameValue}\n\n`;
134
+ if (abstract) md += `## Abstract\n\n${abstract}\n\n`;
135
+
136
+ md += "## Metadata\n\n";
137
+ md += `**Shortname:** ${shortnameValue}\n`;
138
+ if (normalizedStatus.code) {
139
+ md += `**Status:** ${normalizedStatus.code}`;
140
+ if (normalizedStatus.label) md += ` (${normalizedStatus.label})`;
141
+ md += "\n";
142
+ } else if (normalizedStatus.label) {
143
+ md += `**Status:** ${normalizedStatus.label}\n`;
144
+ }
145
+ if (editors.length) md += `**Editors:** ${editors.join(", ")}\n`;
146
+ if (latestVersionUrl) md += `**Latest Version:** ${latestVersionUrl}\n`;
147
+ if (historyUrl) md += `**History:** ${historyUrl}\n`;
148
+
149
+ const output = finalizeOutput(md);
150
+ return {
151
+ url,
152
+ finalUrl: latestVersionUrl ?? url,
153
+ contentType: "text/markdown",
154
+ method: "w3c-api",
155
+ content: output.content,
156
+ fetchedAt,
157
+ truncated: output.truncated,
158
+ notes: ["Fetched via W3C API"],
159
+ };
160
+ } catch {}
161
+
162
+ return null;
163
+ };
@@ -0,0 +1,357 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ /**
5
+ * Common Wikidata property IDs mapped to human-readable names
6
+ */
7
+ const PROPERTY_LABELS: Record<string, string> = {
8
+ P31: "Instance of",
9
+ P279: "Subclass of",
10
+ P17: "Country",
11
+ P131: "Located in",
12
+ P625: "Coordinates",
13
+ P18: "Image",
14
+ P154: "Logo",
15
+ P571: "Founded",
16
+ P576: "Dissolved",
17
+ P169: "CEO",
18
+ P112: "Founded by",
19
+ P159: "Headquarters",
20
+ P452: "Industry",
21
+ P1128: "Employees",
22
+ P2139: "Revenue",
23
+ P856: "Website",
24
+ P21: "Sex/Gender",
25
+ P27: "Citizenship",
26
+ P569: "Born",
27
+ P570: "Died",
28
+ P19: "Birthplace",
29
+ P20: "Death place",
30
+ P106: "Occupation",
31
+ P108: "Employer",
32
+ P69: "Educated at",
33
+ P22: "Father",
34
+ P25: "Mother",
35
+ P26: "Spouse",
36
+ P40: "Child",
37
+ P166: "Award",
38
+ P136: "Genre",
39
+ P495: "Country of origin",
40
+ P577: "Publication date",
41
+ P50: "Author",
42
+ P123: "Publisher",
43
+ P364: "Original language",
44
+ P86: "Composer",
45
+ P57: "Director",
46
+ P161: "Cast member",
47
+ P170: "Creator",
48
+ P178: "Developer",
49
+ P275: "License",
50
+ P306: "Operating system",
51
+ P277: "Programming language",
52
+ P348: "Version",
53
+ P1566: "GeoNames ID",
54
+ P214: "VIAF ID",
55
+ P227: "GND ID",
56
+ P213: "ISNI",
57
+ P496: "ORCID",
58
+ };
59
+
60
+ interface WikidataEntity {
61
+ type: string;
62
+ id: string;
63
+ labels?: Record<string, { language: string; value: string }>;
64
+ descriptions?: Record<string, { language: string; value: string }>;
65
+ aliases?: Record<string, Array<{ language: string; value: string }>>;
66
+ claims?: Record<string, WikidataClaim[]>;
67
+ sitelinks?: Record<string, { site: string; title: string }>;
68
+ }
69
+
70
+ interface WikidataClaim {
71
+ mainsnak: {
72
+ snaktype: string;
73
+ property: string;
74
+ datavalue?: {
75
+ type: string;
76
+ value: WikidataValue;
77
+ };
78
+ };
79
+ rank: string;
80
+ }
81
+
82
+ type WikidataValue =
83
+ | string
84
+ | { "entity-type": string; id: string; "numeric-id": number }
85
+ | { time: string; precision: number; calendarmodel: string }
86
+ | { amount: string; unit: string }
87
+ | { text: string; language: string }
88
+ | { latitude: number; longitude: number; precision: number };
89
+
90
+ /**
91
+ * Handle Wikidata URLs via EntityData API
92
+ */
93
+ export const handleWikidata: SpecialHandler = async (
94
+ url: string,
95
+ timeout: number,
96
+ signal?: AbortSignal,
97
+ ): Promise<RenderResult | null> => {
98
+ try {
99
+ const parsed = new URL(url);
100
+ if (!parsed.hostname.includes("wikidata.org")) return null;
101
+
102
+ // Extract Q-id from /wiki/Q123 or /entity/Q123
103
+ const qidMatch = parsed.pathname.match(/\/(?:wiki|entity)\/(Q\d+)/i);
104
+ if (!qidMatch) return null;
105
+
106
+ const qid = qidMatch[1].toUpperCase();
107
+ const fetchedAt = new Date().toISOString();
108
+
109
+ // Fetch entity data from API
110
+ const apiUrl = `https://www.wikidata.org/wiki/Special:EntityData/${qid}.json`;
111
+ const result = await loadPage(apiUrl, { timeout, signal });
112
+
113
+ if (!result.ok) return null;
114
+
115
+ let data: { entities: Record<string, WikidataEntity> };
116
+ try {
117
+ data = JSON.parse(result.content);
118
+ } catch {
119
+ return null;
120
+ }
121
+
122
+ const entity = data.entities[qid];
123
+ if (!entity) return null;
124
+
125
+ // Get label and description (prefer English)
126
+ const label = getLocalizedValue(entity.labels, "en") || qid;
127
+ const description = getLocalizedValue(entity.descriptions, "en");
128
+ const aliases = getLocalizedAliases(entity.aliases, "en");
129
+
130
+ let md = `# ${label} (${qid})\n\n`;
131
+ if (description) md += `*${description}*\n\n`;
132
+ if (aliases.length > 0) md += `**Also known as:** ${aliases.join(", ")}\n\n`;
133
+
134
+ // Count sitelinks
135
+ const sitelinkCount = entity.sitelinks ? Object.keys(entity.sitelinks).length : 0;
136
+ if (sitelinkCount > 0) {
137
+ md += `**Wikipedia articles:** ${formatCount(sitelinkCount)} languages\n\n`;
138
+ }
139
+
140
+ // Process claims
141
+ if (entity.claims && Object.keys(entity.claims).length > 0) {
142
+ md += "## Properties\n\n";
143
+
144
+ // Collect entity IDs we need to resolve
145
+ const entityIdsToResolve = new Set<string>();
146
+ for (const claims of Object.values(entity.claims)) {
147
+ for (const claim of claims) {
148
+ if (claim.mainsnak.datavalue?.type === "wikibase-entityid") {
149
+ const val = claim.mainsnak.datavalue.value as { id: string };
150
+ entityIdsToResolve.add(val.id);
151
+ }
152
+ }
153
+ }
154
+
155
+ // Fetch labels for referenced entities (limit to 50)
156
+ const entityLabels = await resolveEntityLabels(Array.from(entityIdsToResolve).slice(0, 50), timeout, signal);
157
+
158
+ // Group claims by property
159
+ const processedProperties: string[] = [];
160
+ for (const [propId, claims] of Object.entries(entity.claims)) {
161
+ const propLabel = PROPERTY_LABELS[propId] || propId;
162
+ const values: string[] = [];
163
+
164
+ for (const claim of claims) {
165
+ if (claim.rank === "deprecated") continue;
166
+ const value = formatClaimValue(claim, entityLabels);
167
+ if (value && !values.includes(value)) {
168
+ values.push(value);
169
+ }
170
+ }
171
+
172
+ if (values.length > 0) {
173
+ // Limit values shown per property
174
+ const displayValues = values.slice(0, 10);
175
+ const overflow = values.length > 10 ? ` (+${values.length - 10} more)` : "";
176
+ processedProperties.push(`- **${propLabel}:** ${displayValues.join(", ")}${overflow}`);
177
+ }
178
+ }
179
+
180
+ // Sort: known properties first, then by property ID
181
+ processedProperties.sort((a, b) => {
182
+ const aKnown = Object.values(PROPERTY_LABELS).some((l) => a.includes(`**${l}:**`));
183
+ const bKnown = Object.values(PROPERTY_LABELS).some((l) => b.includes(`**${l}:**`));
184
+ if (aKnown && !bKnown) return -1;
185
+ if (!aKnown && bKnown) return 1;
186
+ return a.localeCompare(b);
187
+ });
188
+
189
+ // Limit total properties shown
190
+ const maxProps = 50;
191
+ md += processedProperties.slice(0, maxProps).join("\n");
192
+ if (processedProperties.length > maxProps) {
193
+ md += `\n\n*...and ${processedProperties.length - maxProps} more properties*`;
194
+ }
195
+ md += "\n";
196
+ }
197
+
198
+ // Add notable sitelinks
199
+ if (entity.sitelinks) {
200
+ const notableSites = ["enwiki", "dewiki", "frwiki", "eswiki", "jawiki", "zhwiki"];
201
+ const links: string[] = [];
202
+
203
+ for (const site of notableSites) {
204
+ const sitelink = entity.sitelinks[site];
205
+ if (sitelink) {
206
+ const lang = site.replace("wiki", "");
207
+ const wikiUrl = `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(sitelink.title)}`;
208
+ links.push(`[${lang.toUpperCase()}](${wikiUrl})`);
209
+ }
210
+ }
211
+
212
+ if (links.length > 0) {
213
+ md += `\n## Wikipedia Links\n\n${links.join(" · ")}\n`;
214
+ }
215
+ }
216
+
217
+ const output = finalizeOutput(md);
218
+ return {
219
+ url,
220
+ finalUrl: url,
221
+ contentType: "text/markdown",
222
+ method: "wikidata",
223
+ content: output.content,
224
+ fetchedAt,
225
+ truncated: output.truncated,
226
+ notes: ["Fetched via Wikidata EntityData API"],
227
+ };
228
+ } catch {}
229
+
230
+ return null;
231
+ };
232
+
233
+ /**
234
+ * Get localized value with fallback
235
+ */
236
+ function getLocalizedValue(
237
+ values: Record<string, { language: string; value: string }> | undefined,
238
+ preferredLang: string,
239
+ ): string | null {
240
+ if (!values) return null;
241
+ if (values[preferredLang]) return values[preferredLang].value;
242
+ // Fallback to any available
243
+ const first = Object.values(values)[0];
244
+ return first?.value || null;
245
+ }
246
+
247
+ /**
248
+ * Get aliases for a language
249
+ */
250
+ function getLocalizedAliases(
251
+ aliases: Record<string, Array<{ language: string; value: string }>> | undefined,
252
+ preferredLang: string,
253
+ ): string[] {
254
+ if (!aliases) return [];
255
+ const langAliases = aliases[preferredLang];
256
+ if (!langAliases) return [];
257
+ return langAliases.map((a) => a.value);
258
+ }
259
+
260
+ /**
261
+ * Resolve entity IDs to their labels via wbgetentities API
262
+ */
263
+ async function resolveEntityLabels(
264
+ entityIds: string[],
265
+ timeout: number,
266
+ signal?: AbortSignal,
267
+ ): Promise<Record<string, string>> {
268
+ if (entityIds.length === 0) return {};
269
+
270
+ const labels: Record<string, string> = {};
271
+
272
+ // Fetch in batches of 50
273
+ const batchSize = 50;
274
+ for (let i = 0; i < entityIds.length; i += batchSize) {
275
+ const batch = entityIds.slice(i, i + batchSize);
276
+ const apiUrl = `https://www.wikidata.org/w/api.php?action=wbgetentities&ids=${batch.join("|")}&props=labels&languages=en&format=json`;
277
+
278
+ try {
279
+ const result = await loadPage(apiUrl, { timeout: Math.min(timeout, 10), signal });
280
+ if (result.ok) {
281
+ const data = JSON.parse(result.content) as {
282
+ entities: Record<string, { labels?: Record<string, { value: string }> }>;
283
+ };
284
+ for (const [id, entity] of Object.entries(data.entities)) {
285
+ const label = entity.labels?.en?.value;
286
+ if (label) labels[id] = label;
287
+ }
288
+ }
289
+ } catch {}
290
+ }
291
+
292
+ return labels;
293
+ }
294
+
295
+ /**
296
+ * Format a claim value to human-readable string
297
+ */
298
+ function formatClaimValue(claim: WikidataClaim, entityLabels: Record<string, string>): string | null {
299
+ const snak = claim.mainsnak;
300
+ if (snak.snaktype !== "value" || !snak.datavalue) return null;
301
+
302
+ const { type, value } = snak.datavalue;
303
+
304
+ switch (type) {
305
+ case "wikibase-entityid": {
306
+ const entityVal = value as { id: string };
307
+ return entityLabels[entityVal.id] || entityVal.id;
308
+ }
309
+ case "string":
310
+ return value as string;
311
+ case "time": {
312
+ const timeVal = value as { time: string; precision: number };
313
+ return formatWikidataTime(timeVal.time, timeVal.precision);
314
+ }
315
+ case "quantity": {
316
+ const qtyVal = value as { amount: string; unit: string };
317
+ const amount = qtyVal.amount.replace(/^\+/, "");
318
+ // Extract unit Q-id if present
319
+ const unitMatch = qtyVal.unit.match(/Q\d+$/);
320
+ const unit = unitMatch ? entityLabels[unitMatch[0]] || "" : "";
321
+ return unit ? `${amount} ${unit}` : amount;
322
+ }
323
+ case "monolingualtext": {
324
+ const textVal = value as { text: string; language: string };
325
+ return textVal.text;
326
+ }
327
+ case "globecoordinate": {
328
+ const coordVal = value as { latitude: number; longitude: number };
329
+ return `${coordVal.latitude.toFixed(4)}, ${coordVal.longitude.toFixed(4)}`;
330
+ }
331
+ default:
332
+ return null;
333
+ }
334
+ }
335
+
336
+ /**
337
+ * Format Wikidata time value to readable date
338
+ */
339
+ function formatWikidataTime(time: string, precision: number): string {
340
+ // Time format: +YYYY-MM-DDT00:00:00Z
341
+ const match = time.match(/^([+-]?\d+)-(\d{2})-(\d{2})/);
342
+ if (!match) return time;
343
+
344
+ const [, year, month, day] = match;
345
+ const yearNum = Number.parseInt(year, 10);
346
+ const absYear = Math.abs(yearNum);
347
+ const era = yearNum < 0 ? " BCE" : "";
348
+
349
+ // Precision: 9=year, 10=month, 11=day
350
+ if (precision >= 11) {
351
+ return `${day}/${month}/${absYear}${era}`;
352
+ }
353
+ if (precision >= 10) {
354
+ return `${month}/${absYear}${era}`;
355
+ }
356
+ return `${absYear}${era}`;
357
+ }