pi-twitterapi.io 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.1] - 2026-10-03
11
+
12
+ First version published to npm.
13
+
14
+ ### Added
15
+
16
+ - `twitter` tool backed by the [twitterapi.io](https://twitterapi.io) REST API, with 17 read modes:
17
+ `posts` (default), `users`, `thread`, `user`, `trends`, `replies`, `quotes`, `mentions`, `followers`,
18
+ `followings`, `profile`, `about`, `tweets`, `retweeters`, `community`, `list` and `space`.
19
+ - Answer synthesis with a pi model: `twitter.synthesisModel` when configured, otherwise the model running
20
+ the current session, with a per-failure-kind fallback chain back to the session model.
21
+ - Citations derived from fetched permalinks only; invented X links are dropped and counted in the notes.
22
+ - Best-effort media understanding (`enableImageUnderstanding`, `enableVideoUnderstanding` with poster
23
+ frames), bounded paging with early-stop disclosure, and per-mode parameter validation.
24
+ - Strict settings parsing for the `twitter` block in `~/.pi/agent/settings.json` and
25
+ `<cwd>/.pi/settings.json`.
26
+
27
+ ### Fixed
28
+
29
+ - README no longer claims a peer range of `>=0.99.2 <2`; the host packages are declared as peers with
30
+ `"*"`, and the `ModelRegistry.complete` requirement is documented separately from that range.
31
+ - The release workflow decides publishing and GitHub-release creation separately, so a rerun after a
32
+ failed release step skips the immutable npm version instead of failing on it and repairs the release.
33
+ A registry lookup that fails for any reason other than "not found" now fails the run loudly.
34
+ - The `mode`, `queryType` and `replySort` tool parameters are closed literal unions rather than free-form
35
+ strings, and the tests assert both the accepted and rejected values.
36
+
37
+ ### Changed
38
+
39
+ - Package layout: the pi manifest loads `./src/index.ts` directly and the tarball ships the TypeScript
40
+ sources (tests excluded), so there is no build step; `tsdown` and `dist/` are gone.
41
+ - Release workflow actions are pinned to commit SHAs, checkouts do not persist credentials, `ci.yml` runs
42
+ with `contents: read`, and the publishing job does not use a package-manager cache.
43
+
44
+ ## [0.1.0] - 2026-10-03
45
+
46
+ Tagged but never published. Superseded by [0.1.1](#011---2026-10-03), which carries the same features
47
+ plus the fixes above; the entry is kept because the git tag exists.
48
+
49
+ [Unreleased]: https://github.com/jagaliano/pi-twitterapi.io/compare/v0.1.1...HEAD
50
+ [0.1.1]: https://github.com/jagaliano/pi-twitterapi.io/compare/v0.1.0...v0.1.1
51
+ [0.1.0]: https://github.com/jagaliano/pi-twitterapi.io/releases/tag/v0.1.0
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 José Antonio Galiano Sandoval (pi-twitterapi.io)
4
+ Copyright (c) 2026 anthod0 (upstream: https://github.com/anthod0/pi-lab)
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,278 @@
1
+ # pi-twitterapi.io
2
+
3
+ A [twitterapi.io](https://twitterapi.io)-backed X/Twitter extension for the
4
+ [pi coding agent](https://pi.dev). It registers a `twitter` tool that reads
5
+ posts, accounts, threads, timelines, trends, conversations and more through
6
+ twitterapi.io, then synthesizes an answer with citation URLs using a model from
7
+ pi's own registry.
8
+
9
+ This extension is self-contained: twitterapi.io is the only retrieval source and
10
+ pi's model registry performs the synthesis. It is designed to cover the same
11
+ surface as xAI's official `x_search` tool and, where twitterapi.io exposes more,
12
+ to go beyond it — see [Compared with xAI `x_search`](#compared-with-xai-x_search).
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pi install npm:pi-twitterapi.io
18
+ ```
19
+
20
+ Or load the local source directly (pi compiles TypeScript extensions on the
21
+ fly, so there is no build step):
22
+
23
+ ```bash
24
+ pi -e ./src/index.ts
25
+ ```
26
+
27
+ ## Configure
28
+
29
+ Set the twitterapi.io API key. Synthesis uses `twitter.synthesisModel` when set, and otherwise falls back to the model running the current pi session, so a key-only setup already works:
30
+
31
+ ```bash
32
+ export TWITTERAPI_IO_API_KEY="your-twitterapi.io-key"
33
+ ```
34
+
35
+ The settings file is strict JSON (no comments). Add the `twitter` block to
36
+ `~/.pi/agent/settings.json` — or `<cwd>/.pi/settings.json` for a project-scoped
37
+ override — and set only the keys you need:
38
+
39
+ ```json
40
+ {
41
+ "twitter": {
42
+ "synthesisModel": "anthropic/claude-haiku-4-5-20251001",
43
+ "enableImageUnderstanding": false,
44
+ "enableVideoUnderstanding": false,
45
+ "maxMediaPerSearch": 4,
46
+ "maxPages": 5,
47
+ "maxPagesCeiling": 20,
48
+ "minRequestIntervalMs": 5000,
49
+ "retryBaseDelayMs": 5000
50
+ }
51
+ }
52
+ ```
53
+
54
+ | Key | Required | Meaning |
55
+ |---|---|---|
56
+ | `synthesisModel` | no | A pi model id (`provider/model`) used to turn retrieved posts into an answer. When unset, the session model is used; if it is set but fails at runtime, the session model answers instead with a note. |
57
+ | `enableImageUnderstanding` | no | Attach post images to the synthesis request when the model accepts image input. |
58
+ | `enableVideoUnderstanding` | no | Attach video poster frames (chat models cannot ingest video). |
59
+ | `maxMediaPerSearch` | no | Upper bound on media attachments per search (max 20, default 4). |
60
+ | `maxPages` | no | Base page budget per search (default 5). |
61
+ | `maxPagesCeiling` | no | Hard cap that `maxPages` is clamped to (default 20). |
62
+ | `minRequestIntervalMs` | no | Minimum spacing between upstream requests (default 5000). twitterapi.io allows 0.2 QPS on unpaid accounts; raise it if you are being throttled, lower it for a higher-QPS tier, or set it to 0 to disable pacing. |
63
+ | `retryBaseDelayMs` | no | Base delay for retry backoff (default 5000). |
64
+
65
+ > **Important:** the extension needs a pi version whose `ModelRegistry.complete`
66
+ > exists — it is absent on pi 0.80.6, present from pi 0.99.2, and verified on
67
+ > pi 1.0.2. The host packages are declared as peers with a `"*"` range because pi
68
+ > supplies them at runtime, so that range does not enforce the capability: check
69
+ > your pi version if you are on an old release. Synthesis is a real model call, so
70
+ > it consumes tokens on the configured model.
71
+
72
+ ## The `twitter` tool
73
+
74
+ | Parameter | Type | Notes |
75
+ |---|---|---|
76
+ | `query` | string (required) | Natural-language question. Required for every mode. |
77
+ | `mode` | `posts` \| `users` \| `thread` \| `user` \| `trends` \| `replies` \| `quotes` \| `mentions` \| `followers` \| `followings` \| `profile` \| `about` \| `tweets` \| `retweeters` \| `community` \| `list` \| `space` | Defaults to `posts`. |
78
+ | `tweet` | string | Post id or X permalink. Required for `thread`, `replies`, `quotes`, `retweeters`; refused in any other mode. |
79
+ | `user` | string | Handle (no `@`) for `mode=user`, `mentions`, `followers`, `followings`, `profile`, `about`. |
80
+ | `userId` | string | Numeric user id for `mode=user`; preferred over `user` when known. |
81
+ | `ids` | string[] | `mode=tweets`: post ids or permalinks to fetch (max 100). |
82
+ | `pageSize` | number | `mode=followers`/`followings`: accounts per page (20–200). |
83
+ | `communityId` | string | `mode=community`: the community id. |
84
+ | `listId` | string | `mode=list`: the list id. |
85
+ | `spaceId` | string | `mode=space`: the X Space id. |
86
+ | `woeid` | number | `mode=trends` location id (1=Worldwide, 23424977=USA). |
87
+ | `includeReplies` | boolean | `mode=user` (timeline) and `mode=quotes`. |
88
+ | `sinceTime` / `untilTime` | number | `mode=quotes` and `mode=mentions`: unix timestamps (seconds) bounding the results. |
89
+ | `limit` | number | `mode=user`/`mentions`/`followers`/`followings`/`replies`/`quotes`/`retweeters`/`community`/`list`: stop after this many items (max 1000). |
90
+ | `replySort` | `"Relevance"` \| `"Latest"` \| `"Likes"` | `mode=replies` sort order (default `Relevance`). |
91
+ | `allowed_x_handles` | string[] | `mode=posts`: only these handles (max 20, no `@`). |
92
+ | `excluded_x_handles` | string[] | `mode=posts`: exclude these handles (max 20, no `@`). |
93
+ | `from_date` / `to_date` | `YYYY-MM-DD` | `mode=posts` date range. |
94
+ | `queryType` | `"Latest"` \| `"Top"` | `mode=posts`: newest-first (default) or ranked. |
95
+ | `count` | number | `mode=posts`/`users`: max items (posts default 10, accounts default 20; max 50). `mode=trends`: number of trends (min 30). |
96
+
97
+ Parameters that cannot apply in a given mode are rejected with an error rather
98
+ than silently ignored.
99
+
100
+ ### Modes
101
+
102
+ | Mode | Endpoint | What it reads |
103
+ |---|---|---|
104
+ | `posts` (default) | `/twitter/tweet/advanced_search` | Keyword/operator post search; handle filters, dates, `Latest`/`Top`, item count. Optionally attaches media. |
105
+ | `users` | `/twitter/user/search` | Account discovery; profile URLs as sources. |
106
+ | `thread` | `/twitter/tweet/thread_context` | A referenced post's whole thread context. |
107
+ | `user` | `/twitter/user/last_tweets` | A specific account's most recent posts (`user`/`userId`, `includeReplies`, `limit`). |
108
+ | `trends` | `/twitter/trends` | Trending topics for a `woeid`; sources are X search URLs. |
109
+ | `replies` | `/twitter/tweet/replies/v2` | Replies to a post (`replySort`, `limit`). |
110
+ | `quotes` | `/twitter/tweet/quotes` | Quote-posts of a post (`sinceTime`/`untilTime`, `includeReplies`, `limit`). |
111
+ | `mentions` | `/twitter/user/mentions` | Posts mentioning an account (`user`, `sinceTime`/`untilTime`, `limit`). |
112
+ | `followers` | `/twitter/user/followers` | Who follows an account (`user`, `pageSize`, `limit`); profile URLs as sources. |
113
+ | `followings` | `/twitter/user/followings` | Who an account follows (`user`, `pageSize`, `limit`). |
114
+ | `profile` | `/twitter/user/info` | A single account profile (`user`). |
115
+ | `about` | `/twitter/user_about` | Extended profile-page metadata (`user`): account-based-in, creation source, handle changes, identity verification; cited as the profile URL. |
116
+ | `tweets` | `/twitter/tweets` | Specific posts by id (`ids`, max 100). |
117
+ | `retweeters` | `/twitter/tweet/retweeters` | Accounts that reposted a post (`tweet`, `limit`); profile URLs as sources. |
118
+ | `community` | `/twitter/community/tweets` | Posts from a community (`communityId`, `limit`). |
119
+ | `list` | `/twitter/list/tweets_timeline` | Posts from a list (`listId`, `limit`). |
120
+ | `space` | `/twitter/spaces/detail` | An X Space's detail (`spaceId`); cited as `https://x.com/i/spaces/<id>`. Note: twitterapi.io currently returns HTTP 404 for this endpoint even for Spaces that are live in the X app, so the mode often reports the upstream error verbatim. |
121
+
122
+ ## Behavior and disclosures
123
+
124
+ - **Citations are derived from fetched permalinks, never from model output.**
125
+ An X/Twitter link the model invents that was not among the retrieved posts is
126
+ dropped from `Sources` and counted in the `## Notes` section. Non-X links are
127
+ outside the citation contract: they are neither published as sources nor
128
+ counted as invented citations.
129
+ - **Media is best-effort.** Video cannot be sent to a chat model, so video posts
130
+ are represented by their poster frame and this limitation is disclosed.
131
+ - **Partial retrieval is disclosed.** If paging stops early (page cap, cursor
132
+ cycle, or a missing cursor while more results remain), the answer carries a
133
+ note saying the results may be incomplete.
134
+ - **`mode=space` depends on a flaky upstream.** twitterapi.io's
135
+ `/twitter/spaces/detail` returned HTTP 404 "Space not found or API error" for
136
+ a Space that X itself displayed as live, so this mode frequently reports the
137
+ upstream error rather than a summary.
138
+ - **Date windows are resolved at 04:00 UTC by twitterapi.io**, so posts outside
139
+ the requested local window are trimmed while paging, with a note when that
140
+ happens. Two consequences worth knowing: far-west offsets (for example
141
+ UTC-8/-10) can lose the last few hours of the requested day because the
142
+ upstream window ends before local midnight (disclosed as a note), and far-east
143
+ offsets (UTC+10 and beyond) can spend free paging on the newer trim band before
144
+ results begin. Start padding is deliberately conservative (one extra hour) so a
145
+ winter boundary shift cannot silently drop the first hour of the local day.
146
+ - **Images and the fallback.** Media is attached only when the model chosen to
147
+ synthesize accepts image input; if that model fails and a different model
148
+ answers, images are omitted rather than sent to a model that cannot read them,
149
+ and the answer says so.
150
+ - **Synthesis fallback.** If `twitter.synthesisModel` fails at runtime, the
151
+ answer is retried with the model running the current session and the result
152
+ carries a note naming the model that answered. Failures are classified so the
153
+ chain reacts per kind rather than treating every error alike:
154
+
155
+ | Failure | Examples | Reaction |
156
+ |---|---|---|
157
+ | Quota / billing | `402`, `insufficient_quota`, quota exceeded, subscription limit | next model |
158
+ | Authentication | `401`, `403`, invalid API key | next model |
159
+ | Unknown model | `404`, "does not exist" | next model |
160
+ | Invalid request | `400`, `422`, malformed | next model |
161
+ | Rate limit | `429`, "too many requests", overloaded | next model, retried once when it is the last one |
162
+ | Server | `5xx`, bad gateway, unavailable | next model, retried once when it is the last one |
163
+ | Transport | timeouts, connection drops, premature stream endings | next model, retried once when it is the last one |
164
+ | Empty response | model returned no usable text | next model, retried once when it is the last one |
165
+ | Unclassified | anything else | next model, never retried |
166
+ | Cancelled | aborted signal / `AbortError`, or pi's "was cancelled" wording | stop, no fallback |
167
+
168
+ Deterministic failures never retry the same model. The bounded retry (500 ms,
169
+ doubling to a 4 s cap) is spent only on the last available model, since an
170
+ untried model is the better bet while one remains.
171
+
172
+ ## Compared with xAI `x_search`
173
+
174
+ xAI's official [`x_search`](https://docs.x.ai/developers/tools/x-search) is a
175
+ server-side tool that bundles four underlying operations — keyword search,
176
+ semantic search, user search and thread fetch — and lets Grok choose which to
177
+ run. `pi-twitterapi.io` targets the same read surface through twitterapi.io's REST
178
+ API and adds the endpoints twitterapi.io exposes that `x_search` has no
179
+ equivalent for. The table below is the honest capability comparison; the full
180
+ version, including parameter mapping, lives in
181
+ [`docs/x-search-comparison.md`](docs/x-search-comparison.md).
182
+
183
+ | Capability | xAI `x_search` | `pi-twitterapi.io` (`twitter` tool) |
184
+ |---|---|---|
185
+ | Retrieval source | xAI's server-side X index | twitterapi.io REST API |
186
+ | Credential | `XAI_API_KEY` | `TWITTERAPI_IO_API_KEY` |
187
+ | Keyword post search | ✅ `x_keyword_search` | ✅ `mode=posts` (`advanced_search`) |
188
+ | Semantic search | ✅ `x_semantic_search` | ❌ **no equivalent** (twitterapi.io has no semantic endpoint) |
189
+ | User/account search | ✅ `x_user_search` | ✅ `mode=users` |
190
+ | Thread fetch | ✅ `x_thread_fetch` | ✅ `mode=thread` |
191
+ | Account timeline | ❌ | ✅ `mode=user` (`last_tweets`) |
192
+ | Trends by location | ❌ | ✅ `mode=trends` |
193
+ | Replies to a post | ❌ | ✅ `mode=replies` |
194
+ | Quote-posts | ❌ | ✅ `mode=quotes` |
195
+ | Mentions of an account | ❌ | ✅ `mode=mentions` |
196
+ | Followers / followings | ❌ | ✅ `mode=followers`, `mode=followings` |
197
+ | Single profile lookup | partial (via user search) | ✅ `mode=profile` |
198
+ | Extended profile ("about") metadata | ❌ | ✅ `mode=about` |
199
+ | Fetch posts by id | ❌ | ✅ `mode=tweets` |
200
+ | Users who reposted a post | ❌ | ✅ `mode=retweeters` |
201
+ | Communities / lists / Spaces | ❌ | ✅ `mode=community`, `mode=list`, `mode=space` |
202
+ | Handle filters | `allowed_x_handles` / `excluded_x_handles` (max 20, mutually exclusive) | same, `mode=posts` |
203
+ | Date range | `from_date` / `to_date` (`YYYY-MM-DD`) | same, `mode=posts`; plus unix windows for `quotes`/`mentions` |
204
+ | Result order | chosen by the model | `queryType` (`Latest`/`Top`), `replySort` |
205
+ | Item-count control | ❌ | ✅ `count`, `limit` |
206
+ | Image understanding | ✅ `enable_image_understanding` | ✅ `enableImageUnderstanding` (attached when the model accepts images) |
207
+ | Video understanding | ✅ `enable_video_understanding` | ⚠️ poster frame only (chat models cannot ingest video) |
208
+ | Answer generation | Grok (xAI) | any pi model: `twitter.synthesisModel`, else the session model |
209
+ | Citations | xAI annotations/citations | derived from fetched permalinks; unmatched X links dropped and disclosed |
210
+ | Cost | xAI tokens + per post/profile | twitterapi.io credits + your model's tokens |
211
+ | Shape | one `x_search` request | one `twitter` tool with 17 modes |
212
+
213
+ **Summary.** `pi-twitterapi.io` matches `x_search` on keyword search, user search,
214
+ thread fetch, handle filters, date ranges and image understanding, and adds a
215
+ dedicated account timeline, trends, replies, quotes, mentions, followers,
216
+ followings, profile, about, posts-by-id, retweeters, community, list and Space
217
+ modes. The one
218
+ capability it cannot match is **semantic search**, because twitterapi.io exposes
219
+ only keyword/operator search. It also differs structurally: `x_search` is one
220
+ server-side call answered by Grok, while this extension retrieves through
221
+ twitterapi.io and synthesizes with a pi model of your choice.
222
+
223
+ ## Development
224
+
225
+ ```bash
226
+ pnpm install
227
+ pnpm typecheck
228
+ pnpm test
229
+ ```
230
+
231
+ There is no build step. Pi loads the TypeScript sources in `src/` directly
232
+ through [jiti](https://github.com/unjs/jiti), and the published package ships
233
+ those sources, so `pi install npm:pi-twitterapi.io` and
234
+ `pi install git:github.com/jagaliano/pi-twitterapi.io` both work without a
235
+ compile step. `src/*.test.ts` files are excluded from the npm tarball.
236
+
237
+ The dev dependencies are version ranges (pi 1.0.2, typebox 1.3.x) resolved by
238
+ [`pnpm-lock.yaml`](pnpm-lock.yaml); CI installs them with `--frozen-lockfile`,
239
+ so a build is reproducible from the lockfile rather than from the manifest.
240
+
241
+ ## Releases
242
+
243
+ 1. `pnpm typecheck && pnpm test`
244
+ 2. Update [`CHANGELOG.md`](CHANGELOG.md), then bump the version with
245
+ `npm version patch` (or `minor`), which also creates the git tag
246
+ 3. `git push origin main --tags` — a `v*` tag makes `.github/workflows/release.yml`
247
+ publish it (needs an `NPM_TOKEN` repository secret) and open the GitHub release
248
+ 4. Or publish by hand: `npm publish --access public`
249
+
250
+ ### Rehearsing a release
251
+
252
+ The pipeline can be checked without releasing anything. It runs the tag/version
253
+ check, install, typecheck, tests and a token check, then stops unless the publish
254
+ is explicitly requested:
255
+
256
+ ```bash
257
+ gh workflow run verify-npm-token.yml --repo jagaliano/pi-twitterapi.io
258
+ gh workflow run release.yml --ref vX.Y.Z # dry run
259
+ gh workflow run release.yml --ref vX.Y.Z -f publish=true # the real publish
260
+ ```
261
+
262
+ `workflow_dispatch` runs the workflow as it exists at the given ref, so a manual
263
+ run has to target a tag that already contains the revision you want. The `v0.1.0`
264
+ tag was created before the dry-run mode existed, so dispatching against it
265
+ publishes directly.
266
+
267
+ Once the package exists on npm you can switch to
268
+ [trusted publishing](https://docs.npmjs.com/trusted-publishers/) (OIDC) and delete
269
+ `NPM_TOKEN`; the workflow works with either.
270
+
271
+ The `pi-package` keyword makes the published package eligible for the
272
+ [pi package gallery](https://pi.dev/packages); the gallery indexes npm, so there
273
+ is nothing to submit separately. `.github/workflows/ci.yml` typechecks and tests
274
+ every push and pull request.
275
+
276
+ ## License
277
+
278
+ MIT
@@ -0,0 +1,119 @@
1
+ # `pi-twitterapi.io` vs xAI's official `x_search`
2
+
3
+ This document compares the `twitter` tool from `pi-twitterapi.io` with xAI's
4
+ official **X Search** (`x_search`) server-side tool. It is meant to be read
5
+ alongside the [README](../README.md).
6
+
7
+ ## How each one works
8
+
9
+ **xAI `x_search`** is a server-side tool on the Grok API. You enable it with
10
+ `{ "type": "x_search" }` and Grok decides which of four underlying operations to
11
+ run:
12
+
13
+ - `x_keyword_search` — literal/keyword post retrieval
14
+ - `x_semantic_search` — retrieval by meaning
15
+ - `x_user_search` — profile/account discovery
16
+ - `x_thread_fetch` — a post's whole thread
17
+
18
+ xAI runs the retrieval and Grok writes the answer, so one request does
19
+ everything. It is only available on xAI models; there is no non-xAI fallback.
20
+
21
+ **`pi-twitterapi.io`** registers one `twitter` tool with 17 modes. Each mode maps
22
+ to a twitterapi.io REST endpoint; the retrieved posts, accounts or metadata are
23
+ then handed to a pi model (`twitter.synthesisModel`, else the session model) that
24
+ writes the answer with citations. Retrieval and synthesis are two separate steps,
25
+ which is why the two are priced differently.
26
+
27
+ ## Capability comparison
28
+
29
+ | Capability | xAI `x_search` | `pi-twitterapi.io` |
30
+ |---|---|---|
31
+ | Retrieval source | xAI server-side X index | twitterapi.io REST API |
32
+ | Credential | `XAI_API_KEY` | `TWITTERAPI_IO_API_KEY` |
33
+ | Keyword post search | ✅ `x_keyword_search` | ✅ `mode=posts` → `/twitter/tweet/advanced_search` |
34
+ | Semantic search | ✅ `x_semantic_search` | ❌ no equivalent |
35
+ | User/account search | ✅ `x_user_search` | ✅ `mode=users` → `/twitter/user/search` |
36
+ | Thread fetch | ✅ `x_thread_fetch` | ✅ `mode=thread` → `/twitter/tweet/thread_context` |
37
+ | Account timeline | ❌ | ✅ `mode=user` → `/twitter/user/last_tweets` |
38
+ | Trends by location | ❌ | ✅ `mode=trends` → `/twitter/trends` |
39
+ | Replies to a post | ❌ | ✅ `mode=replies` → `/twitter/tweet/replies/v2` |
40
+ | Quote-posts | ❌ | ✅ `mode=quotes` → `/twitter/tweet/quotes` |
41
+ | Mentions of an account | ❌ | ✅ `mode=mentions` → `/twitter/user/mentions` |
42
+ | Followers / followings | ❌ | ✅ `mode=followers` / `mode=followings` |
43
+ | Single profile lookup | partial (via user search) | ✅ `mode=profile` → `/twitter/user/info` |
44
+ | Extended profile ("about") metadata | ❌ | ✅ `mode=about` → `/twitter/user_about` |
45
+ | Fetch posts by id | ❌ | ✅ `mode=tweets` → `/twitter/tweets` |
46
+ | Users who reposted a post | ❌ | ✅ `mode=retweeters` → `/twitter/tweet/retweeters` |
47
+ | Communities | ❌ | ✅ `mode=community` → `/twitter/community/tweets` |
48
+ | Lists | ❌ | ✅ `mode=list` → `/twitter/list/tweets_timeline` |
49
+ | Spaces | ❌ | ✅ `mode=space` → `/twitter/spaces/detail` |
50
+ | Handle filters | ✅ `allowed_x_handles` / `excluded_x_handles`, max 20, mutually exclusive | ✅ same names, `mode=posts` |
51
+ | Date range | ✅ `from_date` / `to_date` (`YYYY-MM-DD`) | ✅ same names, `mode=posts`; unix `sinceTime`/`untilTime` for `quotes`/`mentions` |
52
+ | Result order | chosen by the model | ✅ `queryType` (`Latest`/`Top`), `replySort` (`Relevance`/`Latest`/`Likes`) |
53
+ | Item-count control | ❌ | ✅ `count` (posts/users/trends), `limit` (many modes) |
54
+ | Image understanding | ✅ `enable_image_understanding` | ✅ `enableImageUnderstanding` (attached only when the model accepts images) |
55
+ | Video understanding | ✅ `enable_video_understanding` | ⚠️ poster frame only — chat models cannot ingest video, and this is disclosed |
56
+ | Answer generation | Grok (xAI) | any pi model: `twitter.synthesisModel`, else the session model, with runtime fallback |
57
+ | Citations | xAI annotations/citations | derived from fetched permalinks; unmatched X links dropped and disclosed |
58
+ | Billing | xAI model tokens + per post/profile fetched | twitterapi.io credits + your synthesis model's tokens |
59
+ | Tool shape | one `x_search` call | one `twitter` tool, 17 modes |
60
+ | Availability | xAI models only | any pi session with a twitterapi.io key |
61
+
62
+ ## Parameter mapping
63
+
64
+ | xAI `x_search` | `pi-twitterapi.io` |
65
+ |---|---|
66
+ | `allowed_x_handles` | `allowed_x_handles` (`mode=posts`) |
67
+ | `excluded_x_handles` | `excluded_x_handles` (`mode=posts`) |
68
+ | `from_date` | `from_date` (`mode=posts`) |
69
+ | `to_date` | `to_date` (`mode=posts`) |
70
+ | `enable_image_understanding` | `twitter.enableImageUnderstanding` setting |
71
+ | `enable_video_understanding` | `twitter.enableVideoUnderstanding` setting (poster frames) |
72
+ | — | `mode`, `tweet`, `user`, `userId`, `woeid`, `ids`, `communityId`, `listId`, `spaceId`, `queryType`, `replySort`, `count`, `limit`, `pageSize`, `includeReplies`, `sinceTime`, `untilTime` |
73
+
74
+ ## Where `x_search` is stronger
75
+
76
+ **Semantic search.** `x_semantic_search` retrieves posts by meaning, not word
77
+ match. twitterapi.io exposes no semantic endpoint — `advanced_search` is
78
+ keyword/operator based. The closest approximation is to let the synthesis model
79
+ expand a natural-language question into advanced-search operators
80
+ (`"a" OR "b"`, `min_faves:`, `lang:`, `-filter:replies`), which is a heuristic,
81
+ not true semantic retrieval.
82
+
83
+ ## Where `pi-twitterapi.io` is stronger
84
+
85
+ Everything below has no `x_search` equivalent and is a first-class mode here:
86
+ account timeline, trends, replies, quotes, mentions, followers, followings,
87
+ profile, about, fetch-by-id, retweeters, communities, lists and Spaces.
88
+
89
+ ## Behavioral differences worth knowing
90
+
91
+ - **Two steps, not one.** `x_search` bills xAI tokens plus per-item retrieval;
92
+ this extension bills twitterapi.io credits plus tokens on the pi model you
93
+ choose. Choosing a cheap synthesis model for summaries keeps cost down.
94
+ - **Citations are reconstructed.** This extension never trusts model output for
95
+ sources: citations are derived from the permalinks actually fetched, and an X
96
+ link the model invents is dropped from `Sources` and disclosed in `## Notes`.
97
+ - **Retrieval honesty.** Paging can stop early (page cap, cursor cycle, missing
98
+ cursor); when it does, the answer carries a note saying the results may be
99
+ incomplete. Trends have no permalink, so their sources are X search URLs.
100
+ - **Cancellation is not retried** by the synthesis fallback. A cancellation is
101
+ authoritative from the aborted signal or `AbortError`, not from provider text;
102
+ a message such as "connection aborted" is classified as a transport failure and
103
+ routed like any other.
104
+ - **Images respect the answering model.** Media is attached only for a model that
105
+ accepts image input; if the primary fails and a text-only fallback answers, the
106
+ images are dropped (and the answer discloses it) instead of being sent to a model
107
+ that would reject them.
108
+ - **Date boundaries are 04:00 UTC as observed, with conservative padding.** The
109
+ upstream resolves `since:` day boundaries at 04:00 UTC in the probed season; if
110
+ that is really US-Eastern local midnight it shifts to 05:00 UTC in winter, so the
111
+ start padding is one hour wider to avoid silently dropping the first hour of the
112
+ local day. Far-west offsets can lose the tail of the requested day (disclosed),
113
+ and far-east offsets can spend free pages on the newer trim band.
114
+
115
+ ## Sources
116
+
117
+ - xAI X Search docs: https://docs.x.ai/developers/tools/x-search
118
+ - xAI tool usage details: https://docs.x.ai/developers/tools/tool-usage-details
119
+ - twitterapi.io docs: https://docs.twitterapi.io/introduction
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "pi-twitterapi.io",
3
+ "version": "0.1.1",
4
+ "description": "twitterapi.io-backed X/Twitter search extension for pi coding agent",
5
+ "author": "José Antonio Galiano Sandoval",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "twitter",
10
+ "twitterapi.io",
11
+ "x"
12
+ ],
13
+ "license": "MIT",
14
+ "homepage": "https://github.com/jagaliano/pi-twitterapi.io#readme",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/jagaliano/pi-twitterapi.io.git"
18
+ },
19
+ "bugs": {
20
+ "url": "https://github.com/jagaliano/pi-twitterapi.io/issues"
21
+ },
22
+ "type": "module",
23
+ "files": [
24
+ "src",
25
+ "!src/**/*.test.ts",
26
+ "README.md",
27
+ "CHANGELOG.md",
28
+ "docs"
29
+ ],
30
+ "pi": {
31
+ "extensions": [
32
+ "./src/index.ts"
33
+ ],
34
+ "image": "https://raw.githubusercontent.com/jagaliano/pi-twitterapi.io/main/preview.png"
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "test": "tsx --test src/**/*.test.ts"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "devDependencies": {
44
+ "@earendil-works/pi-coding-agent": "^1.0.2",
45
+ "@earendil-works/pi-tui": "^1.0.2",
46
+ "@types/node": "^26.6.4",
47
+ "tsx": "^4.23.15",
48
+ "typebox": "^1.3.34",
49
+ "typescript": "^6.0.3"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": "*",
53
+ "@earendil-works/pi-tui": "*",
54
+ "typebox": "*"
55
+ }
56
+ }
@@ -0,0 +1,101 @@
1
+ import { toBase64, type ImageAttachment } from "../synthesize.js";
2
+
3
+ const MAX_MEDIA_BYTES = 8 * 1024 * 1024;
4
+ const MEDIA_TIMEOUT_MS = 20_000;
5
+
6
+ /** Read a body while enforcing a byte cap, so an oversized response is never fully buffered. */
7
+ async function readCapped(response: Response, limit: number): Promise<Uint8Array | undefined> {
8
+ const body = response.body;
9
+ if (!body) return undefined;
10
+ const reader = body.getReader();
11
+ const chunks: Uint8Array[] = [];
12
+ let total = 0;
13
+ try {
14
+ for (;;) {
15
+ const { done, value } = await reader.read();
16
+ if (done) break;
17
+ if (!value) continue;
18
+ total += value.byteLength;
19
+ if (total > limit) {
20
+ await reader.cancel().catch(() => undefined);
21
+ return undefined;
22
+ }
23
+ chunks.push(value);
24
+ }
25
+ } finally {
26
+ reader.releaseLock();
27
+ }
28
+ const bytes = new Uint8Array(total);
29
+ let offset = 0;
30
+ for (const chunk of chunks) {
31
+ bytes.set(chunk, offset);
32
+ offset += chunk.byteLength;
33
+ }
34
+ return bytes;
35
+ }
36
+
37
+ export interface FetchMediaLimits {
38
+ /** Maximum accepted image size in bytes (default 8 MiB). */
39
+ maxBytes?: number;
40
+ /** Per-download deadline covering the body read (default 20s). */
41
+ timeoutMs?: number;
42
+ }
43
+
44
+ /** Twitter media CDN domains whose URLs may be downloaded for synthesis. */
45
+ export const ALLOWED_MEDIA_HOSTS = ["twimg.com"] as const;
46
+
47
+ /**
48
+ * True for an HTTPS URL on a known Twitter media host.
49
+ *
50
+ * Post media URLs come from the upstream API and are therefore attacker-
51
+ * influenceable: without this check a crafted URL could point the download at
52
+ * localhost or a private service (SSRF). Redirects are additionally refused at
53
+ * fetch time so an allowed host cannot bounce the request inward.
54
+ */
55
+ export function isAllowedMediaUrl(raw: string): boolean {
56
+ let parsed: URL;
57
+ try {
58
+ parsed = new URL(raw);
59
+ } catch {
60
+ return false;
61
+ }
62
+ if (parsed.protocol !== "https:") return false;
63
+ const host = parsed.hostname.toLowerCase();
64
+ return ALLOWED_MEDIA_HOSTS.some((suffix) => host === suffix || host.endsWith(`.${suffix}`));
65
+ }
66
+
67
+ export function createFetchMedia(fetcher: typeof fetch, callerSignal?: AbortSignal, limits: FetchMediaLimits = {}) {
68
+ const maxBytes = limits.maxBytes ?? MAX_MEDIA_BYTES;
69
+ const timeoutMs = limits.timeoutMs ?? MEDIA_TIMEOUT_MS;
70
+ return async function fetchMedia(url: string, timeoutMsOverride?: number): Promise<ImageAttachment | undefined> {
71
+ const budget = timeoutMsOverride === undefined ? timeoutMs : Math.max(1, Math.min(timeoutMsOverride, timeoutMs));
72
+ if (callerSignal?.aborted) return undefined;
73
+ // Media URLs are attacker-influenceable upstream data, so the host and
74
+ // scheme are checked before any request is made.
75
+ if (!isAllowedMediaUrl(url)) return undefined;
76
+ // A stalled image must not outlive the tool call: the download carries the
77
+ // caller's signal and its own deadline, covering the body read too. A caller
78
+ // may shorten that deadline (the media phase passes what remains of its
79
+ // budget) but never extend it.
80
+ const controller = new AbortController();
81
+ const onAbort = () => controller.abort();
82
+ callerSignal?.addEventListener("abort", onAbort, { once: true });
83
+ const timer = setTimeout(() => controller.abort(), budget);
84
+ try {
85
+ const response = await fetcher(url, { signal: controller.signal, redirect: "error" });
86
+ if (!response.ok) return undefined;
87
+ const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() ?? "";
88
+ if (!mimeType.startsWith("image/")) return undefined;
89
+ const declared = Number(response.headers.get("content-length") ?? Number.NaN);
90
+ if (Number.isFinite(declared) && declared > maxBytes) return undefined;
91
+ const bytes = await readCapped(response, maxBytes);
92
+ if (!bytes) return undefined;
93
+ return { data: toBase64(bytes), mimeType };
94
+ } catch {
95
+ return undefined;
96
+ } finally {
97
+ clearTimeout(timer);
98
+ callerSignal?.removeEventListener("abort", onAbort);
99
+ }
100
+ };
101
+ }