@tduyng/pi-web 0.0.0-stage → 0.0.1
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/LICENSE +178 -0
- package/README.md +38 -2
- package/package.json +42 -4
- package/src/backends.ts +539 -0
- package/src/index.ts +151 -0
- package/src/x-post.ts +164 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
Use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
Copyright 2026 tduyng
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tduyng/pi-web
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Web search and fetch for pi. The extension registers `web_search` and `web_fetch`, and registers the keyless Exa MCP server through pi's own MCP client: `pi.registerMcpServer('exa', ...)`, exposure `codemode`, listed in `/mcp`. The tools call that server's two tools, `web_search_exa` and `web_fetch_exa`, through `ctx.executeTool` and carry no MCP SDK of their own. The server takes a 60 second request timeout, which covers the connect handshake, and pi retries only HTTP 408, 429 and 5xx: a handshake that overruns marks the server failed for the session and `/mcp` shows why.
|
|
4
|
+
|
|
5
|
+
## Backends
|
|
6
|
+
|
|
7
|
+
`web_search` takes an array of up to six queries, one call each, and groups the results under the query that produced them. The primary backend is keyless Exa MCP, with DuckDuckGo HTML behind it. The Exa leg goes through the registered `exa` server, so an unconnected or `--no-mcp` session lands in the same DuckDuckGo fallback. x.com and twitter.com URLs go to Fixupx instead, which answers a page X refuses. It returns a post together with the whole long-form Article it links. An `x.com/i/article/` URL carries an article id rather than a post id, so the reply names the post URL to fetch.
|
|
8
|
+
|
|
9
|
+
`source: arxiv` searches the arXiv Atom API instead, sorted by submission date, and returns abstracts with authors and dates. A miss falls back to the open web, because arXiv indexes preprints and nothing else.
|
|
10
|
+
|
|
11
|
+
`source: hn` searches Hacker News through Algolia's index, which needs no account, and returns each thread's points and comment count, the only measure of how much discussion a subject has. A comment hit is reported as the thread that holds it, with the comment's first 300 characters. A miss falls back to the open web.
|
|
12
|
+
|
|
13
|
+
Search returns excerpts, so the result ends with the ranked URLs to read. Without that list the model answers from a snippet.
|
|
14
|
+
|
|
15
|
+
## Typed results
|
|
16
|
+
|
|
17
|
+
`web_search` declares an output schema, so a codemode script gets the answer as data in `structuredContent.groups`, one entry per query carrying `title`, `url` and `snippet`, plus `published` and `authors` where the backend has them, and `points` and `comments` on Hacker News. A script can compare values across results without parsing text. Exa's answer is prose: `Title:`, `URL:`, `Published:`, `Author:` and `Highlights:` blocks with a rule between them, which `parseExa` reads back into that shape, so the typed view holds for every backend. The model still receives the text, and the data is not sent to it a second time.
|
|
18
|
+
|
|
19
|
+
A backend that answers with nothing is a miss, and the next one runs. Before, an empty Exa answer ended the search and DuckDuckGo never got a turn.
|
|
20
|
+
|
|
21
|
+
## DuckDuckGo queue
|
|
22
|
+
|
|
23
|
+
DuckDuckGo answers a burst of requests from one address with a bot check, and keeps that address locked out for 10 to 15 minutes, with each retry extending it. Measured on 2026-10-26: six requests at once got three pages of results and three challenge pages at 14 KB apiece, and the challenge arrives as HTTP 202 as often as a 403. Searches therefore run one at a time, two seconds apart. A bot check pauses the provider for five minutes, doubling to an hour while it keeps answering that way, and a paused search fails at once instead of waiting, so the batch reports that query as failed rather than holding the tool open.
|
|
24
|
+
|
|
25
|
+
## Links in a fetched page
|
|
26
|
+
|
|
27
|
+
Exa's extraction returns plain text. Measured on 2026-09-27, `arxiv.org/abs/1706.03762` came back with no links at all, so a citation cannot be read out of the page that cites it. Jina Reader returns Markdown with the links intact, 295 of them on a Hacker News thread, but it blocks a domain that hammered it: `arxiv.org` answered 403 until 2026-09-27. Exa answers first for that reason, and the links survive only when Jina does. Follow a citation by searching for the paper's title.
|
|
28
|
+
|
|
29
|
+
## arXiv query form
|
|
30
|
+
|
|
31
|
+
arXiv ANDs nothing on its own. Measured on the live API: `typed decision model` returns 1,470,125 rows because the words are read as alternatives, and `all:"typed decision model"` returns 3 because the phrase is too strict. So a plain query has its words ANDed one at a time, which is the only form that ranks usefully. A query already written with arXiv field prefixes (`ti:`, `cat:`, `au:`, and the rest) is passed through untouched.
|
|
32
|
+
|
|
33
|
+
## Reddit and X
|
|
34
|
+
|
|
35
|
+
Neither has a keyless search. Measured on 2026-09-27: `reddit.com/search.json` answers 403 on `www`, `old` and `api`, with a browser user agent and with a plain one, and X has no search without a key. DuckDuckGo's `site:` operator does return both, so a query that names the forum reaches those threads. The web backend finds them when a query names them: `Hacker News and Reddit discussion of typed decision models` returned a `news.ycombinator.com` host, while the bare query did not.
|
|
36
|
+
|
|
37
|
+
## Quota
|
|
38
|
+
|
|
39
|
+
The keyless Exa MCP endpoint needs no account. Set `EXA_API_KEY` to send your own key. The extension reads it at load and puts it on the registered server URL as the `exaApiKey` query parameter. Exa's free plan includes 1,000 requests per month. A batch of six queries costs six of them, so they run three at a time.
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-web",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Keyless web search and fetch for pi: batched queries, arXiv preprints, Exa MCP with DuckDuckGo and Jina Reader fallbacks, zero dependencies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"pi-coding-agent",
|
|
10
|
+
"web-search",
|
|
11
|
+
"exa"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://gitlab.com/tduyng/pi-agent/-/tree/main/extensions/pi-web#readme",
|
|
14
|
+
"bugs": "https://gitlab.com/tduyng/pi-agent/-/issues",
|
|
15
|
+
"license": "Apache-2.0",
|
|
16
|
+
"author": "tduyng",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://gitlab.com/tduyng/pi-agent.git",
|
|
20
|
+
"directory": "extensions/pi-web"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"!src/**/*.test.ts",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./src/index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"ata-validator": "^1.62.1"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/backends.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { FIXUPX_STATUS_URL, isXUrl, X_ARTICLE_ID_ERROR, xArticleId, xPostId, xPostMarkdown } from './x-post.js'
|
|
2
|
+
|
|
3
|
+
const SEARCH_OBJECTIVE =
|
|
4
|
+
'Rank pages that answer the query directly. Prefer primary sources, official documentation, and canonical pages. Skip aggregators and SEO mirrors.'
|
|
5
|
+
const ARXIV_API = 'https://export.arxiv.org/api/query'
|
|
6
|
+
const HN_SEARCH = 'https://hn.algolia.com/api/v1/search'
|
|
7
|
+
const TIMEOUT_MS = 20_000
|
|
8
|
+
export const MAX_QUERIES = 6
|
|
9
|
+
const CONCURRENCY = 3
|
|
10
|
+
const ABSTRACT_CHARS = 700
|
|
11
|
+
const READ_LINKS = 30
|
|
12
|
+
const COMMENT_CHARS = 300
|
|
13
|
+
|
|
14
|
+
export const SOURCES = ['web', 'arxiv', 'hn'] as const
|
|
15
|
+
export type Source = (typeof SOURCES)[number]
|
|
16
|
+
|
|
17
|
+
export type Fetch = typeof fetch
|
|
18
|
+
|
|
19
|
+
export interface Hit {
|
|
20
|
+
text: string
|
|
21
|
+
backend: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SearchResult = {
|
|
25
|
+
title: string
|
|
26
|
+
url: string
|
|
27
|
+
snippet: string
|
|
28
|
+
published?: string
|
|
29
|
+
authors?: string[]
|
|
30
|
+
points?: number
|
|
31
|
+
comments?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type SearchHit = Hit & { results: SearchResult[] }
|
|
35
|
+
|
|
36
|
+
export type SearchGroup = {
|
|
37
|
+
query: string
|
|
38
|
+
backend: string
|
|
39
|
+
results: SearchResult[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ExaArgs =
|
|
43
|
+
| { query: string; numResults: number; objective: string }
|
|
44
|
+
| { urls: string[]; maxCharacters: number }
|
|
45
|
+
|
|
46
|
+
export type ExaCall = (name: string, args: ExaArgs, signal?: AbortSignal) => Promise<string>
|
|
47
|
+
|
|
48
|
+
function withTimeout(signal?: AbortSignal): AbortSignal {
|
|
49
|
+
const timeout = AbortSignal.timeout(TIMEOUT_MS)
|
|
50
|
+
return signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ENTITIES = new Map<string, string>([
|
|
54
|
+
['amp', '&'],
|
|
55
|
+
['lt', '<'],
|
|
56
|
+
['gt', '>'],
|
|
57
|
+
['quot', '"'],
|
|
58
|
+
['apos', "'"],
|
|
59
|
+
['nbsp', ' ']
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
export function decodeEntities(text: string): string {
|
|
63
|
+
return text.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, (whole, entity: string) => {
|
|
64
|
+
const known = ENTITIES.get(entity.toLowerCase())
|
|
65
|
+
if (known !== undefined) return known
|
|
66
|
+
const code = entity.startsWith('#x')
|
|
67
|
+
? Number.parseInt(entity.slice(2), 16)
|
|
68
|
+
: Number.parseInt(entity.slice(1), 10)
|
|
69
|
+
return Number.isFinite(code) && code < 0x110_000 ? String.fromCodePoint(code) : whole
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function clean(html: string): string {
|
|
74
|
+
return decodeEntities(html.replace(/<[^>]+>/g, ''))
|
|
75
|
+
.replace(/\s+/g, ' ')
|
|
76
|
+
.trim()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ddgTarget(href: string): string {
|
|
80
|
+
const raw = href.replace(/&/g, '&')
|
|
81
|
+
const url = new URL(raw.startsWith('//') ? `https:${raw}` : raw)
|
|
82
|
+
return url.searchParams.get('uddg') ?? url.href
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface DdgResult {
|
|
86
|
+
title: string
|
|
87
|
+
url: string
|
|
88
|
+
snippet: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function parseDdg(html: string): DdgResult[] {
|
|
92
|
+
const out: DdgResult[] = []
|
|
93
|
+
for (const chunk of html.split('class="result__a"').slice(1)) {
|
|
94
|
+
const link = /^\s*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/.exec(chunk)
|
|
95
|
+
if (!link?.[1] || link[1].includes('duckduckgo.com/y.js')) continue
|
|
96
|
+
const snippet = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/.exec(chunk)
|
|
97
|
+
out.push({ title: clean(link[2] ?? ''), url: ddgTarget(link[1]), snippet: clean(snippet?.[1] ?? '') })
|
|
98
|
+
}
|
|
99
|
+
return out
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const DDG_GAP_MS = 2_000
|
|
103
|
+
const DDG_BACKOFF_MS = 5 * 60_000
|
|
104
|
+
const DDG_BACKOFF_MAX_MS = 60 * 60_000
|
|
105
|
+
|
|
106
|
+
export const ddgGate = { gapMs: DDG_GAP_MS, freeAt: 0, backoffMs: 0, blockedUntil: 0 }
|
|
107
|
+
|
|
108
|
+
async function sleepUntil(at: number, signal?: AbortSignal): Promise<void> {
|
|
109
|
+
const wait = at - Date.now()
|
|
110
|
+
if (wait <= 0) return
|
|
111
|
+
await new Promise<void>((resolve, reject) => {
|
|
112
|
+
const timer = setTimeout(() => {
|
|
113
|
+
signal?.removeEventListener('abort', onAbort)
|
|
114
|
+
resolve()
|
|
115
|
+
}, wait)
|
|
116
|
+
const onAbort = () => {
|
|
117
|
+
clearTimeout(timer)
|
|
118
|
+
reject(new Error('aborted while waiting its turn'))
|
|
119
|
+
}
|
|
120
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function ddgPause(reason: string): never {
|
|
125
|
+
ddgGate.backoffMs = Math.min(ddgGate.backoffMs ? ddgGate.backoffMs * 2 : DDG_BACKOFF_MS, DDG_BACKOFF_MAX_MS)
|
|
126
|
+
ddgGate.blockedUntil = Date.now() + ddgGate.backoffMs
|
|
127
|
+
throw new Error(`bot check ${reason}, paused for ${ddgGate.backoffMs / 60_000} min`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function ddgSearch(
|
|
131
|
+
query: string,
|
|
132
|
+
numResults: number,
|
|
133
|
+
signal?: AbortSignal,
|
|
134
|
+
f: Fetch = fetch
|
|
135
|
+
): Promise<SearchHit> {
|
|
136
|
+
signal?.throwIfAborted()
|
|
137
|
+
const blocked = ddgGate.blockedUntil - Date.now()
|
|
138
|
+
if (blocked > 0) throw new Error(`paused after a bot check, ${Math.ceil(blocked / 60_000)} min left`)
|
|
139
|
+
const start = Math.max(Date.now(), ddgGate.freeAt)
|
|
140
|
+
ddgGate.freeAt = start + ddgGate.gapMs
|
|
141
|
+
await sleepUntil(start, signal)
|
|
142
|
+
const res = await f(`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`, {
|
|
143
|
+
headers: { 'user-agent': 'Mozilla/5.0' },
|
|
144
|
+
signal: withTimeout(signal)
|
|
145
|
+
})
|
|
146
|
+
if (res.status === 202 || res.status === 403 || res.status === 429) ddgPause(`answered HTTP ${res.status}`)
|
|
147
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
148
|
+
const html = await res.text()
|
|
149
|
+
if (html.includes('duckduckgo.com/anomaly.js')) ddgPause('page')
|
|
150
|
+
const results = parseDdg(html).slice(0, numResults)
|
|
151
|
+
if (!results.length) throw new Error('no results')
|
|
152
|
+
ddgGate.backoffMs = 0
|
|
153
|
+
return {
|
|
154
|
+
text: results.map((r) => `Title: ${r.title}\nURL: ${r.url}\n${r.snippet}`).join('\n\n'),
|
|
155
|
+
backend: 'duckduckgo',
|
|
156
|
+
results
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const truncatedAt = (maxCharacters: number) => `[truncated at ${maxCharacters} chars]`
|
|
161
|
+
|
|
162
|
+
function markTruncation(pages: string, maxCharacters: number): string {
|
|
163
|
+
return pages
|
|
164
|
+
.split(/^(?=# .*\nURL: \S+\n)/m)
|
|
165
|
+
.map((page) => {
|
|
166
|
+
const header = page.indexOf('\n\n')
|
|
167
|
+
const body = header < 0 ? '' : page.slice(header + 2).trimEnd()
|
|
168
|
+
return body.length < maxCharacters ? page.trimEnd() : `${page.trimEnd()}\n${truncatedAt(maxCharacters)}`
|
|
169
|
+
})
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join('\n\n')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function fetchEach(
|
|
175
|
+
urls: string[],
|
|
176
|
+
maxCharacters: number,
|
|
177
|
+
signal: AbortSignal | undefined,
|
|
178
|
+
run: (url: string) => Promise<string>
|
|
179
|
+
): Promise<string> {
|
|
180
|
+
let failed = 0
|
|
181
|
+
const pages = await Promise.all(
|
|
182
|
+
urls.map(async (url) => {
|
|
183
|
+
try {
|
|
184
|
+
const page = await run(url)
|
|
185
|
+
return page.length > maxCharacters
|
|
186
|
+
? `${page.slice(0, maxCharacters)}\n${truncatedAt(maxCharacters)}`
|
|
187
|
+
: page
|
|
188
|
+
} catch (err) {
|
|
189
|
+
failed++
|
|
190
|
+
return `URL: ${url}\nError: ${err instanceof Error ? err.message : String(err)}`
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
)
|
|
194
|
+
if (failed === urls.length) throw new Error(pages.join('; '))
|
|
195
|
+
return pages.join('\n\n---\n\n')
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export async function jinaFetch(
|
|
199
|
+
urls: string[],
|
|
200
|
+
maxCharacters: number,
|
|
201
|
+
signal?: AbortSignal,
|
|
202
|
+
f: Fetch = fetch
|
|
203
|
+
): Promise<string> {
|
|
204
|
+
return fetchEach(urls, maxCharacters, signal, async (url) => {
|
|
205
|
+
const res = await f(`https://r.jina.ai/${url}`, { signal: withTimeout(signal) })
|
|
206
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
207
|
+
return res.text()
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function xFetch(
|
|
212
|
+
urls: string[],
|
|
213
|
+
maxCharacters: number,
|
|
214
|
+
signal: AbortSignal | undefined,
|
|
215
|
+
f: Fetch
|
|
216
|
+
): Promise<string> {
|
|
217
|
+
return fetchEach(urls, maxCharacters, signal, async (url) => {
|
|
218
|
+
if (xArticleId(url)) throw new Error(X_ARTICLE_ID_ERROR)
|
|
219
|
+
const postId = xPostId(url)
|
|
220
|
+
if (!postId) throw new Error('no X post id in this URL')
|
|
221
|
+
const res = await f(`${FIXUPX_STATUS_URL}${postId}`, { signal: withTimeout(signal) })
|
|
222
|
+
if (!res.ok) throw new Error(`Fixupx HTTP ${res.status}`)
|
|
223
|
+
return xPostMarkdown(await res.text())
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function firstOf<T extends { text: string }>(
|
|
228
|
+
signal: AbortSignal | undefined,
|
|
229
|
+
backends: [string, () => Promise<T>][]
|
|
230
|
+
): Promise<T & Hit> {
|
|
231
|
+
const errors: string[] = []
|
|
232
|
+
for (const [backend, run] of backends) {
|
|
233
|
+
try {
|
|
234
|
+
const hit = await run()
|
|
235
|
+
if (hit.text.trim()) return { ...hit, backend }
|
|
236
|
+
errors.push(`${backend}: empty answer`)
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (signal?.aborted) throw err
|
|
239
|
+
errors.push(`${backend}: ${err instanceof Error ? err.message : String(err)}`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error(errors.join('; '))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function exaField(block: string, name: string): string | undefined {
|
|
246
|
+
const value = new RegExp(`^${name}: (.*)$`, 'm').exec(block)?.[1]?.trim()
|
|
247
|
+
return value && value !== 'N/A' ? value : undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function parseExa(text: string): SearchResult[] {
|
|
251
|
+
return text.split(/\n-{3,}\n/).flatMap((block): SearchResult[] => {
|
|
252
|
+
const title = exaField(block, 'Title')
|
|
253
|
+
const url = exaField(block, 'URL')
|
|
254
|
+
if (!title || !url) return []
|
|
255
|
+
const highlights = block.indexOf('Highlights:')
|
|
256
|
+
const result: SearchResult = {
|
|
257
|
+
title,
|
|
258
|
+
url,
|
|
259
|
+
snippet: highlights < 0 ? '' : block.slice(highlights + 'Highlights:'.length).trim()
|
|
260
|
+
}
|
|
261
|
+
const published = exaField(block, 'Published')
|
|
262
|
+
if (published) result.published = published
|
|
263
|
+
const author = exaField(block, 'Author')
|
|
264
|
+
if (author) result.authors = [author]
|
|
265
|
+
return [result]
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function search(
|
|
270
|
+
query: string,
|
|
271
|
+
numResults: number,
|
|
272
|
+
source: Source,
|
|
273
|
+
signal: AbortSignal | undefined,
|
|
274
|
+
exa: ExaCall,
|
|
275
|
+
f: Fetch
|
|
276
|
+
): Promise<SearchHit> {
|
|
277
|
+
const special = SOURCE_SEARCH[source]
|
|
278
|
+
if (special) {
|
|
279
|
+
try {
|
|
280
|
+
return await special(query, numResults, signal, f)
|
|
281
|
+
} catch (err) {
|
|
282
|
+
if (signal?.aborted) throw err
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return firstOf<{ text: string; results: SearchResult[] }>(signal, [
|
|
286
|
+
[
|
|
287
|
+
'exa',
|
|
288
|
+
async () => {
|
|
289
|
+
const text = await exa('web_search_exa', { query, numResults, objective: SEARCH_OBJECTIVE }, signal)
|
|
290
|
+
return { text, results: parseExa(text) }
|
|
291
|
+
}
|
|
292
|
+
],
|
|
293
|
+
['duckduckgo', () => ddgSearch(query, numResults, signal, f)]
|
|
294
|
+
])
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface Paper {
|
|
298
|
+
title: string
|
|
299
|
+
url: string
|
|
300
|
+
published: string
|
|
301
|
+
authors: string[]
|
|
302
|
+
abstract: string
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function atomText(entry: string, tag: string): string {
|
|
306
|
+
const found = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`).exec(entry)
|
|
307
|
+
return found?.[1]
|
|
308
|
+
? decodeEntities(found[1].replace(/<[^>]+>/g, ' '))
|
|
309
|
+
.replace(/\s+/g, ' ')
|
|
310
|
+
.trim()
|
|
311
|
+
: ''
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function parseArxiv(xml: string): Paper[] {
|
|
315
|
+
const papers: Paper[] = []
|
|
316
|
+
for (const entry of xml.split('<entry>').slice(1)) {
|
|
317
|
+
const title = atomText(entry, 'title')
|
|
318
|
+
const url = atomText(entry, 'id')
|
|
319
|
+
if (!title || !url) continue
|
|
320
|
+
papers.push({
|
|
321
|
+
title,
|
|
322
|
+
url: url.replace('http://', 'https://'),
|
|
323
|
+
published: atomText(entry, 'published').slice(0, 10),
|
|
324
|
+
authors: [...entry.matchAll(/<name>([\s\S]*?)<\/name>/g)].map((m) => m[1]?.trim() ?? '').filter(Boolean),
|
|
325
|
+
abstract: atomText(entry, 'summary').slice(0, ABSTRACT_CHARS)
|
|
326
|
+
})
|
|
327
|
+
}
|
|
328
|
+
return papers
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export function arxivQuery(query: string): string {
|
|
332
|
+
if (/(?:^|\s)(?:all|ti|abs|au|co|jr|cat|rn|id|src)\s*:/i.test(query)) return query
|
|
333
|
+
return query
|
|
334
|
+
.split(/\s+/)
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
.map((term) => `all:"${term}"`)
|
|
337
|
+
.join(' AND ')
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export async function arxivSearch(
|
|
341
|
+
query: string,
|
|
342
|
+
numResults: number,
|
|
343
|
+
signal?: AbortSignal,
|
|
344
|
+
f: Fetch = fetch
|
|
345
|
+
): Promise<SearchHit> {
|
|
346
|
+
const url = new URL(ARXIV_API)
|
|
347
|
+
url.searchParams.set('search_query', arxivQuery(query))
|
|
348
|
+
url.searchParams.set('start', '0')
|
|
349
|
+
url.searchParams.set('max_results', String(numResults))
|
|
350
|
+
url.searchParams.set('sortBy', 'submittedDate')
|
|
351
|
+
url.searchParams.set('sortOrder', 'descending')
|
|
352
|
+
const res = await f(url, { headers: { 'user-agent': 'pi-web' }, signal: withTimeout(signal) })
|
|
353
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
354
|
+
const papers = parseArxiv(await res.text())
|
|
355
|
+
if (!papers.length) throw new Error('no papers')
|
|
356
|
+
return {
|
|
357
|
+
text: papers
|
|
358
|
+
.map(
|
|
359
|
+
(p) =>
|
|
360
|
+
`Title: ${p.title}\nURL: ${p.url}\nPublished: ${p.published}\nAuthor: ${p.authors.join(', ')}\nAbstract: ${p.abstract}`
|
|
361
|
+
)
|
|
362
|
+
.join('\n\n'),
|
|
363
|
+
backend: 'arxiv',
|
|
364
|
+
results: papers.map((p) => ({
|
|
365
|
+
title: p.title,
|
|
366
|
+
url: p.url,
|
|
367
|
+
snippet: p.abstract,
|
|
368
|
+
published: p.published,
|
|
369
|
+
authors: p.authors
|
|
370
|
+
}))
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
interface HnHit {
|
|
375
|
+
title?: string
|
|
376
|
+
story_title?: string
|
|
377
|
+
objectID?: string
|
|
378
|
+
created_at?: string
|
|
379
|
+
points?: number
|
|
380
|
+
num_comments?: number
|
|
381
|
+
comment_text?: string
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function parseHits(text: string): HnHit[] | undefined {
|
|
385
|
+
return (JSON.parse(text) as { hits?: HnHit[] }).hits
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
export interface HnPost {
|
|
389
|
+
title: string
|
|
390
|
+
url: string
|
|
391
|
+
published: string
|
|
392
|
+
points: number
|
|
393
|
+
comments: number
|
|
394
|
+
comment: string
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function hnSearch(
|
|
398
|
+
query: string,
|
|
399
|
+
numResults: number,
|
|
400
|
+
signal?: AbortSignal,
|
|
401
|
+
f: Fetch = fetch
|
|
402
|
+
): Promise<SearchHit> {
|
|
403
|
+
const url = new URL(HN_SEARCH)
|
|
404
|
+
url.searchParams.set('query', query)
|
|
405
|
+
url.searchParams.set('hitsPerPage', String(numResults))
|
|
406
|
+
const res = await f(url, { signal: withTimeout(signal) })
|
|
407
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
408
|
+
const posts = (parseHits(await res.text()) ?? []).flatMap((hit): HnPost[] => {
|
|
409
|
+
const title = hit.title ?? hit.story_title
|
|
410
|
+
if (!title || !hit.objectID) return []
|
|
411
|
+
return [
|
|
412
|
+
{
|
|
413
|
+
title,
|
|
414
|
+
url: `https://news.ycombinator.com/item?id=${hit.objectID}`,
|
|
415
|
+
published: (hit.created_at ?? '').slice(0, 10),
|
|
416
|
+
points: hit.points ?? 0,
|
|
417
|
+
comments: hit.num_comments ?? 0,
|
|
418
|
+
comment: clean(hit.comment_text ?? '').slice(0, COMMENT_CHARS)
|
|
419
|
+
}
|
|
420
|
+
]
|
|
421
|
+
})
|
|
422
|
+
if (!posts.length) throw new Error('no posts')
|
|
423
|
+
return {
|
|
424
|
+
text: posts
|
|
425
|
+
.map((p) =>
|
|
426
|
+
[
|
|
427
|
+
`Title: ${p.title}`,
|
|
428
|
+
`URL: ${p.url}`,
|
|
429
|
+
`Published: ${p.published}`,
|
|
430
|
+
`Points: ${p.points}`,
|
|
431
|
+
`Comments: ${p.comments}`,
|
|
432
|
+
...(p.comment ? [`Comment: ${p.comment}`] : [])
|
|
433
|
+
].join('\n')
|
|
434
|
+
)
|
|
435
|
+
.join('\n\n'),
|
|
436
|
+
backend: 'hn',
|
|
437
|
+
results: posts.map((p) => ({
|
|
438
|
+
title: p.title,
|
|
439
|
+
url: p.url,
|
|
440
|
+
snippet: p.comment,
|
|
441
|
+
published: p.published,
|
|
442
|
+
points: p.points,
|
|
443
|
+
comments: p.comments
|
|
444
|
+
}))
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
type SourceSearch = (query: string, numResults: number, signal?: AbortSignal, f?: Fetch) => Promise<SearchHit>
|
|
449
|
+
|
|
450
|
+
const SOURCE_SEARCH: Record<Source, SourceSearch | undefined> = {
|
|
451
|
+
web: undefined,
|
|
452
|
+
arxiv: arxivSearch,
|
|
453
|
+
hn: hnSearch
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export type SearchManyHit = Hit & { groups: SearchGroup[] }
|
|
457
|
+
|
|
458
|
+
export async function searchMany(
|
|
459
|
+
queries: string[],
|
|
460
|
+
numResults: number,
|
|
461
|
+
source: Source,
|
|
462
|
+
signal: AbortSignal | undefined,
|
|
463
|
+
exa: ExaCall,
|
|
464
|
+
f: Fetch
|
|
465
|
+
): Promise<SearchManyHit> {
|
|
466
|
+
const sections: { query: string; hit: SearchHit }[] = []
|
|
467
|
+
for (let i = 0; i < queries.length; i += CONCURRENCY) {
|
|
468
|
+
const wave = await Promise.all(
|
|
469
|
+
queries.slice(i, i + CONCURRENCY).map((query) =>
|
|
470
|
+
search(query, numResults, source, signal, exa, f).catch((err): SearchHit => ({
|
|
471
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`,
|
|
472
|
+
backend: 'failed',
|
|
473
|
+
results: []
|
|
474
|
+
}))
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
for (const [offset, hit] of wave.entries()) sections.push({ query: queries[i + offset] ?? '', hit })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const groups: SearchGroup[] = sections.map(({ query, hit }) => ({
|
|
481
|
+
query,
|
|
482
|
+
backend: hit.backend,
|
|
483
|
+
results: hit.results
|
|
484
|
+
}))
|
|
485
|
+
const links = [...new Set(groups.flatMap((group) => group.results.map((result) => result.url)))]
|
|
486
|
+
const body = sections.map(({ query, hit }) =>
|
|
487
|
+
queries.length === 1 ? hit.text : `## ${query}\n[${hit.backend}]\n\n${hit.text}`
|
|
488
|
+
)
|
|
489
|
+
const backend = [...new Set(sections.map((s) => s.hit.backend))].join('+')
|
|
490
|
+
if (!links.length) return { text: body.join('\n\n'), backend, groups }
|
|
491
|
+
|
|
492
|
+
const read = links.slice(0, READ_LINKS)
|
|
493
|
+
const tail = `\n\n# Read these pages\nweb_fetch the promising ones among these ${read.length} URLs: ${read.join(' ')}`
|
|
494
|
+
return { text: body.join('\n\n') + tail, backend, groups }
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function fetchPages(
|
|
498
|
+
urls: string[],
|
|
499
|
+
maxCharacters: number,
|
|
500
|
+
signal: AbortSignal | undefined,
|
|
501
|
+
exa: ExaCall,
|
|
502
|
+
f: Fetch = fetch
|
|
503
|
+
): Promise<Hit> {
|
|
504
|
+
const xUrls = urls.filter(isXUrl)
|
|
505
|
+
const otherUrls = urls.filter((url) => !isXUrl(url))
|
|
506
|
+
if (!xUrls.length) return firstOf(signal, exaAndJina(otherUrls, maxCharacters, signal, exa, f))
|
|
507
|
+
if (!otherUrls.length) {
|
|
508
|
+
const text = await xFetch(xUrls, maxCharacters, signal, f).catch(
|
|
509
|
+
(err) => `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
510
|
+
)
|
|
511
|
+
return { text, backend: 'fxtwitter' }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const [xText, other] = await Promise.all([
|
|
515
|
+
xFetch(xUrls, maxCharacters, signal, f).catch(
|
|
516
|
+
(err) => `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
517
|
+
),
|
|
518
|
+
firstOf(signal, exaAndJina(otherUrls, maxCharacters, signal, exa, f))
|
|
519
|
+
])
|
|
520
|
+
return { text: `${xText}\n\n---\n\n${other.text}`, backend: `fxtwitter+${other.backend}` }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function exaAndJina(
|
|
524
|
+
urls: string[],
|
|
525
|
+
maxCharacters: number,
|
|
526
|
+
signal: AbortSignal | undefined,
|
|
527
|
+
exa: ExaCall,
|
|
528
|
+
f: Fetch
|
|
529
|
+
): [string, () => Promise<{ text: string }>][] {
|
|
530
|
+
return [
|
|
531
|
+
[
|
|
532
|
+
'exa',
|
|
533
|
+
async () => ({
|
|
534
|
+
text: markTruncation(await exa('web_fetch_exa', { urls, maxCharacters }, signal), maxCharacters)
|
|
535
|
+
})
|
|
536
|
+
],
|
|
537
|
+
['jina', async () => ({ text: await jinaFetch(urls, maxCharacters, signal, f) })]
|
|
538
|
+
]
|
|
539
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionToolContext, Theme } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
import { Text } from '@earendil-works/pi-tui'
|
|
3
|
+
import type { Infer } from 'ata-validator'
|
|
4
|
+
import { t } from 'ata-validator/t'
|
|
5
|
+
import { fetchPages, MAX_QUERIES, searchMany, SOURCES, type ExaCall } from './backends.js'
|
|
6
|
+
|
|
7
|
+
const EXA_MCP = 'https://mcp.exa.ai/mcp'
|
|
8
|
+
|
|
9
|
+
function exaViaMcp(ctx: ExtensionToolContext): ExaCall {
|
|
10
|
+
return async (name, args, signal) => {
|
|
11
|
+
const outcome = await ctx.executeTool(`mcp__exa__${name}`, args, { signal })
|
|
12
|
+
const text = outcome.result.content
|
|
13
|
+
.map((item) => (item.type === 'text' ? item.text : ''))
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join('\n')
|
|
16
|
+
if (outcome.isError) throw new Error(text || 'tool error')
|
|
17
|
+
return text
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ToolRenderArgs {
|
|
22
|
+
queries?: string[]
|
|
23
|
+
urls?: string[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderCall(name: string, args: ToolRenderArgs, theme: Theme): Text {
|
|
27
|
+
const display = args.queries ? args.queries.map((q) => `"${q}"`).join(' ') : (args.urls ?? []).join(' ')
|
|
28
|
+
return new Text(theme.fg('toolTitle', theme.bold(`${name} `)) + theme.fg('muted', display), 0, 0)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderResult(
|
|
32
|
+
result: { content: { type: string; text?: string }[]; details?: { backend: string } },
|
|
33
|
+
{ expanded }: { expanded: boolean },
|
|
34
|
+
theme: Theme
|
|
35
|
+
): Text {
|
|
36
|
+
const text = result.content
|
|
37
|
+
.filter((item) => item.type === 'text')
|
|
38
|
+
.map((item) => item.text ?? '')
|
|
39
|
+
.join('\n')
|
|
40
|
+
if (expanded) return new Text(text, 0, 0)
|
|
41
|
+
const lines = text.split('\n').length
|
|
42
|
+
const backend = result.details?.backend ?? '?'
|
|
43
|
+
return new Text(theme.fg('muted', `${lines} lines via ${backend}`), 0, 0)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default function (pi: ExtensionAPI): void {
|
|
47
|
+
const key = process.env['EXA_API_KEY']
|
|
48
|
+
pi.registerMcpServer('exa', {
|
|
49
|
+
url: key ? `${EXA_MCP}?exaApiKey=${encodeURIComponent(key)}` : EXA_MCP,
|
|
50
|
+
description: 'Exa search and page extraction, the primary backend of web_search and web_fetch',
|
|
51
|
+
exposure: 'codemode',
|
|
52
|
+
timeout: 60
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const searchResult = t.object({
|
|
56
|
+
title: t.string(),
|
|
57
|
+
url: t.string(),
|
|
58
|
+
snippet: t.string(),
|
|
59
|
+
published: t.optional(t.string()),
|
|
60
|
+
authors: t.optional(t.array(t.string())),
|
|
61
|
+
points: t.optional(t.number()),
|
|
62
|
+
comments: t.optional(t.number())
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const searchParams = t.object({
|
|
66
|
+
queries: t.array(t.string({ minLength: 1 }), { minItems: 1, maxItems: MAX_QUERIES }),
|
|
67
|
+
numResults: t.optional(t.integer({ minimum: 1, maximum: 10 })),
|
|
68
|
+
source: t.optional(t.enum(SOURCES))
|
|
69
|
+
})
|
|
70
|
+
type SearchParams = Infer<typeof searchParams>
|
|
71
|
+
|
|
72
|
+
pi.registerTool({
|
|
73
|
+
name: 'web_search',
|
|
74
|
+
label: 'Web Search',
|
|
75
|
+
description: [
|
|
76
|
+
'Pass several queries at once to cover a topic from several angles in one call.',
|
|
77
|
+
'Describe the ideal page, not keywords: "blog post comparing X and Y" beats "X vs Y".',
|
|
78
|
+
'source arxiv returns preprints with abstracts, authors and dates, sorted by submission date.',
|
|
79
|
+
'source hn returns Hacker News threads scored by points and comment count.',
|
|
80
|
+
'Reddit and X have no keyless search: name the forum in a query.',
|
|
81
|
+
'Results are excerpts: web_fetch the ranked links at the end before answering.'
|
|
82
|
+
].join(' '),
|
|
83
|
+
promptSnippet: 'search the web, arxiv or Hacker News for current information, papers and discussion',
|
|
84
|
+
exposure: 'direct',
|
|
85
|
+
namespace: {
|
|
86
|
+
name: 'web',
|
|
87
|
+
description: 'The open web: search it, and read a known URL as markdown.'
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
91
|
+
parameters: searchParams,
|
|
92
|
+
renderCall: (args, theme) => renderCall('web_search', args, theme),
|
|
93
|
+
renderResult,
|
|
94
|
+
outputSchema: t.object({
|
|
95
|
+
groups: t.array(
|
|
96
|
+
t.object({
|
|
97
|
+
query: t.string(),
|
|
98
|
+
backend: t.string(),
|
|
99
|
+
results: t.array(searchResult)
|
|
100
|
+
})
|
|
101
|
+
)
|
|
102
|
+
}),
|
|
103
|
+
async execute(_toolCallId, params: SearchParams, signal, _onUpdate, ctx) {
|
|
104
|
+
const hit = await searchMany(
|
|
105
|
+
params.queries.slice(0, MAX_QUERIES),
|
|
106
|
+
params.numResults ?? 5,
|
|
107
|
+
params.source ?? 'web',
|
|
108
|
+
signal,
|
|
109
|
+
exaViaMcp(ctx),
|
|
110
|
+
fetch
|
|
111
|
+
)
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: 'text' as const, text: hit.text || 'No results' }],
|
|
114
|
+
details: { backend: hit.backend },
|
|
115
|
+
structuredContent: { groups: hit.groups }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const fetchParams = t.object({
|
|
121
|
+
urls: t.array(t.string(), { minItems: 1 }),
|
|
122
|
+
maxCharacters: t.optional(t.integer({ minimum: 1 }))
|
|
123
|
+
})
|
|
124
|
+
type FetchParams = Infer<typeof fetchParams>
|
|
125
|
+
|
|
126
|
+
pi.registerTool({
|
|
127
|
+
name: 'web_fetch',
|
|
128
|
+
label: 'Web Fetch',
|
|
129
|
+
description:
|
|
130
|
+
'Read known pages as Markdown; batch URLs, and raise maxCharacters for a long page. ' +
|
|
131
|
+
'A page cut at the limit says so at the end, and raising the limit refetches it whole.',
|
|
132
|
+
promptSnippet: 'read known URLs as markdown',
|
|
133
|
+
exposure: 'direct',
|
|
134
|
+
namespace: {
|
|
135
|
+
name: 'web',
|
|
136
|
+
description: 'The open web: search it, and read a known URL as markdown.'
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
140
|
+
parameters: fetchParams,
|
|
141
|
+
renderCall: (args, theme) => renderCall('web_fetch', args, theme),
|
|
142
|
+
renderResult,
|
|
143
|
+
async execute(_toolCallId, params: FetchParams, signal, _onUpdate, ctx) {
|
|
144
|
+
const hit = await fetchPages(params.urls, params.maxCharacters ?? 8000, signal, exaViaMcp(ctx))
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: 'text' as const, text: hit.text || 'No results' }],
|
|
147
|
+
details: { backend: hit.backend }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
})
|
|
151
|
+
}
|
package/src/x-post.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
export const FIXUPX_STATUS_URL = 'https://api.fxtwitter.com/2/status/'
|
|
2
|
+
|
|
3
|
+
export const X_ARTICLE_ID_ERROR =
|
|
4
|
+
'this x.com/i/article/ URL holds an article id, not a post id, and the article body is served under the post that links it: fetch the x.com/<user>/status/<post id> URL instead'
|
|
5
|
+
|
|
6
|
+
function xBlockPrefix(type: string | undefined): string {
|
|
7
|
+
switch (type) {
|
|
8
|
+
case 'header-one':
|
|
9
|
+
return '# '
|
|
10
|
+
case 'header-two':
|
|
11
|
+
return '## '
|
|
12
|
+
case 'header-three':
|
|
13
|
+
return '### '
|
|
14
|
+
case 'header-four':
|
|
15
|
+
return '#### '
|
|
16
|
+
case 'header-five':
|
|
17
|
+
return '##### '
|
|
18
|
+
case 'header-six':
|
|
19
|
+
return '###### '
|
|
20
|
+
case 'blockquote':
|
|
21
|
+
return '> '
|
|
22
|
+
case 'unordered-list-item':
|
|
23
|
+
return '- '
|
|
24
|
+
case 'ordered-list-item':
|
|
25
|
+
return '1. '
|
|
26
|
+
case 'code':
|
|
27
|
+
return ' '
|
|
28
|
+
default:
|
|
29
|
+
return ''
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface XRange {
|
|
34
|
+
offset: number
|
|
35
|
+
length: number
|
|
36
|
+
key?: number
|
|
37
|
+
style?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface XBlock {
|
|
41
|
+
type?: string
|
|
42
|
+
text?: string
|
|
43
|
+
inlineStyleRanges?: XRange[]
|
|
44
|
+
entityRanges?: XRange[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface XArticle {
|
|
48
|
+
title?: string
|
|
49
|
+
preview_text?: string
|
|
50
|
+
content?: {
|
|
51
|
+
blocks?: XBlock[]
|
|
52
|
+
entityMap?:
|
|
53
|
+
| Record<string, { data?: { type?: string; url?: string } }>
|
|
54
|
+
| { data?: { type?: string; url?: string } }[]
|
|
55
|
+
}
|
|
56
|
+
media_entities?: { url?: string }[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface XPost {
|
|
60
|
+
text?: string
|
|
61
|
+
created_at?: string
|
|
62
|
+
author?: { name?: string; screen_name?: string }
|
|
63
|
+
article?: XArticle
|
|
64
|
+
media?: { photos?: { url?: string }[] }
|
|
65
|
+
quote?: XPost
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isXUrl(url: string): boolean {
|
|
69
|
+
let host: string
|
|
70
|
+
try {
|
|
71
|
+
host = new URL(url).hostname.toLowerCase()
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
return host === 'x.com' || host.endsWith('.x.com') || host === 'twitter.com' || host.endsWith('.twitter.com')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function xPostId(url: string): string | undefined {
|
|
79
|
+
return /\/(?:i\/web\/)?status(?:es)?\/(\d+)/.exec(url)?.[1]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function xArticleId(url: string): string | undefined {
|
|
83
|
+
return /\/i\/article\/(\d+)/.exec(url)?.[1]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function xEntity(article: XArticle, key: number): { data?: { type?: string; url?: string } } | undefined {
|
|
87
|
+
const map = article.content?.entityMap
|
|
88
|
+
if (!map) return undefined
|
|
89
|
+
return Array.isArray(map) ? map[key] : map[String(key)]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function xInline(text: string, block: XBlock, article: XArticle): string {
|
|
93
|
+
const wrapped = [
|
|
94
|
+
...(block.inlineStyleRanges ?? []).map((range) => ({
|
|
95
|
+
...range,
|
|
96
|
+
marker: range.style === 'BOLD' ? '**' : range.style === 'ITALIC' ? '*' : ''
|
|
97
|
+
})),
|
|
98
|
+
...(block.entityRanges ?? []).map((range) => {
|
|
99
|
+
const url = xEntity(article, range.key ?? -1)?.data?.url
|
|
100
|
+
return {
|
|
101
|
+
...range,
|
|
102
|
+
marker: url ? `[${text.slice(range.offset, range.offset + range.length)}](${url})]` : ''
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
]
|
|
106
|
+
.filter((range) => range.marker)
|
|
107
|
+
.toSorted((a, b) => b.offset - a.offset)
|
|
108
|
+
|
|
109
|
+
let out = text
|
|
110
|
+
const taken = new Set<number>()
|
|
111
|
+
for (const range of wrapped) {
|
|
112
|
+
const end = range.offset + range.length
|
|
113
|
+
if (Array.from({ length: range.length }, (_, i) => taken.has(range.offset + i)).some(Boolean)) continue
|
|
114
|
+
for (let i = range.offset; i < end; i++) taken.add(i)
|
|
115
|
+
out = `${out.slice(0, range.offset)}${range.marker}${out.slice(end)}`
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function xAtomic(block: XBlock, article: XArticle): string {
|
|
121
|
+
const key = block.entityRanges?.[0]?.key
|
|
122
|
+
if (key === undefined) return ''
|
|
123
|
+
if (xEntity(article, key)?.data?.type?.toUpperCase().includes('DIVIDER')) return '---'
|
|
124
|
+
const url = article.media_entities?.[key]?.url
|
|
125
|
+
return url ? `` : ''
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function xArticleMarkdown(article: XArticle): string {
|
|
129
|
+
const parts: string[] = []
|
|
130
|
+
if (article.title) parts.push(`# ${article.title}`)
|
|
131
|
+
const blocks = article.content?.blocks
|
|
132
|
+
if (!blocks?.length) {
|
|
133
|
+
if (article.preview_text) parts.push(article.preview_text)
|
|
134
|
+
return parts.join('\n\n')
|
|
135
|
+
}
|
|
136
|
+
for (const block of blocks) {
|
|
137
|
+
if (block.type === 'atomic') {
|
|
138
|
+
const media = xAtomic(block, article)
|
|
139
|
+
if (media) parts.push(media)
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
const text = (block.text ?? '').trim()
|
|
143
|
+
if (!text) continue
|
|
144
|
+
parts.push(`${xBlockPrefix(block.type)}${xInline(text, block, article)}`)
|
|
145
|
+
}
|
|
146
|
+
return parts.join('\n\n')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function xPostMarkdown(payload: string): string {
|
|
150
|
+
const body = JSON.parse(payload) as { status?: XPost; message?: string }
|
|
151
|
+
const post = body.status
|
|
152
|
+
if (!post) throw new Error(`Fixupx has no post for this id: ${body.message ?? 'not found'}`)
|
|
153
|
+
|
|
154
|
+
const parts: string[] = []
|
|
155
|
+
const author = post.author
|
|
156
|
+
if (author?.screen_name) parts.push(`**${author.name ?? author.screen_name}** (@${author.screen_name})`)
|
|
157
|
+
if (post.text) parts.push(post.text)
|
|
158
|
+
for (const photo of post.media?.photos ?? []) {
|
|
159
|
+
if (photo.url) parts.push(``)
|
|
160
|
+
}
|
|
161
|
+
if (post.quote?.text) parts.push(`> ${post.quote.author?.screen_name ?? 'quoted post'}: ${post.quote.text}`)
|
|
162
|
+
if (post.article) parts.push(xArticleMarkdown(post.article))
|
|
163
|
+
return parts.join('\n\n')
|
|
164
|
+
}
|