@tduyng/pi-web 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,39 +1,62 @@
1
1
  # @tduyng/pi-web
2
2
 
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.
3
+ Web search and fetch for pi that needs no API key, no setup, and no MCP tool pile in your context. Two tools, 326 tokens a request, done.
4
4
 
5
- ## Backends
5
+ ## What it costs
6
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.
7
+ `web_search` is 219 tokens a request and `web_fetch` is 107. That is the whole bill. The Exa MCP server is registered with exposure `codemode`, so its two tools never enter the prompt: a codemode script calls them through `ctx.executeTool`, and the model that never writes such a script never pays for them.
8
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.
9
+ Cold start is the same 326 tokens on every session. No key to paste, no account, no handshake, so the first search of a session is as fast as the last.
10
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.
11
+ ## What you get
12
12
 
13
- Search returns excerpts, so the result ends with the ranked URLs to read. Without that list the model answers from a snippet.
13
+ `web_search` takes up to six queries in one call and groups the results under the query that produced them. Three backends answer, in order, and a backend that returns nothing hands over to the next one:
14
+
15
+ | Backend | When | What it returns |
16
+ | --- | --- | --- |
17
+ | Exa MCP | always, keyless | prose answer plus highlights |
18
+ | DuckDuckGo | Exa is unconnected, `--no-mcp`, or empty | result list |
19
+ | arXiv Atom API | `source: arxiv` | abstracts, authors, dates, newest first |
20
+ | Hacker News (Algolia) | `source: hn` | threads with points and comment count |
21
+
22
+ `web_fetch` reads a URL as Markdown. `x.com` and `twitter.com` links go to Fixupx, which answers a page X refuses, and it returns the post together with the long-form Article it links to.
14
23
 
15
24
  ## Typed results
16
25
 
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.
26
+ `web_search` declares an output schema, so a codemode script gets the answer as data in `structuredContent.groups`: `title`, `url`, `snippet`, plus `published` and `authors` where the backend has them, and `points` and `comments` on Hacker News. Compare values across results without parsing text. Exa's answer is prose, and `parseExa` reads it back into the same shape, so the typed view holds for every backend. The text still reaches the model and the data is not sent twice.
27
+
28
+ ## Search is a step, not an answer
18
29
 
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.
30
+ Results are excerpts, so the reply ends with the ranked URLs to read. Without that list the model answers from a snippet. Reddit and X have no keyless search: measured on 2026-09-27, `reddit.com/search.json` answers 403 on `www`, `old`, and `api`, with a browser user agent and a plain one. DuckDuckGo's `site:` operator does reach both, so name the forum in the query and the backend finds the thread.
20
31
 
21
32
  ## DuckDuckGo queue
22
33
 
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.
34
+ DuckDuckGo answers a burst 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 403.
35
+
36
+ So searches 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. A paused search fails at once instead of holding the tool open, and the batch reports that query as failed.
24
37
 
25
38
  ## Links in a fetched page
26
39
 
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.
40
+ 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 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 links survive only when Jina does. Follow a citation by searching for the paper's title.
28
41
 
29
42
  ## arXiv query form
30
43
 
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
44
+ 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. A plain query therefore 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) passes through untouched.
34
45
 
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.
46
+ A miss on arXiv falls back to the open web, because arXiv indexes preprints and nothing else. A miss on Hacker News does the same.
36
47
 
37
48
  ## Quota
38
49
 
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.
50
+ 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` parameter. Exa's free plan includes 1,000 requests a month, so a batch of six queries costs six of them and runs three at a time.
51
+
52
+ The server takes a 60 second request timeout, which covers the connect handshake. pi retries only HTTP 408, 429, and 5xx, so a handshake that overruns marks the server failed for the session and `/mcp` shows why.
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pi install npm:@tduyng/pi-web
58
+ ```
59
+
60
+ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-web`
61
+
62
+ Nothing else to configure.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tduyng/pi-web",
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.",
3
+ "version": "0.0.3",
4
+ "description": "Keyless web search and fetch: batched queries, arXiv, Hacker News, and Exa MCP with fallbacks. No API key, no MCP tool pile in context.",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
package/src/backends.ts CHANGED
@@ -14,7 +14,7 @@ const COMMENT_CHARS = 300
14
14
  export const SOURCES = ['web', 'arxiv', 'hn'] as const
15
15
  export type Source = (typeof SOURCES)[number]
16
16
 
17
- export type Fetch = typeof fetch
17
+ export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>
18
18
 
19
19
  export interface Hit {
20
20
  text: string
@@ -280,6 +280,8 @@ export async function search(
280
280
  return await special(query, numResults, signal, f)
281
281
  } catch (err) {
282
282
  if (signal?.aborted) throw err
283
+ const noResults = err instanceof Error && /^no (papers|posts)$/.test(err.message)
284
+ if (!noResults) throw err
283
285
  }
284
286
  }
285
287
  return firstOf<{ text: string; results: SearchResult[] }>(signal, [
package/src/x-post.ts CHANGED
@@ -99,7 +99,7 @@ function xInline(text: string, block: XBlock, article: XArticle): string {
99
99
  const url = xEntity(article, range.key ?? -1)?.data?.url
100
100
  return {
101
101
  ...range,
102
- marker: url ? `[${text.slice(range.offset, range.offset + range.length)}](${url})]` : ''
102
+ marker: url ? `[${text.slice(range.offset, range.offset + range.length)}](${url})` : ''
103
103
  }
104
104
  })
105
105
  ]