@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.
- package/CHANGELOG.md +90 -0
- package/package.json +5 -5
- package/src/cli/args.ts +4 -0
- package/src/core/agent-session.ts +29 -2
- package/src/core/bash-executor.ts +2 -1
- package/src/core/custom-commands/bundled/review/index.ts +369 -14
- package/src/core/custom-commands/bundled/wt/index.ts +1 -1
- package/src/core/session-manager.ts +158 -246
- package/src/core/session-storage.ts +379 -0
- package/src/core/settings-manager.ts +155 -4
- package/src/core/system-prompt.ts +62 -64
- package/src/core/tools/ask.ts +5 -4
- package/src/core/tools/bash-interceptor.ts +26 -61
- package/src/core/tools/bash.ts +13 -8
- package/src/core/tools/complete.ts +2 -4
- package/src/core/tools/edit-diff.ts +11 -4
- package/src/core/tools/edit.ts +7 -13
- package/src/core/tools/find.ts +111 -50
- package/src/core/tools/gemini-image.ts +128 -147
- package/src/core/tools/grep.ts +397 -415
- package/src/core/tools/index.test.ts +5 -1
- package/src/core/tools/index.ts +6 -8
- package/src/core/tools/jtd-to-json-schema.ts +174 -196
- package/src/core/tools/ls.ts +12 -10
- package/src/core/tools/lsp/client.ts +58 -9
- package/src/core/tools/lsp/config.ts +205 -656
- package/src/core/tools/lsp/defaults.json +465 -0
- package/src/core/tools/lsp/index.ts +55 -32
- package/src/core/tools/lsp/rust-analyzer.ts +49 -10
- package/src/core/tools/lsp/types.ts +1 -0
- package/src/core/tools/lsp/utils.ts +1 -1
- package/src/core/tools/read.ts +152 -76
- package/src/core/tools/render-utils.ts +70 -10
- package/src/core/tools/review.ts +38 -126
- package/src/core/tools/task/artifacts.ts +5 -4
- package/src/core/tools/task/executor.ts +204 -67
- package/src/core/tools/task/index.ts +129 -92
- package/src/core/tools/task/name-generator.ts +1544 -214
- package/src/core/tools/task/parallel.ts +30 -3
- package/src/core/tools/task/render.ts +85 -39
- package/src/core/tools/task/types.ts +34 -11
- package/src/core/tools/task/worker.ts +152 -27
- package/src/core/tools/web-fetch.ts +220 -1657
- package/src/core/tools/web-scrapers/academic.test.ts +239 -0
- package/src/core/tools/web-scrapers/artifacthub.ts +215 -0
- package/src/core/tools/web-scrapers/arxiv.ts +88 -0
- package/src/core/tools/web-scrapers/aur.ts +175 -0
- package/src/core/tools/web-scrapers/biorxiv.ts +141 -0
- package/src/core/tools/web-scrapers/bluesky.ts +284 -0
- package/src/core/tools/web-scrapers/brew.ts +177 -0
- package/src/core/tools/web-scrapers/business.test.ts +82 -0
- package/src/core/tools/web-scrapers/cheatsh.ts +78 -0
- package/src/core/tools/web-scrapers/chocolatey.ts +158 -0
- package/src/core/tools/web-scrapers/choosealicense.ts +110 -0
- package/src/core/tools/web-scrapers/cisa-kev.ts +100 -0
- package/src/core/tools/web-scrapers/clojars.ts +180 -0
- package/src/core/tools/web-scrapers/coingecko.ts +184 -0
- package/src/core/tools/web-scrapers/crates-io.ts +128 -0
- package/src/core/tools/web-scrapers/crossref.ts +149 -0
- package/src/core/tools/web-scrapers/dev-platforms.test.ts +254 -0
- package/src/core/tools/web-scrapers/devto.ts +177 -0
- package/src/core/tools/web-scrapers/discogs.ts +308 -0
- package/src/core/tools/web-scrapers/discourse.ts +221 -0
- package/src/core/tools/web-scrapers/dockerhub.ts +160 -0
- package/src/core/tools/web-scrapers/documentation.test.ts +85 -0
- package/src/core/tools/web-scrapers/fdroid.ts +158 -0
- package/src/core/tools/web-scrapers/finance-media.test.ts +144 -0
- package/src/core/tools/web-scrapers/firefox-addons.ts +214 -0
- package/src/core/tools/web-scrapers/flathub.ts +239 -0
- package/src/core/tools/web-scrapers/git-hosting.test.ts +272 -0
- package/src/core/tools/web-scrapers/github-gist.ts +68 -0
- package/src/core/tools/web-scrapers/github.ts +455 -0
- package/src/core/tools/web-scrapers/gitlab.ts +456 -0
- package/src/core/tools/web-scrapers/go-pkg.ts +275 -0
- package/src/core/tools/web-scrapers/hackage.ts +94 -0
- package/src/core/tools/web-scrapers/hackernews.ts +208 -0
- package/src/core/tools/web-scrapers/hex.ts +121 -0
- package/src/core/tools/web-scrapers/huggingface.ts +385 -0
- package/src/core/tools/web-scrapers/iacr.ts +86 -0
- package/src/core/tools/web-scrapers/index.ts +250 -0
- package/src/core/tools/web-scrapers/jetbrains-marketplace.ts +169 -0
- package/src/core/tools/web-scrapers/lemmy.ts +220 -0
- package/src/core/tools/web-scrapers/lobsters.ts +186 -0
- package/src/core/tools/web-scrapers/mastodon.ts +310 -0
- package/src/core/tools/web-scrapers/maven.ts +152 -0
- package/src/core/tools/web-scrapers/mdn.ts +174 -0
- package/src/core/tools/web-scrapers/media.test.ts +138 -0
- package/src/core/tools/web-scrapers/metacpan.ts +253 -0
- package/src/core/tools/web-scrapers/musicbrainz.ts +273 -0
- package/src/core/tools/web-scrapers/npm.ts +114 -0
- package/src/core/tools/web-scrapers/nuget.ts +205 -0
- package/src/core/tools/web-scrapers/nvd.ts +243 -0
- package/src/core/tools/web-scrapers/ollama.ts +267 -0
- package/src/core/tools/web-scrapers/open-vsx.ts +119 -0
- package/src/core/tools/web-scrapers/opencorporates.ts +275 -0
- package/src/core/tools/web-scrapers/openlibrary.ts +319 -0
- package/src/core/tools/web-scrapers/orcid.ts +299 -0
- package/src/core/tools/web-scrapers/osv.ts +189 -0
- package/src/core/tools/web-scrapers/package-managers-2.test.ts +199 -0
- package/src/core/tools/web-scrapers/package-managers.test.ts +171 -0
- package/src/core/tools/web-scrapers/package-registries.test.ts +259 -0
- package/src/core/tools/web-scrapers/packagist.ts +174 -0
- package/src/core/tools/web-scrapers/pub-dev.ts +185 -0
- package/src/core/tools/web-scrapers/pubmed.ts +178 -0
- package/src/core/tools/web-scrapers/pypi.ts +129 -0
- package/src/core/tools/web-scrapers/rawg.ts +124 -0
- package/src/core/tools/web-scrapers/readthedocs.ts +126 -0
- package/src/core/tools/web-scrapers/reddit.ts +104 -0
- package/src/core/tools/web-scrapers/repology.ts +262 -0
- package/src/core/tools/web-scrapers/research.test.ts +107 -0
- package/src/core/tools/web-scrapers/rfc.ts +209 -0
- package/src/core/tools/web-scrapers/rubygems.ts +117 -0
- package/src/core/tools/web-scrapers/searchcode.ts +217 -0
- package/src/core/tools/web-scrapers/sec-edgar.ts +274 -0
- package/src/core/tools/web-scrapers/security.test.ts +103 -0
- package/src/core/tools/web-scrapers/semantic-scholar.ts +190 -0
- package/src/core/tools/web-scrapers/snapcraft.ts +200 -0
- package/src/core/tools/web-scrapers/social-extended.test.ts +192 -0
- package/src/core/tools/web-scrapers/social.test.ts +259 -0
- package/src/core/tools/web-scrapers/sourcegraph.ts +373 -0
- package/src/core/tools/web-scrapers/spdx.ts +121 -0
- package/src/core/tools/web-scrapers/spotify.ts +218 -0
- package/src/core/tools/web-scrapers/stackexchange.test.ts +120 -0
- package/src/core/tools/web-scrapers/stackoverflow.ts +124 -0
- package/src/core/tools/web-scrapers/standards.test.ts +122 -0
- package/src/core/tools/web-scrapers/terraform.ts +304 -0
- package/src/core/tools/web-scrapers/tldr.ts +51 -0
- package/src/core/tools/web-scrapers/twitter.ts +96 -0
- package/src/core/tools/web-scrapers/types.ts +234 -0
- package/src/core/tools/web-scrapers/utils.ts +162 -0
- package/src/core/tools/web-scrapers/vimeo.ts +152 -0
- package/src/core/tools/web-scrapers/vscode-marketplace.ts +195 -0
- package/src/core/tools/web-scrapers/w3c.ts +163 -0
- package/src/core/tools/web-scrapers/wikidata.ts +357 -0
- package/src/core/tools/web-scrapers/wikipedia.test.ts +73 -0
- package/src/core/tools/web-scrapers/wikipedia.ts +95 -0
- package/src/core/tools/web-scrapers/youtube.test.ts +198 -0
- package/src/core/tools/web-scrapers/youtube.ts +371 -0
- package/src/core/tools/write.ts +21 -18
- package/src/core/voice.ts +3 -2
- package/src/lib/worktree/collapse.ts +2 -1
- package/src/lib/worktree/git.ts +2 -18
- package/src/main.ts +59 -3
- package/src/modes/interactive/components/extensions/extension-dashboard.ts +33 -19
- package/src/modes/interactive/components/extensions/extension-list.ts +15 -8
- package/src/modes/interactive/components/hook-editor.ts +2 -1
- package/src/modes/interactive/components/model-selector.ts +19 -4
- package/src/modes/interactive/interactive-mode.ts +41 -38
- package/src/modes/interactive/theme/theme.ts +58 -58
- package/src/modes/rpc/rpc-mode.ts +10 -9
- package/src/prompts/review-request.md +27 -0
- package/src/prompts/reviewer.md +64 -68
- package/src/prompts/tools/output.md +22 -3
- package/src/prompts/tools/task.md +32 -33
- package/src/utils/clipboard.ts +2 -1
- package/src/utils/tools-manager.ts +110 -8
- package/examples/extensions/subagent/agents/reviewer.md +0 -35
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
const GRAPHQL_ENDPOINT = "https://sourcegraph.com/.api/graphql";
|
|
5
|
+
const GRAPHQL_HEADERS = {
|
|
6
|
+
Accept: "application/json",
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type SourcegraphTarget =
|
|
11
|
+
| { type: "search"; query: string }
|
|
12
|
+
| { type: "repo"; repoName: string; rev?: string }
|
|
13
|
+
| { type: "file"; repoName: string; rev?: string; filePath: string };
|
|
14
|
+
|
|
15
|
+
interface SourcegraphRepository {
|
|
16
|
+
name: string;
|
|
17
|
+
url: string;
|
|
18
|
+
description?: string | null;
|
|
19
|
+
defaultBranch?: { name: string } | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface RepoQueryData {
|
|
23
|
+
repository?: SourcegraphRepository | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RepoFileQueryData {
|
|
27
|
+
repository?:
|
|
28
|
+
| (SourcegraphRepository & {
|
|
29
|
+
commit?: {
|
|
30
|
+
blob?: { content?: string | null } | null;
|
|
31
|
+
} | null;
|
|
32
|
+
})
|
|
33
|
+
| null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SearchQueryData {
|
|
37
|
+
search?: {
|
|
38
|
+
results?: {
|
|
39
|
+
results?: SearchResultItem[] | null;
|
|
40
|
+
matchCount?: number | null;
|
|
41
|
+
limitHit?: boolean | null;
|
|
42
|
+
} | null;
|
|
43
|
+
} | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface FileMatchResult {
|
|
47
|
+
__typename: "FileMatch";
|
|
48
|
+
repository?: { name?: string | null; url?: string | null } | null;
|
|
49
|
+
file?: { path?: string | null; url?: string | null } | null;
|
|
50
|
+
lineMatches?: Array<{ preview?: string | null; lineNumber?: number | null }> | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface RepositoryResult {
|
|
54
|
+
__typename: "Repository";
|
|
55
|
+
name?: string | null;
|
|
56
|
+
url?: string | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SearchResultItem = FileMatchResult | RepositoryResult | { __typename: string };
|
|
60
|
+
|
|
61
|
+
const REPO_QUERY = `query Repo($name: String!) {
|
|
62
|
+
repository(name: $name) {
|
|
63
|
+
name
|
|
64
|
+
url
|
|
65
|
+
description
|
|
66
|
+
defaultBranch {
|
|
67
|
+
name
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}`;
|
|
71
|
+
|
|
72
|
+
const REPO_FILE_QUERY = `query RepoFile($name: String!, $path: String!, $rev: String!) {
|
|
73
|
+
repository(name: $name) {
|
|
74
|
+
name
|
|
75
|
+
url
|
|
76
|
+
description
|
|
77
|
+
defaultBranch {
|
|
78
|
+
name
|
|
79
|
+
}
|
|
80
|
+
commit(rev: $rev) {
|
|
81
|
+
blob(path: $path) {
|
|
82
|
+
content
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}`;
|
|
87
|
+
|
|
88
|
+
const SEARCH_QUERY = `query Search($query: String!) {
|
|
89
|
+
search(query: $query, version: V2) {
|
|
90
|
+
results {
|
|
91
|
+
results {
|
|
92
|
+
__typename
|
|
93
|
+
... on FileMatch {
|
|
94
|
+
repository {
|
|
95
|
+
name
|
|
96
|
+
url
|
|
97
|
+
}
|
|
98
|
+
file {
|
|
99
|
+
path
|
|
100
|
+
url
|
|
101
|
+
}
|
|
102
|
+
lineMatches {
|
|
103
|
+
preview
|
|
104
|
+
lineNumber
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
... on Repository {
|
|
108
|
+
name
|
|
109
|
+
url
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
matchCount
|
|
113
|
+
limitHit
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}`;
|
|
117
|
+
|
|
118
|
+
function parseSourcegraphUrl(url: string): SourcegraphTarget | null {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = new URL(url);
|
|
121
|
+
if (parsed.hostname !== "sourcegraph.com" && parsed.hostname !== "www.sourcegraph.com") return null;
|
|
122
|
+
|
|
123
|
+
if (parsed.pathname.startsWith("/search")) {
|
|
124
|
+
const query = parsed.searchParams.get("q")?.trim();
|
|
125
|
+
if (!query) return null;
|
|
126
|
+
return { type: "search", query };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const parts = parsed.pathname
|
|
130
|
+
.split("/")
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.map((part) => decodeURIComponent(part));
|
|
133
|
+
if (parts.length < 3) return null;
|
|
134
|
+
|
|
135
|
+
const hyphenIndex = parts.indexOf("-");
|
|
136
|
+
const repoParts = hyphenIndex === -1 ? parts : parts.slice(0, hyphenIndex);
|
|
137
|
+
if (repoParts.length < 3) return null;
|
|
138
|
+
|
|
139
|
+
const lastRepoPart = repoParts[repoParts.length - 1];
|
|
140
|
+
const atIndex = lastRepoPart.indexOf("@");
|
|
141
|
+
let rev: string | undefined;
|
|
142
|
+
let repoTail = lastRepoPart;
|
|
143
|
+
if (atIndex > 0) {
|
|
144
|
+
repoTail = lastRepoPart.slice(0, atIndex);
|
|
145
|
+
rev = lastRepoPart.slice(atIndex + 1) || undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
repoParts[repoParts.length - 1] = repoTail;
|
|
149
|
+
const repoName = repoParts.join("/");
|
|
150
|
+
|
|
151
|
+
if (hyphenIndex !== -1 && parts[hyphenIndex + 1] === "blob") {
|
|
152
|
+
const filePath = parts.slice(hyphenIndex + 2).join("/");
|
|
153
|
+
if (!filePath) return null;
|
|
154
|
+
return { type: "file", repoName, rev, filePath };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { type: "repo", repoName, rev };
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function safeParseJson<T>(content: string): T | null {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(content) as T;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function fetchGraphql<T>(
|
|
172
|
+
query: string,
|
|
173
|
+
variables: Record<string, unknown>,
|
|
174
|
+
timeout: number,
|
|
175
|
+
signal?: AbortSignal,
|
|
176
|
+
): Promise<T | null> {
|
|
177
|
+
const body = JSON.stringify({ query, variables });
|
|
178
|
+
const result = await loadPage(GRAPHQL_ENDPOINT, {
|
|
179
|
+
timeout,
|
|
180
|
+
headers: GRAPHQL_HEADERS,
|
|
181
|
+
method: "POST",
|
|
182
|
+
body,
|
|
183
|
+
signal,
|
|
184
|
+
});
|
|
185
|
+
if (!result.ok) return null;
|
|
186
|
+
|
|
187
|
+
const parsed = safeParseJson<{ data?: T; errors?: unknown }>(result.content);
|
|
188
|
+
if (!parsed?.data) return null;
|
|
189
|
+
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) return null;
|
|
190
|
+
return parsed.data;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isFileMatchResult(result: SearchResultItem): result is FileMatchResult {
|
|
194
|
+
return result.__typename === "FileMatch";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function isRepositoryResult(result: SearchResultItem): result is RepositoryResult {
|
|
198
|
+
return result.__typename === "Repository";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function formatRepoMarkdown(repo: SourcegraphRepository): string {
|
|
202
|
+
let md = `# ${repo.name}\n\n`;
|
|
203
|
+
if (repo.description) md += `${repo.description}\n\n`;
|
|
204
|
+
md += `**URL:** ${repo.url}\n`;
|
|
205
|
+
if (repo.defaultBranch?.name) md += `**Default branch:** ${repo.defaultBranch.name}\n`;
|
|
206
|
+
return md;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function renderRepo(
|
|
210
|
+
repoName: string,
|
|
211
|
+
timeout: number,
|
|
212
|
+
signal?: AbortSignal,
|
|
213
|
+
): Promise<{ content: string; ok: boolean }> {
|
|
214
|
+
const data = await fetchGraphql<RepoQueryData>(REPO_QUERY, { name: repoName }, timeout, signal);
|
|
215
|
+
if (!data?.repository) return { content: "", ok: false };
|
|
216
|
+
|
|
217
|
+
return { content: formatRepoMarkdown(data.repository), ok: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function renderFile(
|
|
221
|
+
repoName: string,
|
|
222
|
+
filePath: string,
|
|
223
|
+
rev: string,
|
|
224
|
+
timeout: number,
|
|
225
|
+
signal?: AbortSignal,
|
|
226
|
+
): Promise<{ content: string; ok: boolean }> {
|
|
227
|
+
const data = await fetchGraphql<RepoFileQueryData>(
|
|
228
|
+
REPO_FILE_QUERY,
|
|
229
|
+
{ name: repoName, path: filePath, rev },
|
|
230
|
+
timeout,
|
|
231
|
+
signal,
|
|
232
|
+
);
|
|
233
|
+
const repo = data?.repository;
|
|
234
|
+
const content = repo?.commit?.blob?.content ?? null;
|
|
235
|
+
if (!repo || content === null) return { content: "", ok: false };
|
|
236
|
+
|
|
237
|
+
let md = `${formatRepoMarkdown(repo)}\n`;
|
|
238
|
+
md += `**Path:** ${filePath}\n`;
|
|
239
|
+
md += `**Revision:** ${rev}\n\n`;
|
|
240
|
+
md += `---\n\n## File\n\n`;
|
|
241
|
+
md += "```text\n";
|
|
242
|
+
md += `${content}\n`;
|
|
243
|
+
md += "```\n";
|
|
244
|
+
return { content: md, ok: true };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function renderSearch(
|
|
248
|
+
query: string,
|
|
249
|
+
timeout: number,
|
|
250
|
+
signal?: AbortSignal,
|
|
251
|
+
): Promise<{ content: string; ok: boolean }> {
|
|
252
|
+
const data = await fetchGraphql<SearchQueryData>(SEARCH_QUERY, { query }, timeout, signal);
|
|
253
|
+
const resultsData = data?.search?.results;
|
|
254
|
+
if (!resultsData) return { content: "", ok: false };
|
|
255
|
+
const results = resultsData.results ?? [];
|
|
256
|
+
|
|
257
|
+
let md = "# Sourcegraph Search\n\n";
|
|
258
|
+
md += `**Query:** \`${query}\`\n`;
|
|
259
|
+
if (typeof resultsData?.matchCount === "number") {
|
|
260
|
+
md += `**Matches:** ${resultsData.matchCount}\n`;
|
|
261
|
+
}
|
|
262
|
+
if (typeof resultsData?.limitHit === "boolean") {
|
|
263
|
+
md += `**Limit hit:** ${resultsData.limitHit ? "yes" : "no"}\n`;
|
|
264
|
+
}
|
|
265
|
+
md += "\n";
|
|
266
|
+
|
|
267
|
+
if (!results || results.length === 0) {
|
|
268
|
+
md += "_No results._\n";
|
|
269
|
+
return { content: md, ok: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const maxResults = 10;
|
|
273
|
+
md += "## Results\n\n";
|
|
274
|
+
for (const result of results.slice(0, maxResults)) {
|
|
275
|
+
if (isFileMatchResult(result)) {
|
|
276
|
+
const repoName = result.repository?.name ?? "unknown";
|
|
277
|
+
const filePath = result.file?.path ?? "unknown";
|
|
278
|
+
md += `### ${repoName}/${filePath}\n\n`;
|
|
279
|
+
if (result.repository?.url) md += `**Repository:** ${result.repository.url}\n`;
|
|
280
|
+
if (result.file?.url) md += `**File:** ${result.file.url}\n`;
|
|
281
|
+
|
|
282
|
+
const lineMatches = result.lineMatches ?? [];
|
|
283
|
+
if (lineMatches.length > 0) {
|
|
284
|
+
md += "\n```text\n";
|
|
285
|
+
for (const line of lineMatches.slice(0, 5)) {
|
|
286
|
+
const preview = (line.preview ?? "").replace(/\n/g, " ").trim();
|
|
287
|
+
const lineNumber = line.lineNumber ?? 0;
|
|
288
|
+
md += `L${lineNumber}: ${preview}\n`;
|
|
289
|
+
}
|
|
290
|
+
md += "```\n\n";
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (isRepositoryResult(result)) {
|
|
296
|
+
const name = result.name ?? "unknown";
|
|
297
|
+
md += `### ${name}\n\n`;
|
|
298
|
+
if (result.url) md += `**Repository:** ${result.url}\n`;
|
|
299
|
+
md += "\n";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (results.length > maxResults) {
|
|
304
|
+
md += `... and ${results.length - maxResults} more results\n`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return { content: md, ok: true };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export const handleSourcegraph: SpecialHandler = async (
|
|
311
|
+
url: string,
|
|
312
|
+
timeout: number,
|
|
313
|
+
signal?: AbortSignal,
|
|
314
|
+
): Promise<RenderResult | null> => {
|
|
315
|
+
try {
|
|
316
|
+
const target = parseSourcegraphUrl(url);
|
|
317
|
+
if (!target) return null;
|
|
318
|
+
|
|
319
|
+
const fetchedAt = new Date().toISOString();
|
|
320
|
+
const notes = ["Fetched via Sourcegraph GraphQL API"];
|
|
321
|
+
|
|
322
|
+
switch (target.type) {
|
|
323
|
+
case "search": {
|
|
324
|
+
const result = await renderSearch(target.query, timeout, signal);
|
|
325
|
+
if (!result.ok) return null;
|
|
326
|
+
const output = finalizeOutput(result.content);
|
|
327
|
+
return {
|
|
328
|
+
url,
|
|
329
|
+
finalUrl: url,
|
|
330
|
+
contentType: "text/markdown",
|
|
331
|
+
method: "sourcegraph-search",
|
|
332
|
+
content: output.content,
|
|
333
|
+
fetchedAt,
|
|
334
|
+
truncated: output.truncated,
|
|
335
|
+
notes,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
case "file": {
|
|
339
|
+
const rev = target.rev ?? "HEAD";
|
|
340
|
+
const result = await renderFile(target.repoName, target.filePath, rev, timeout, signal);
|
|
341
|
+
if (!result.ok) return null;
|
|
342
|
+
const output = finalizeOutput(result.content);
|
|
343
|
+
return {
|
|
344
|
+
url,
|
|
345
|
+
finalUrl: url,
|
|
346
|
+
contentType: "text/markdown",
|
|
347
|
+
method: "sourcegraph-file",
|
|
348
|
+
content: output.content,
|
|
349
|
+
fetchedAt,
|
|
350
|
+
truncated: output.truncated,
|
|
351
|
+
notes,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
case "repo": {
|
|
355
|
+
const result = await renderRepo(target.repoName, timeout, signal);
|
|
356
|
+
if (!result.ok) return null;
|
|
357
|
+
const output = finalizeOutput(result.content);
|
|
358
|
+
return {
|
|
359
|
+
url,
|
|
360
|
+
finalUrl: url,
|
|
361
|
+
contentType: "text/markdown",
|
|
362
|
+
method: "sourcegraph-repo",
|
|
363
|
+
content: output.content,
|
|
364
|
+
fetchedAt,
|
|
365
|
+
truncated: output.truncated,
|
|
366
|
+
notes,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} catch {}
|
|
371
|
+
|
|
372
|
+
return null;
|
|
373
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { RenderResult, SpecialHandler } from "./types";
|
|
2
|
+
import { finalizeOutput, htmlToBasicMarkdown, loadPage } from "./types";
|
|
3
|
+
|
|
4
|
+
interface SpdxCrossRef {
|
|
5
|
+
url?: string;
|
|
6
|
+
isValid?: boolean;
|
|
7
|
+
isLive?: boolean;
|
|
8
|
+
match?: string;
|
|
9
|
+
order?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SpdxLicense {
|
|
13
|
+
licenseId: string;
|
|
14
|
+
name: string;
|
|
15
|
+
isOsiApproved?: boolean;
|
|
16
|
+
isFsfLibre?: boolean;
|
|
17
|
+
licenseText?: string;
|
|
18
|
+
licenseTextHtml?: string;
|
|
19
|
+
seeAlso?: string[];
|
|
20
|
+
crossRef?: SpdxCrossRef[];
|
|
21
|
+
comment?: string;
|
|
22
|
+
licenseComments?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatYesNo(value?: boolean): string {
|
|
26
|
+
if (value === true) return "Yes";
|
|
27
|
+
if (value === false) return "No";
|
|
28
|
+
return "Unknown";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function collectCrossReferences(license: SpdxLicense): string[] {
|
|
32
|
+
const ordered = (license.crossRef ?? [])
|
|
33
|
+
.filter((ref) => ref.url)
|
|
34
|
+
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
35
|
+
.map((ref) => ref.url as string);
|
|
36
|
+
|
|
37
|
+
const seeAlso = (license.seeAlso ?? []).filter((url) => url);
|
|
38
|
+
const combined = [...ordered, ...seeAlso];
|
|
39
|
+
return combined.filter((url, index) => combined.indexOf(url) === index);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Handle SPDX license URLs via SPDX JSON API
|
|
44
|
+
*/
|
|
45
|
+
export const handleSpdx: SpecialHandler = async (
|
|
46
|
+
url: string,
|
|
47
|
+
timeout: number,
|
|
48
|
+
signal?: AbortSignal,
|
|
49
|
+
): Promise<RenderResult | null> => {
|
|
50
|
+
try {
|
|
51
|
+
const parsed = new URL(url);
|
|
52
|
+
if (parsed.hostname !== "spdx.org" && parsed.hostname !== "www.spdx.org") return null;
|
|
53
|
+
|
|
54
|
+
const match = parsed.pathname.match(/^\/licenses\/([^/]+?)(?:\.html)?\/?$/i);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
|
|
57
|
+
const licenseId = decodeURIComponent(match[1]);
|
|
58
|
+
if (!licenseId) return null;
|
|
59
|
+
|
|
60
|
+
const fetchedAt = new Date().toISOString();
|
|
61
|
+
const apiUrl = `https://spdx.org/licenses/${encodeURIComponent(licenseId)}.json`;
|
|
62
|
+
const result = await loadPage(apiUrl, {
|
|
63
|
+
timeout,
|
|
64
|
+
headers: { Accept: "application/json" },
|
|
65
|
+
signal,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!result.ok) return null;
|
|
69
|
+
|
|
70
|
+
let license: SpdxLicense;
|
|
71
|
+
try {
|
|
72
|
+
license = JSON.parse(result.content);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const title = license.name || license.licenseId || licenseId;
|
|
78
|
+
let md = `# ${title}\n\n`;
|
|
79
|
+
|
|
80
|
+
md += `**License ID:** ${license.licenseId ? `\`${license.licenseId}\`` : `\`${licenseId}\``}\n`;
|
|
81
|
+
md += `**OSI Approved:** ${formatYesNo(license.isOsiApproved)}\n`;
|
|
82
|
+
md += `**FSF Libre:** ${formatYesNo(license.isFsfLibre)}\n`;
|
|
83
|
+
|
|
84
|
+
const description = license.licenseComments ?? license.comment;
|
|
85
|
+
if (description) {
|
|
86
|
+
md += `\n## Description\n\n${description}\n`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const crossReferences = collectCrossReferences(license);
|
|
90
|
+
if (crossReferences.length) {
|
|
91
|
+
md += `\n## Cross References\n\n`;
|
|
92
|
+
for (const ref of crossReferences) {
|
|
93
|
+
md += `- ${ref}\n`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const licenseText = license.licenseText
|
|
98
|
+
? license.licenseText
|
|
99
|
+
: license.licenseTextHtml
|
|
100
|
+
? htmlToBasicMarkdown(license.licenseTextHtml)
|
|
101
|
+
: null;
|
|
102
|
+
|
|
103
|
+
if (licenseText) {
|
|
104
|
+
md += `\n## License Text\n\n\`\`\`\n${licenseText}\n\`\`\`\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const output = finalizeOutput(md);
|
|
108
|
+
return {
|
|
109
|
+
url,
|
|
110
|
+
finalUrl: url,
|
|
111
|
+
contentType: "text/markdown",
|
|
112
|
+
method: "spdx-api",
|
|
113
|
+
content: output.content,
|
|
114
|
+
fetchedAt,
|
|
115
|
+
truncated: output.truncated,
|
|
116
|
+
notes: ["Fetched via SPDX license API"],
|
|
117
|
+
};
|
|
118
|
+
} catch {}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spotify URL handler for podcasts, tracks, albums, and playlists
|
|
3
|
+
*
|
|
4
|
+
* Uses oEmbed API and Open Graph metadata to extract information
|
|
5
|
+
* from Spotify URLs without requiring authentication.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { SpecialHandler } from "./types";
|
|
9
|
+
import { finalizeOutput, loadPage } from "./types";
|
|
10
|
+
|
|
11
|
+
interface SpotifyOEmbedResponse {
|
|
12
|
+
title?: string;
|
|
13
|
+
thumbnail_url?: string;
|
|
14
|
+
provider_name?: string;
|
|
15
|
+
html?: string;
|
|
16
|
+
width?: number;
|
|
17
|
+
height?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OpenGraphData {
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
audio?: string;
|
|
24
|
+
image?: string;
|
|
25
|
+
type?: string;
|
|
26
|
+
duration?: string;
|
|
27
|
+
album?: string;
|
|
28
|
+
musician?: string;
|
|
29
|
+
artist?: string;
|
|
30
|
+
releaseDate?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Parse Open Graph meta tags from HTML
|
|
35
|
+
*/
|
|
36
|
+
function parseOpenGraph(html: string): OpenGraphData {
|
|
37
|
+
const og: OpenGraphData = {};
|
|
38
|
+
|
|
39
|
+
const metaPattern = /<meta\s+(?:property|name)="([^"]+)"\s+content="([^"]*)"[^>]*>/gi;
|
|
40
|
+
let match: RegExpExecArray | null = null;
|
|
41
|
+
|
|
42
|
+
while (true) {
|
|
43
|
+
match = metaPattern.exec(html);
|
|
44
|
+
if (match === null) break;
|
|
45
|
+
const [, property, content] = match;
|
|
46
|
+
|
|
47
|
+
if (property === "og:title") og.title = content;
|
|
48
|
+
else if (property === "og:description") og.description = content;
|
|
49
|
+
else if (property === "og:audio") og.audio = content;
|
|
50
|
+
else if (property === "og:image") og.image = content;
|
|
51
|
+
else if (property === "og:type") og.type = content;
|
|
52
|
+
else if (property === "music:duration") og.duration = content;
|
|
53
|
+
else if (property === "music:album") og.album = content;
|
|
54
|
+
else if (property === "music:musician") og.musician = content;
|
|
55
|
+
else if (property === "music:release_date") og.releaseDate = content;
|
|
56
|
+
else if (property === "twitter:audio:artist_name") og.artist = content;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return og;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Determine content type from URL path
|
|
64
|
+
*/
|
|
65
|
+
function getContentType(url: string): string | null {
|
|
66
|
+
if (url.includes("/episode/")) return "podcast-episode";
|
|
67
|
+
if (url.includes("/show/")) return "podcast-show";
|
|
68
|
+
if (url.includes("/track/")) return "track";
|
|
69
|
+
if (url.includes("/album/")) return "album";
|
|
70
|
+
if (url.includes("/playlist/")) return "playlist";
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Format duration from seconds
|
|
76
|
+
*/
|
|
77
|
+
function formatDuration(seconds: string | undefined): string | null {
|
|
78
|
+
if (!seconds) return null;
|
|
79
|
+
const num = parseInt(seconds, 10);
|
|
80
|
+
if (Number.isNaN(num)) return null;
|
|
81
|
+
|
|
82
|
+
const hours = Math.floor(num / 3600);
|
|
83
|
+
const minutes = Math.floor((num % 3600) / 60);
|
|
84
|
+
const secs = num % 60;
|
|
85
|
+
|
|
86
|
+
if (hours > 0) {
|
|
87
|
+
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
|
|
88
|
+
}
|
|
89
|
+
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Format output based on content type and available metadata
|
|
94
|
+
*/
|
|
95
|
+
function formatOutput(contentType: string, oEmbed: SpotifyOEmbedResponse, og: OpenGraphData, url: string): string {
|
|
96
|
+
const sections: string[] = [];
|
|
97
|
+
|
|
98
|
+
// Title
|
|
99
|
+
const title = og.title || oEmbed.title || "Unknown";
|
|
100
|
+
sections.push(`# ${title}\n`);
|
|
101
|
+
|
|
102
|
+
// Type
|
|
103
|
+
sections.push(`**Type**: ${contentType}\n`);
|
|
104
|
+
|
|
105
|
+
// Description
|
|
106
|
+
if (og.description) {
|
|
107
|
+
sections.push(`**Description**: ${og.description}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Content-specific metadata
|
|
111
|
+
if (contentType === "track" || contentType === "podcast-episode") {
|
|
112
|
+
if (og.artist || og.musician) {
|
|
113
|
+
sections.push(`**Artist**: ${og.artist || og.musician}\n`);
|
|
114
|
+
}
|
|
115
|
+
if (og.album) {
|
|
116
|
+
sections.push(`**Album**: ${og.album}\n`);
|
|
117
|
+
}
|
|
118
|
+
if (og.duration) {
|
|
119
|
+
const formatted = formatDuration(og.duration);
|
|
120
|
+
if (formatted) {
|
|
121
|
+
sections.push(`**Duration**: ${formatted}\n`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (contentType === "album" && og.releaseDate) {
|
|
127
|
+
sections.push(`**Release Date**: ${og.releaseDate}\n`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Note about limited information
|
|
131
|
+
sections.push("\n---\n");
|
|
132
|
+
if (contentType === "playlist") {
|
|
133
|
+
sections.push(
|
|
134
|
+
"**Note**: Playlist details (tracks, creator, follower count) require authentication. " +
|
|
135
|
+
"Only basic metadata is available without Spotify API credentials.\n",
|
|
136
|
+
);
|
|
137
|
+
} else if (contentType === "album") {
|
|
138
|
+
sections.push(
|
|
139
|
+
"**Note**: Track listing and detailed album information require authentication. " +
|
|
140
|
+
"Only basic metadata is available without Spotify API credentials.\n",
|
|
141
|
+
);
|
|
142
|
+
} else if (contentType === "podcast-show") {
|
|
143
|
+
sections.push(
|
|
144
|
+
"**Note**: Episode listing and detailed show information require authentication. " +
|
|
145
|
+
"Only basic metadata is available without Spotify API credentials.\n",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sections.push(`**URL**: ${url}\n`);
|
|
150
|
+
|
|
151
|
+
if (oEmbed.thumbnail_url) {
|
|
152
|
+
sections.push(`**Thumbnail**: ${oEmbed.thumbnail_url}\n`);
|
|
153
|
+
} else if (og.image) {
|
|
154
|
+
sections.push(`**Image**: ${og.image}\n`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return sections.join("\n");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const handleSpotify: SpecialHandler = async (url: string, timeout: number, signal?: AbortSignal) => {
|
|
161
|
+
// Check if this is a Spotify URL
|
|
162
|
+
if (!url.includes("open.spotify.com/")) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const contentType = getContentType(url);
|
|
167
|
+
if (!contentType) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const notes: string[] = [];
|
|
172
|
+
let oEmbedData: SpotifyOEmbedResponse = {};
|
|
173
|
+
let ogData: OpenGraphData = {};
|
|
174
|
+
|
|
175
|
+
// Fetch oEmbed data
|
|
176
|
+
try {
|
|
177
|
+
const oEmbedUrl = `https://open.spotify.com/oembed?url=${encodeURIComponent(url)}`;
|
|
178
|
+
const response = await loadPage(oEmbedUrl, { timeout, signal });
|
|
179
|
+
|
|
180
|
+
if (response.ok) {
|
|
181
|
+
oEmbedData = JSON.parse(response.content) as SpotifyOEmbedResponse;
|
|
182
|
+
notes.push("Retrieved metadata via Spotify oEmbed API");
|
|
183
|
+
} else {
|
|
184
|
+
notes.push(`oEmbed API returned status ${response.status || "error"}`);
|
|
185
|
+
}
|
|
186
|
+
} catch (err) {
|
|
187
|
+
notes.push(`Failed to fetch oEmbed data: ${err instanceof Error ? err.message : String(err)}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Fetch page HTML for Open Graph metadata
|
|
191
|
+
try {
|
|
192
|
+
const pageResponse = await loadPage(url, { timeout, signal });
|
|
193
|
+
|
|
194
|
+
if (pageResponse.ok) {
|
|
195
|
+
ogData = parseOpenGraph(pageResponse.content);
|
|
196
|
+
notes.push("Parsed Open Graph metadata from page HTML");
|
|
197
|
+
} else {
|
|
198
|
+
notes.push(`Page fetch returned status ${pageResponse.status || "error"}`);
|
|
199
|
+
}
|
|
200
|
+
} catch (err) {
|
|
201
|
+
notes.push(`Failed to fetch page HTML: ${err instanceof Error ? err.message : String(err)}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Format output
|
|
205
|
+
const output = formatOutput(contentType, oEmbedData, ogData, url);
|
|
206
|
+
const { content, truncated } = finalizeOutput(output);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
url,
|
|
210
|
+
finalUrl: url,
|
|
211
|
+
contentType: "text/markdown",
|
|
212
|
+
method: "spotify",
|
|
213
|
+
content,
|
|
214
|
+
fetchedAt: new Date().toISOString(),
|
|
215
|
+
truncated,
|
|
216
|
+
notes,
|
|
217
|
+
};
|
|
218
|
+
};
|