@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,209 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, loadPage } from "./types";
3
+
4
+ interface RfcMetadata {
5
+ doc_id: string;
6
+ title: string;
7
+ authors?: Array<{ name: string; affiliation?: string }>;
8
+ pub_status?: string;
9
+ current_status?: string;
10
+ stream?: string;
11
+ area?: string;
12
+ wg_acronym?: string;
13
+ pub_date?: string;
14
+ page_count?: number;
15
+ abstract?: string;
16
+ keywords?: string[];
17
+ obsoletes?: string[];
18
+ obsoleted_by?: string[];
19
+ updates?: string[];
20
+ updated_by?: string[];
21
+ see_also?: string[];
22
+ errata_url?: string;
23
+ }
24
+
25
+ /**
26
+ * Extract RFC number from various URL patterns
27
+ */
28
+ function extractRfcNumber(url: URL): string | null {
29
+ const { hostname, pathname } = url;
30
+
31
+ // https://www.rfc-editor.org/rfc/rfc{number}
32
+ // https://www.rfc-editor.org/rfc/rfc{number}.html
33
+ // https://www.rfc-editor.org/rfc/rfc{number}.txt
34
+ if (hostname === "www.rfc-editor.org" || hostname === "rfc-editor.org") {
35
+ const match = pathname.match(/\/rfc\/rfc(\d+)(?:\.(?:html|txt|pdf))?$/i);
36
+ if (match) return match[1];
37
+ }
38
+
39
+ // https://datatracker.ietf.org/doc/rfc{number}/
40
+ // https://datatracker.ietf.org/doc/html/rfc{number}
41
+ if (hostname === "datatracker.ietf.org") {
42
+ const match = pathname.match(/\/doc\/(?:html\/)?rfc(\d+)\/?$/i);
43
+ if (match) return match[1];
44
+ }
45
+
46
+ // https://tools.ietf.org/html/rfc{number}
47
+ if (hostname === "tools.ietf.org") {
48
+ const match = pathname.match(/\/html\/rfc(\d+)$/i);
49
+ if (match) return match[1];
50
+ }
51
+
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Clean up RFC plain text - remove page headers/footers and extra formatting
57
+ */
58
+ function cleanRfcText(text: string): string {
59
+ const lines = text.split("\n");
60
+ const cleaned: string[] = [];
61
+ let skipNext = 0;
62
+
63
+ for (let i = 0; i < lines.length; i++) {
64
+ const line = lines[i];
65
+
66
+ // Skip lines we've marked to skip (form feeds and surrounding blank lines)
67
+ if (skipNext > 0) {
68
+ skipNext--;
69
+ continue;
70
+ }
71
+
72
+ // Skip form feed characters and page headers (RFC NNNN ... Month Year pattern)
73
+ if (line.includes("\f")) {
74
+ // Skip the form feed line and typically 2-3 following header lines
75
+ skipNext = 3;
76
+ continue;
77
+ }
78
+
79
+ // Skip page footer lines (typically just a page number or "[Page N]")
80
+ if (/^\s*\[Page \d+\]\s*$/.test(line)) {
81
+ continue;
82
+ }
83
+
84
+ cleaned.push(line);
85
+ }
86
+
87
+ return cleaned.join("\n").replace(/\n{4,}/g, "\n\n\n");
88
+ }
89
+
90
+ /**
91
+ * Handle RFC Editor URLs - fetches IETF RFCs
92
+ */
93
+ export const handleRfc: SpecialHandler = async (
94
+ url: string,
95
+ timeout: number,
96
+ signal?: AbortSignal,
97
+ ): Promise<RenderResult | null> => {
98
+ try {
99
+ const parsed = new URL(url);
100
+ const rfcNumber = extractRfcNumber(parsed);
101
+
102
+ if (!rfcNumber) return null;
103
+
104
+ const fetchedAt = new Date().toISOString();
105
+ const notes: string[] = [];
106
+
107
+ // Fetch metadata JSON and plain text in parallel
108
+ const metadataUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.json`;
109
+ const textUrl = `https://www.rfc-editor.org/rfc/rfc${rfcNumber}.txt`;
110
+
111
+ const [metaResult, textResult] = await Promise.all([
112
+ loadPage(metadataUrl, { timeout: Math.min(timeout, 10), signal }),
113
+ loadPage(textUrl, { timeout, signal }),
114
+ ]);
115
+
116
+ // We need at least the text content
117
+ if (!textResult.ok) return null;
118
+
119
+ let metadata: RfcMetadata | null = null;
120
+ if (metaResult.ok) {
121
+ try {
122
+ metadata = JSON.parse(metaResult.content);
123
+ notes.push("Metadata from RFC Editor JSON API");
124
+ } catch {
125
+ // JSON parse failed, continue without metadata
126
+ }
127
+ }
128
+
129
+ // Build markdown output
130
+ let md = "";
131
+
132
+ if (metadata) {
133
+ md += `# RFC ${rfcNumber}: ${metadata.title}\n\n`;
134
+
135
+ // Authors
136
+ if (metadata.authors?.length) {
137
+ const authorList = metadata.authors
138
+ .map((a) => (a.affiliation ? `${a.name} (${a.affiliation})` : a.name))
139
+ .join(", ");
140
+ md += `**Authors:** ${authorList}\n`;
141
+ }
142
+
143
+ // Publication info
144
+ if (metadata.pub_date) md += `**Published:** ${metadata.pub_date}\n`;
145
+ if (metadata.current_status) md += `**Status:** ${metadata.current_status}\n`;
146
+ if (metadata.stream) md += `**Stream:** ${metadata.stream}\n`;
147
+ if (metadata.area) md += `**Area:** ${metadata.area}\n`;
148
+ if (metadata.wg_acronym) md += `**Working Group:** ${metadata.wg_acronym}\n`;
149
+ if (metadata.page_count) md += `**Pages:** ${metadata.page_count}\n`;
150
+
151
+ // Related RFCs
152
+ if (metadata.obsoletes?.length) {
153
+ md += `**Obsoletes:** ${metadata.obsoletes.join(", ")}\n`;
154
+ }
155
+ if (metadata.obsoleted_by?.length) {
156
+ md += `**Obsoleted by:** ${metadata.obsoleted_by.join(", ")}\n`;
157
+ }
158
+ if (metadata.updates?.length) {
159
+ md += `**Updates:** ${metadata.updates.join(", ")}\n`;
160
+ }
161
+ if (metadata.updated_by?.length) {
162
+ md += `**Updated by:** ${metadata.updated_by.join(", ")}\n`;
163
+ }
164
+
165
+ // Keywords
166
+ if (metadata.keywords?.length) {
167
+ md += `**Keywords:** ${metadata.keywords.join(", ")}\n`;
168
+ }
169
+
170
+ // Errata
171
+ if (metadata.errata_url) {
172
+ md += `**Errata:** ${metadata.errata_url}\n`;
173
+ }
174
+
175
+ md += "\n";
176
+
177
+ // Abstract from metadata
178
+ if (metadata.abstract) {
179
+ md += `## Abstract\n\n${metadata.abstract}\n\n`;
180
+ }
181
+
182
+ md += "---\n\n";
183
+ } else {
184
+ // No metadata, use simple header
185
+ md += `# RFC ${rfcNumber}\n\n`;
186
+ notes.push("Metadata not available, showing plain text only");
187
+ }
188
+
189
+ // Add full text content
190
+ md += "## Full Text\n\n";
191
+ md += "```\n";
192
+ md += cleanRfcText(textResult.content);
193
+ md += "\n```\n";
194
+
195
+ const output = finalizeOutput(md);
196
+ return {
197
+ url,
198
+ finalUrl: `https://www.rfc-editor.org/rfc/rfc${rfcNumber}`,
199
+ contentType: "text/markdown",
200
+ method: "rfc",
201
+ content: output.content,
202
+ fetchedAt,
203
+ truncated: output.truncated,
204
+ notes: notes.length ? notes : ["Fetched from RFC Editor"],
205
+ };
206
+ } catch {}
207
+
208
+ return null;
209
+ };
@@ -0,0 +1,117 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface RubyGemsDependency {
5
+ name: string;
6
+ requirements: string;
7
+ }
8
+
9
+ interface RubyGemsResponse {
10
+ name: string;
11
+ version: string;
12
+ version_created_at?: string;
13
+ authors?: string;
14
+ info?: string;
15
+ licenses?: string[];
16
+ homepage_uri?: string;
17
+ source_code_uri?: string;
18
+ documentation_uri?: string;
19
+ project_uri?: string;
20
+ downloads: number;
21
+ version_downloads?: number;
22
+ gem_uri?: string;
23
+ dependencies?: {
24
+ development?: RubyGemsDependency[];
25
+ runtime?: RubyGemsDependency[];
26
+ };
27
+ metadata?: Record<string, string>;
28
+ }
29
+
30
+ /**
31
+ * Handle RubyGems URLs via API
32
+ */
33
+ export const handleRubyGems: SpecialHandler = async (
34
+ url: string,
35
+ timeout: number,
36
+ signal?: AbortSignal,
37
+ ): Promise<RenderResult | null> => {
38
+ try {
39
+ const parsed = new URL(url);
40
+ if (parsed.hostname !== "rubygems.org" && parsed.hostname !== "www.rubygems.org") return null;
41
+
42
+ // Extract gem name from /gems/{name}
43
+ const match = parsed.pathname.match(/^\/gems\/([^/]+)/);
44
+ if (!match) return null;
45
+
46
+ const gemName = decodeURIComponent(match[1]);
47
+ const fetchedAt = new Date().toISOString();
48
+
49
+ // Fetch from RubyGems API
50
+ const apiUrl = `https://rubygems.org/api/v1/gems/${encodeURIComponent(gemName)}.json`;
51
+ const result = await loadPage(apiUrl, {
52
+ timeout,
53
+ signal,
54
+ headers: { Accept: "application/json" },
55
+ });
56
+
57
+ if (!result.ok) return null;
58
+
59
+ let gem: RubyGemsResponse;
60
+ try {
61
+ gem = JSON.parse(result.content);
62
+ } catch {
63
+ return null;
64
+ }
65
+
66
+ let md = `# ${gem.name}\n\n`;
67
+ if (gem.info) md += `${gem.info}\n\n`;
68
+
69
+ // Version and license
70
+ md += `**Version:** ${gem.version}`;
71
+ if (gem.licenses?.length) md += ` · **License:** ${gem.licenses.join(", ")}`;
72
+ md += "\n";
73
+
74
+ // Downloads
75
+ md += `**Total Downloads:** ${formatCount(gem.downloads)}`;
76
+ if (gem.version_downloads) md += ` · **Version Downloads:** ${formatCount(gem.version_downloads)}`;
77
+ md += "\n\n";
78
+
79
+ // Links
80
+ if (gem.homepage_uri) md += `**Homepage:** ${gem.homepage_uri}\n`;
81
+ if (gem.source_code_uri) md += `**Source Code:** ${gem.source_code_uri}\n`;
82
+ if (gem.documentation_uri) md += `**Documentation:** ${gem.documentation_uri}\n`;
83
+ if (gem.authors) md += `**Authors:** ${gem.authors}\n`;
84
+
85
+ // Runtime dependencies
86
+ const runtimeDeps = gem.dependencies?.runtime;
87
+ if (runtimeDeps && runtimeDeps.length > 0) {
88
+ md += `\n## Runtime Dependencies\n\n`;
89
+ for (const dep of runtimeDeps) {
90
+ md += `- ${dep.name} ${dep.requirements}\n`;
91
+ }
92
+ }
93
+
94
+ // Development dependencies
95
+ const devDeps = gem.dependencies?.development;
96
+ if (devDeps && devDeps.length > 0) {
97
+ md += `\n## Development Dependencies\n\n`;
98
+ for (const dep of devDeps) {
99
+ md += `- ${dep.name} ${dep.requirements}\n`;
100
+ }
101
+ }
102
+
103
+ const output = finalizeOutput(md);
104
+ return {
105
+ url,
106
+ finalUrl: url,
107
+ contentType: "text/markdown",
108
+ method: "rubygems",
109
+ content: output.content,
110
+ fetchedAt,
111
+ truncated: output.truncated,
112
+ notes: ["Fetched via RubyGems API"],
113
+ };
114
+ } catch {}
115
+
116
+ return null;
117
+ };
@@ -0,0 +1,217 @@
1
+ import type { RenderResult, SpecialHandler } from "./types";
2
+ import { finalizeOutput, formatCount, loadPage } from "./types";
3
+
4
+ interface SearchcodeResult {
5
+ id?: number | string;
6
+ filename?: string;
7
+ repo?: string;
8
+ language?: string;
9
+ code?: string;
10
+ lines?: number | string | Array<number | string>;
11
+ location?: string;
12
+ url?: string;
13
+ }
14
+
15
+ interface SearchcodeSearchResponse {
16
+ query?: string;
17
+ results?: SearchcodeResult[];
18
+ total?: number;
19
+ total_results?: number;
20
+ nextpage?: number;
21
+ }
22
+
23
+ const VALID_HOSTS = new Set(["searchcode.com", "www.searchcode.com"]);
24
+
25
+ function parseLineNumbers(lines: SearchcodeResult["lines"]): number[] | null {
26
+ if (typeof lines === "number" && Number.isFinite(lines)) return [lines];
27
+
28
+ if (typeof lines === "string") {
29
+ const parts = lines.split(/[,\s]+/).filter(Boolean);
30
+ const parsed = parts.map((part) => Number.parseInt(part, 10)).filter((value) => Number.isFinite(value));
31
+ return parsed.length ? parsed : null;
32
+ }
33
+
34
+ if (Array.isArray(lines)) {
35
+ const parsed = lines.map((part) => Number.parseInt(String(part), 10)).filter((value) => Number.isFinite(value));
36
+ return parsed.length ? parsed : null;
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ function formatLineNumbers(lines: number[] | null): string | null {
43
+ if (!lines || lines.length === 0) return null;
44
+ if (lines.length <= 10) return lines.join(", ");
45
+ const min = Math.min(...lines);
46
+ const max = Math.max(...lines);
47
+ return `${min}-${max} (${lines.length} lines)`;
48
+ }
49
+
50
+ function formatCodeBlock(
51
+ code: string | undefined,
52
+ language: string | undefined,
53
+ lines: number[] | null,
54
+ ): string | null {
55
+ if (!code) return null;
56
+
57
+ const normalized = code.replace(/\r\n/g, "\n").trimEnd();
58
+ const codeLines = normalized.split("\n");
59
+ const languageTag = typeof language === "string" ? language.trim().toLowerCase() : "";
60
+
61
+ let displayLines = codeLines;
62
+ if (lines && lines.length === codeLines.length) {
63
+ displayLines = codeLines.map((line, index) => `${lines[index]}: ${line}`);
64
+ }
65
+
66
+ const fence = languageTag ? languageTag : "";
67
+ return `\n\n\`\`\`${fence}\n${displayLines.join("\n")}\n\`\`\`\n`;
68
+ }
69
+
70
+ export const handleSearchcode: SpecialHandler = async (
71
+ url: string,
72
+ timeout: number,
73
+ signal?: AbortSignal,
74
+ ): Promise<RenderResult | null> => {
75
+ try {
76
+ const parsed = new URL(url);
77
+ if (!VALID_HOSTS.has(parsed.hostname)) return null;
78
+
79
+ const fetchedAt = new Date().toISOString();
80
+ const viewMatch = parsed.pathname.match(/^\/codesearch\/view\/([^/?#]+)/);
81
+ if (viewMatch) {
82
+ const id = viewMatch[1];
83
+ const apiUrl = `https://searchcode.com/api/result/${encodeURIComponent(id)}/`;
84
+ const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } });
85
+ if (!result.ok) return null;
86
+
87
+ let data: SearchcodeResult;
88
+ try {
89
+ data = JSON.parse(result.content) as SearchcodeResult;
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ const filename = data.filename || data.location || `Result ${id}`;
95
+ const lineNumbers = parseLineNumbers(data.lines);
96
+ const formattedLines = formatLineNumbers(lineNumbers);
97
+ const viewUrl = data.url || `https://searchcode.com/codesearch/view/${id}`;
98
+ const snippetBlock = formatCodeBlock(data.code, data.language, lineNumbers);
99
+
100
+ let md = `# ${filename}\n\n`;
101
+ md += `## Description\n\n`;
102
+ md += "Code snippet from searchcode.com.\n\n";
103
+ md += `## Metadata\n\n`;
104
+ if (data.repo) md += `**Repository:** ${data.repo}\n`;
105
+ if (data.language) md += `**Language:** ${data.language}\n`;
106
+ if (data.filename) md += `**File:** ${data.filename}\n`;
107
+ if (data.location) md += `**Location:** ${data.location}\n`;
108
+ if (formattedLines) md += `**Lines:** ${formattedLines}\n`;
109
+ md += `**Result ID:** ${id}\n`;
110
+ md += `**URL:** ${viewUrl}\n`;
111
+
112
+ md += `\n## Snippet`;
113
+ if (snippetBlock) {
114
+ md += snippetBlock;
115
+ } else {
116
+ md += "\n\n_No snippet available._\n";
117
+ }
118
+
119
+ const output = finalizeOutput(md);
120
+ return {
121
+ url,
122
+ finalUrl: url,
123
+ contentType: "text/markdown",
124
+ method: "searchcode",
125
+ content: output.content,
126
+ fetchedAt,
127
+ truncated: output.truncated,
128
+ notes: ["Fetched via searchcode API"],
129
+ };
130
+ }
131
+
132
+ const query = parsed.searchParams.get("q");
133
+ const isSearchPage =
134
+ parsed.pathname === "/" || parsed.pathname === "/codesearch" || parsed.pathname === "/codesearch/";
135
+ if (!query || !isSearchPage) return null;
136
+
137
+ const pageRaw = parsed.searchParams.get("p") ?? parsed.searchParams.get("page");
138
+ const pageNumber = pageRaw ? Number.parseInt(pageRaw, 10) : 0;
139
+ const page = Number.isFinite(pageNumber) && pageNumber >= 0 ? pageNumber : 0;
140
+ const apiUrl = `https://searchcode.com/api/codesearch_I/?q=${encodeURIComponent(query)}&p=${page}`;
141
+ const result = await loadPage(apiUrl, { timeout, signal, headers: { Accept: "application/json" } });
142
+ if (!result.ok) return null;
143
+
144
+ let data: SearchcodeSearchResponse;
145
+ try {
146
+ data = JSON.parse(result.content) as SearchcodeSearchResponse;
147
+ } catch {
148
+ return null;
149
+ }
150
+
151
+ const results = Array.isArray(data.results) ? data.results : [];
152
+ const total =
153
+ typeof data.total === "number"
154
+ ? data.total
155
+ : typeof data.total_results === "number"
156
+ ? data.total_results
157
+ : null;
158
+
159
+ let md = `# Searchcode Results\n\n`;
160
+ md += `## Description\n\n`;
161
+ md += `Search results for \`${query}\` on searchcode.com.\n\n`;
162
+ md += `## Metadata\n\n`;
163
+ md += `**Query:** \`${query}\`\n`;
164
+ md += `**Page:** ${page}\n`;
165
+ if (total !== null) md += `**Total Results:** ${formatCount(total)}\n`;
166
+ md += `**Result Count:** ${results.length}\n`;
167
+ if (typeof data.nextpage === "number") md += `**Next Page:** ${data.nextpage}\n`;
168
+
169
+ md += `\n## Results\n\n`;
170
+
171
+ if (results.length === 0) {
172
+ md += "_No results found._\n";
173
+ } else {
174
+ const maxResults = 10;
175
+ for (const resultItem of results.slice(0, maxResults)) {
176
+ const id = resultItem.id !== undefined ? String(resultItem.id) : null;
177
+ const filename = resultItem.filename || resultItem.location || "Result";
178
+ const lineNumbers = parseLineNumbers(resultItem.lines);
179
+ const formattedLines = formatLineNumbers(lineNumbers);
180
+ const viewUrl = resultItem.url || (id ? `https://searchcode.com/codesearch/view/${id}` : null);
181
+ const snippetBlock = formatCodeBlock(resultItem.code, resultItem.language, lineNumbers);
182
+
183
+ md += `### ${filename}\n\n`;
184
+ if (resultItem.repo) md += `**Repository:** ${resultItem.repo}\n`;
185
+ if (resultItem.language) md += `**Language:** ${resultItem.language}\n`;
186
+ if (resultItem.filename) md += `**File:** ${resultItem.filename}\n`;
187
+ if (resultItem.location) md += `**Location:** ${resultItem.location}\n`;
188
+ if (formattedLines) md += `**Lines:** ${formattedLines}\n`;
189
+ if (viewUrl) md += `**URL:** ${viewUrl}\n`;
190
+
191
+ if (snippetBlock) {
192
+ md += `${snippetBlock}\n`;
193
+ }
194
+
195
+ md += "\n";
196
+ }
197
+
198
+ if (results.length > maxResults) {
199
+ md += `\n_Only showing first ${maxResults} results._\n`;
200
+ }
201
+ }
202
+
203
+ const output = finalizeOutput(md);
204
+ return {
205
+ url,
206
+ finalUrl: url,
207
+ contentType: "text/markdown",
208
+ method: "searchcode",
209
+ content: output.content,
210
+ fetchedAt,
211
+ truncated: output.truncated,
212
+ notes: ["Fetched via searchcode API"],
213
+ };
214
+ } catch {}
215
+
216
+ return null;
217
+ };