@gtrabanco/pi-nan-provider 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -270,29 +270,33 @@ Since 1.0.0 the package version **aligns its MAJOR.MINOR with the pi release lin
270
270
  the `SessionEntry` union, expanded `TurnEndEvent`, removed `shouldStopAfterTurn`,
271
271
  `SessionManager` canonical for provider context. The package touches none of them.
272
272
  - Since 0.10.0, this package uses native MCP via `pi.registerMcpServer()`.
273
- Both servers are session-scoped, visible in `/mcp` with source "extension".
273
+ The server is session-scoped, visible in `/mcp` with source "extension".
274
274
  Server names and tool IDs:
275
- - `nan-search` → tools: `mcp__nan_search__web_search`
276
- (official remote, HTTP, `Authorization: Bearer <key>` header)
277
275
  - `nan-media` → tools: `mcp__nan_media__generate_image/edit_image/text_to_speech/list_voices/speech_to_text/list_models/embed_text/rerank_documents/get_quota`
278
276
  (community stdio, `npx -y nan-mcp-server@1.3.0`, 120s timeout; 9 tools as of 1.3.0)
279
- - `exposure: "direct"` on both — tools are declared to the model.
277
+ - `exposure: "direct"` — tools are declared to the model.
280
278
  - User `mcp.json` entries with the same name take precedence.
281
279
  - `/nan-mcp enable/disable` calls `pi.registerMcpServer()` / `pi.unregisterMcpServer()` directly.
282
- - Missing `NAN_API_KEY` at load: web-search server NOT registered (console.warn).
280
+ - Missing `NAN_API_KEY` at load: media server NOT registered (console.warn).
281
+ - **`nan-search` was REMOVED in 1.2.1.** NaN's official remote MCP server
282
+ (`https://api.nan.builders/mcp`) now exposes ZERO tools: `initialize` returns an
283
+ empty `capabilities.tools` and `tools/list` returns `{"tools":[]}` (verified with a
284
+ valid key on 2026-10-15), and NaN deleted every MCP mention from its docs (the old
285
+ `/docs/mcp` redirects to `/docs/agent-setup`). The `web_search` tool no longer
286
+ exists anywhere in NaN's surface. Users must bring their own `web_search`/fetch MCP
287
+ server in `mcp.json` (README documents this). `NAN_MCP_TOOLS` is gone with it.
283
288
  NaN integration facts remain:
284
289
  - pi >=0.99.2 sanitizes MCP tool/namespace names by replacing every character except
285
- `[A-Za-z0-9_]` with `"_"` (0.99.0–0.99.1 kept `-`). So `mcp__nan-search__web_search`
286
- became `mcp__nan_search__web_search` and `mcp__nan-media__*` became
287
- `mcp__nan_media__*`. Server names differing only in `-`/`_` now collide
288
- (`nan-search` and `nan-media` do not collide, they differ in more than one character).
290
+ `[A-Za-z0-9_]` with `"_"` (0.99.0–0.99.1 kept `-`). So `mcp__nan-media__*` keeps its
291
+ underscore form from 0.99.2 onward. (The removed `nan-search` server used to
292
+ demonstrate the hyphen→underscore rename; do not rely on it.)
289
293
  - pi 0.99.2+/1.0 accept `McpServerConfig.description` (system-prompt listing plus
290
294
  tool-search ranking); `validateMcpServerConfig` in 0.99.0–0.99.1 ignores unknown
291
295
  keys, so passing `description` stays compatible.
292
296
  - NaN's official remote MCP server: `https://api.nan.builders/mcp` (host
293
297
  root, NOT /v1; JSON-RPC 2.0 over streamable HTTP, stateless; same `sk-`
294
298
  key, shared rate limit/quota). Spec: https://nan.builders/openapi.json
295
- (tag "MCP"). Currently exposes `web_search`.
299
+ (tag "MCP"). **Currently exposes no tools** (see the removal note above).
296
300
  - Community `nan-mcp-server` (https://github.com/luciferfran/nan-mcp-server):
297
301
  stdio MCP server, version-pinned via NAN_MEDIA_MCP_VERSION (default 1.3.0)
298
302
  or full command override via NAN_MEDIA_MCP_COMMAND. Tools (1.3.0): generate_image,
@@ -340,8 +344,26 @@ Since 1.0.0 the package version **aligns its MAJOR.MINOR with the pi release lin
340
344
  `src/reasoning-only-stream-guard.ts` (issue #21) keys on that marker;
341
345
  earlier comment/message text said "60,000 characters" and was corrected to
342
346
  the documented figures.
343
- - **pi 1.2.0 / pi-ai 1.2.0 compat** (verified 2026-10-14, the current verified
344
- baseline): the peer range stays `">=0.99.0 <2"` (1.2.0 is inside it). The pi-ai
347
+ - **pi 1.3.0 / pi-ai 1.3.0 compat** (verified 2026-10-15, the current verified
348
+ baseline): devDependencies bumped 1.2.0 → 1.3.0; the peer range stays
349
+ `">=0.99.0 <2"`. The pi-ai 1.3.0 `compat.d.ts` and `api/openai-completions.d.ts`
350
+ surfaces are byte-identical to 1.2.0; `index.d.ts` only drops the
351
+ `utils/assistant-message-frame` re-export (unused here), and the
352
+ `thinkingLevelMap.off` branch in `buildRequest` still fires. **pi-ai 1.3.0 lands
353
+ the upstream fix for issue #3**: `transformMessages` now DROPS cross-model
354
+ `thinking` blocks (`if (!isSameModel) return []`) instead of downgrading them to
355
+ unbounded text — the cross-model-thinking guard is therefore VERSION-GATED
356
+ (`CROSS_MODEL_GUARD_PI_AI_CEILING = "1.3.0"`; registers only on hosts older than
357
+ that, unknown version keeps it on) and is DEPRECATED for removal in the next
358
+ package release once the peer floor reaches `>=1.3.0`. `RETRYABLE_PROVIDER_ERROR_PATTERN`
359
+ expands with gateway/upstream wording ("service is temporarily unavailable" #10965,
360
+ upstream wrapper text #10842) and #10926 fixes OpenAI-compatible streams where
361
+ LiteLLM sends reasoning and content in the same chunk — both relevant to NaN's
362
+ LiteLLM gateway, both upstream-side. Package tests (319) and typecheck pass against
363
+ 1.3.0; the issue-3 acceptance tests were re-pointed at the new upstream reality
364
+ (thinking scenario pins pi-ai's drop; the opaque-400 reclassification contract is
365
+ exercised via oversized user content pi-ai cannot shrink).
366
+ - **pi 1.2.0 / pi-ai 1.2.0 compat** (verified 2026-10-14): the peer range stays `">=0.99.0 <2"` (1.2.0 is inside it). The pi-ai
345
367
  1.2.0 index/compat type surface is byte-identical to 1.0.0 (`index.d.ts` diff is
346
368
  empty); the only additions are `KnownProvider` gaining `"azure"` (renamed from
347
369
  `"azure-openai-responses"` in pi 1.0.3 — not used here), a new
package/README.es.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # @gtrabanco/pi-nan-provider
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
- [![Version](https://img.shields.io/badge/version-0.11.0-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
4
+ [![Version](https://img.shields.io/badge/version-1.2.1-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
5
+ [![Built with NaN](https://nan.builders/badge/built-with-nan-compact.svg)](https://cloud.nan.builders/r/7GK06FX8)
5
6
 
6
- [NaN Builders](https://nan.builders) model provider + MCP nativo para [pi](https://github.com/earendil-works/pi).
7
+ [NaN Builders](https://cloud.nan.builders/r/7GK06FX8) model provider + MCP nativo para [pi](https://github.com/earendil-works/pi).
7
8
 
8
- Registra el proveedor `nan` vía `pi.registerProvider()` usando la API OpenAI-compatible de NaN (`https://api.nan.builders/v1`), y registra servidores MCP nativos con `pi.registerMcpServer()`.
9
+ Registra el proveedor `nan` vía `pi.registerProvider()` usando la API OpenAI-compatible de NaN (`https://api.nan.builders/v1`), y registra el servidor MCP de media de la comunidad con `pi.registerMcpServer()`.
9
10
 
10
11
  ---
11
12
 
@@ -51,6 +52,8 @@ Al cambiar de modelo, pi-ai reenvía el razonamiento del modelo anterior como te
51
52
 
52
53
  Este paquete **elimina todos los bloques de razonamiento cross-model reenviados**, de modo que cambiar de un modelo de 1M de contexto a uno de 262K (`qwen3.6`) ya no desborda la ventana. Las respuestas y los tool results de los modelos no se tocan — solo se quitan sus trazas internas de razonamiento, así que `qwen3.6` puede seguir respondiendo sobre lo que hizo otro modelo. El razonamiento del mismo modelo no se toca nunca, y el guard solo actúa sobre peticiones dirigidas a los proveedores de este paquete. Pon `NAN_THINKING_GUARD=0` para desactivarlo.
53
54
 
55
+ > **pi 1.3.0+: este guard ya no se registra.** pi-ai 1.3.0 elimina el thinking cross-model por sí mismo (el fix upstream ya está publicado), así que el guard solo se activa en pi < 1.3.0 y está **deprecado — se eliminará en la próxima versión del paquete**. Si usas pi < 1.3.0, actualiza pi para mantener la mitigación; en pi ≥ 1.3.0 no hay que hacer nada.
56
+
54
57
  Si una petición sigue desbordando — guard desactivado, inflado que no es un bloque de razonamiento (tool outputs grandes, imágenes) o una ventana de destino más pequeña — NaN responde con el mismo 400 genérico en lugar de nombrar el desbordamiento, y la auto-compactación de pi no lo reconoce, así que la sesión se queda atascada en el techo. Por eso el proveedor **vuelve a comprobar el tamaño de la petición a la salida**: cuando llega ese 400 genérico para una petición estimada por encima de la ventana del modelo, el error se reescribe como un mensaje de context overflow que pi reconoce, de modo que compacta y reintenta en vez de bloquearse. Un 400 genérico en una petición dentro de la ventana no se toca, así que nunca se etiquetan mal errores no relacionados.
55
58
 
56
59
  ### ⏱️ Streams truncados intermitentes (auto-retry, sin stall silencioso)
@@ -101,22 +104,48 @@ Consigue una clave en la [plataforma NaN](https://cloud.nan.builders/r/7GK06FX8)
101
104
 
102
105
  ## 🔌 MCP nativo
103
106
 
104
- Dado que [pi 0.99.0 incluye un cliente MCP integrado](https://github.com/earendil-works/pi/blob/main/docs/usage.md), este paquete migra sus puentes a MCP nativo (requiere `pi >=0.99, <2` — pi 0.99 hasta 1.0 son compatibles; el rango peer se amplió a `<2` en 0.11.0).
107
+ Dado que [pi 0.99.0 incluye un cliente MCP integrado](https://github.com/earendil-works/pi/blob/main/docs/usage.md), este paquete usa MCP nativo (requiere `pi >=0.99, <2` — pi 0.99 hasta 1.2 son compatibles; el rango peer se amplió a `<2` en 0.11.0).
105
108
 
106
- Ambos puentes están **activados por defecto** (por sesión, visibles en `/mcp`). Usa `/nan-mcp` para gestionarlos.
109
+ El puente de media está **activado por defecto** (por sesión, visible en `/mcp`). Usa `/nan-mcp` para gestionarlo.
107
110
 
108
- > [!TIP]
109
- > ¿Prefieres configurar los servidores MCP de NaN tú mismo? Desde pi 0.99.2/1.0, un servidor MCP HTTP en el `mcp.json` global puede autenticarse con tu token de `/login nan` en vez de una clave copiada: `{ "auth": { "provider": "nan" } }` (solo en el `mcp.json` global; requiere `https`). El registro integrado de `nan-search` sigue usando `NAN_API_KEY`/la credencial almacenada.
111
+ > [!WARNING]
112
+ > **El servidor MCP remoto oficial de NaN ya no expone ninguna herramienta.** Hasta 1.2.0 este paquete
113
+ > incluía un puente `nan-search` apuntando a `https://api.nan.builders/mcp`, que exponía
114
+ > `web_search`. Ese servidor ahora responde a `initialize` con `capabilities.tools` vacío y a
115
+ > `tools/list` con `{"tools":[]}` — verificado con una clave válida el 2026-10-15 — y NaN eliminó toda
116
+ > mención a MCP de su documentación (la antigua página `/docs/mcp` ahora redirige a `/docs/agent-setup`).
117
+ > El puente se eliminó en **1.2.1** porque registrar un servidor que no expone nada solo produce un
118
+ > servidor sin herramientas en `/mcp`.
119
+ >
120
+ > **Trae tu propio `web_search` y fetch.** Instala un servidor MCP que los proporcione (p. ej. el par
121
+ > `fetch` + `brave-search` de los servidores MCP oficiales, o cualquier otro servidor MCP de
122
+ > búsqueda/fetch) y decláralo en tu `mcp.json` global:
123
+ >
124
+ > ```json
125
+ > {
126
+ > "mcpServers": {
127
+ > "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] },
128
+ > "web-search": {
129
+ > "command": "npx",
130
+ > "args": ["-y", "@modelcontextprotocol/server-brave-search"],
131
+ > "env": { "BRAVE_API_KEY": "tu-clave" }
132
+ > }
133
+ > }
134
+ > }
135
+ > ```
136
+ >
137
+ > Vuelve a comprobar `https://api.nan.builders/mcp` periódicamente: si NaN publica herramientas otra
138
+ > vez, este paquete puede restaurar el puente.
110
139
 
111
140
  ### 🛠️ Comando de Gestión: `/nan-mcp`
112
141
 
113
142
  | Comando | Efecto |
114
143
  | :--- | :--- |
115
- | `/nan-mcp status` | Muestra el estado actual de ambos puentes y la resolución de la clave API. |
116
- | `/nan-mcp enable [target]` | Activa `web-search` o `nan-mcp-server` (persiste). |
117
- | `/nan-mcp disable [target]` | Desactiva un puente de forma persistente. |
144
+ | `/nan-mcp status` | Muestra el estado del puente de media y la resolución de la clave API. |
145
+ | `/nan-mcp enable [target]` | Activa `nan-mcp-server` (persiste). |
146
+ | `/nan-mcp disable [target]` | Lo desactiva de forma persistente. |
118
147
 
119
- Ambos puentes resuelven la credencial almacenada de `/login nan` (almacenada → env). Si ninguna se resuelve, el puente no se registra y se muestra un aviso en la salida de estado.
148
+ El puente resuelve la credencial almacenada de `/login nan` (almacenada → env). Si ninguna se resuelve, el servidor no se registra y se muestra un aviso en la salida de estado.
120
149
 
121
150
  > [!NOTE]
122
151
  > El registro ocurre cuando se carga la extensión. Si ejecutas `/login nan` **después** de que la sesión haya empezado, la factoría ya se ejecutó — usa `/reload` (o `/nan-mcp enable`) para registrar los puentes sin reiniciar pi.
@@ -171,15 +200,7 @@ conector con `pi config` → Built-in extensions → `mcp`.
171
200
 
172
201
  ---
173
202
 
174
- ### 1. Servidor MCP oficial de NaN
175
- *Puente oficial para herramientas remotas vía [https://api.nan.builders/mcp](https://nan.builders/docs/api).*
176
-
177
- - **`mcp__nan_search__web_search(query, ...)`**: Realiza búsquedas web a través del gateway de NaN.
178
- > pi 0.99.0–0.99.1 usaba la forma con guiones (`mcp__nan-search__web_search`); pi 0.99.2+ sanitiza
179
- > los nombres de herramientas a solo `[A-Za-z0-9_]`, así que los guiones se convirtieron en
180
- > guiones bajos. La forma con guiones bajos aplica desde 0.99.2 hasta pi 1.0.
181
-
182
- ### 2. Servidor MCP de Media (Comunidad)
203
+ ### Servidor MCP de Media (Comunidad)
183
204
  *Conecta [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) mediante un cliente stdio local mínimo.*
184
205
 
185
206
  - **Por sesión**: El servidor se conecta al registrarse (eager). Aparece en `/mcp` con fuente "extension".
@@ -197,10 +218,9 @@ conector con `pi config` → Built-in extensions → `mcp`.
197
218
  | `mcp__nan_media__rerank_documents` | Reordenación de documentos (Qwen3-Reranker) |
198
219
  | `mcp__nan_media__get_quota` | Cuota de tokens por modelo (usado / tope / restante / reinicio) |
199
220
 
200
- > pi 0.99.0–0.99.1 usaba las formas con guiones (`mcp__nan-search__web_search`,
201
- > `mcp__nan-media__*`); pi 0.99.2+ sanitiza los nombres de herramientas a solo
202
- > `[A-Za-z0-9_]`, así que los guiones se convirtieron en guiones bajos. La forma
203
- > con guiones bajos aplica desde 0.99.2 hasta pi 1.0.
221
+ > pi 0.99.0–0.99.1 usaba la forma con guiones (`mcp__nan-media__*`); pi 0.99.2+ sanitiza los
222
+ > nombres de herramientas a solo `[A-Za-z0-9_]`, así que los guiones se convirtieron en guiones
223
+ > bajos. La forma con guiones bajos aplica desde 0.99.2 hasta pi 1.2.
204
224
 
205
225
  #### 🔧 Configuración del Puente de Media
206
226
 
@@ -210,7 +230,6 @@ conector con `pi config` → Built-in extensions → `mcp`.
210
230
  | `NAN_MEDIA_MCP_VERSION` | `1.3.0` | Versión del servidor fijada (recomendado). |
211
231
  | `NAN_MEDIA_MCP_COMMAND` | — | Override del comando personalizado. |
212
232
  | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Timeout por llamada. |
213
- | `NAN_MCP_TOOLS` | — | Override para el puente oficial (`0` para desactivar). |
214
233
 
215
234
  #### 🤖 Detección Automática de Actualizaciones
216
235
 
@@ -222,6 +241,12 @@ bun run check-nan-mcp-server --json # JSON para máquinas
222
241
  bun run check-nan-mcp-server --issue # crea/refresca el issue
223
242
  ```
224
243
 
244
+ Las actualizaciones del SDK de pi (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent`)
245
+ también están automatizadas: el workflow semanal `check-pi-sdk-versions` abre el issue
246
+ `sdk-monitor` **y** prepara un PR con el bump de devDependencies (`bun test` +
247
+ `bun run typecheck` + diff de superficie de tipos incluidos) para revisión humana — ver
248
+ `docs/sdk-version-monitoring.md`.
249
+
225
250
  ---
226
251
 
227
252
  ## 📊 Uso de Cuotas: `/nan-usage`
@@ -384,4 +409,4 @@ bun test # Ejecutar todos los tests
384
409
  bun run typecheck # Ejecutar typechecking
385
410
  ```
386
411
 
387
- *Las versiones siguen semver estricto. CI publica automáticamente al hacer merge a `main`.*
412
+ *Las versiones siguen semver estricto. CI publica automáticamente al hacer merge a `main`.*
package/README.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # @gtrabanco/pi-nan-provider
2
2
 
3
3
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
- [![Version](https://img.shields.io/badge/version-0.11.0-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
4
+ [![Version](https://img.shields.io/badge/version-1.2.1-blue)](https://github.com/gtrabanco/pi-nan-provider/releases)
5
+ [![Built with NaN](https://nan.builders/badge/built-with-nan-compact.svg)](https://cloud.nan.builders/r/7GK06FX8)
5
6
 
6
- [NaN Builders](https://nan.builders) model provider + native MCP servers for [pi](https://github.com/earendil-works/pi).
7
+ [NaN Builders](https://cloud.nan.builders/r/7GK06FX8) model provider + a native MCP server for [pi](https://github.com/earendil-works/pi).
7
8
 
8
- Registers the `nan` provider via `pi.registerProvider()` using NaN's OpenAI-compatible API (`https://api.nan.builders/v1`), and registers MCP servers via pi's native `pi.registerMcpServer()`.
9
+ Registers the `nan` provider via `pi.registerProvider()` using NaN's OpenAI-compatible API (`https://api.nan.builders/v1`), and registers the community media MCP server via pi's native `pi.registerMcpServer()`.
9
10
 
10
11
  ---
11
12
 
@@ -51,6 +52,8 @@ When you switch models, pi-ai replays the previous model's reasoning as plain as
51
52
 
52
53
  This package **drops every replayed cross-model reasoning block**, so switching from a 1M-context model to a 262K one (`qwen3.6`) no longer overflows the window. The models' answers and tool results are untouched — only their internal reasoning traces are removed, so `qwen3.6` can still answer about what another model did. Same-model reasoning is never altered, and the guard only acts on requests targeting this package's providers. Set `NAN_THINKING_GUARD=0` to disable it.
53
54
 
55
+ > **pi 1.3.0+: this guard no longer registers.** pi-ai 1.3.0 drops cross-model thinking itself (the upstream fix landed), so the guard only activates on pi < 1.3.0 and is **deprecated — it will be removed in the next package release**. If you run pi < 1.3.0, update pi to keep the mitigation; no action is needed on pi ≥ 1.3.0.
56
+
54
57
  If a request still overflows — the guard is disabled, the inflation is not a reasoning block (large tool outputs, images), or the destination window is simply smaller — NaN answers the same generic 400 instead of naming the overflow, and pi's auto-compaction does not recognize it, so the session wedges at the ceiling. The provider therefore **re-checks the request size on the way out**: when that generic 400 arrives for a request estimated over the model's window, the error is rewritten into a context-overflow message pi recognizes, so it compacts and retries instead of stalling. A generic 400 on a within-window request is left untouched, so unrelated errors are never mislabelled.
55
58
 
56
59
  ### ⏱️ Intermittent truncated streams (auto-retry, no silent stall)
@@ -99,24 +102,50 @@ You can override any model's `compat` per-model in `~/.pi/agent/models.json` (pi
99
102
 
100
103
  Get a key from the [NaN platform](https://cloud.nan.builders/r/7GK06FX8) (user settings → API Keys; referral link).
101
104
 
102
- ## 🔌 Native MCP Servers
105
+ ## 🔌 Native MCP Server
103
106
 
104
- Since [pi 0.99.0 ships a built-in MCP client](https://github.com/earendil-works/pi/blob/main/docs/usage.md), this package migrates its bridges to native MCP (requires `pi >=0.99, <2` — pi 0.99 through 1.0 are supported; the peer range was widened to `<2` in 0.11.0).
107
+ Since [pi 0.99.0 ships a built-in MCP client](https://github.com/earendil-works/pi/blob/main/docs/usage.md), this package bridges to native MCP (requires `pi >=0.99, <2` — pi 0.99 through 1.2 are supported; the peer range was widened to `<2` in 0.11.0).
105
108
 
106
- Both bridges are **enabled by default** (session-scoped, visible in `/mcp`). Use `/nan-mcp` to manage them.
109
+ The media bridge is **enabled by default** (session-scoped, visible in `/mcp`). Use `/nan-mcp` to manage it.
107
110
 
108
- > [!TIP]
109
- > Prefer to configure NaN's MCP servers yourself? Since pi 0.99.2/1.0 an HTTP MCP server in the global `mcp.json` can authenticate with your `/login nan` token instead of a copied key: `{ "auth": { "provider": "nan" } }` (global `mcp.json` only; `https` required). The bundled `nan-search` registration keeps using `NAN_API_KEY`/the stored credential.
111
+ > [!WARNING]
112
+ > **NaN's official remote MCP server no longer exposes any tool.** Until 1.2.0 this package shipped a
113
+ > `nan-search` bridge pointing at `https://api.nan.builders/mcp`, which exposed
114
+ > `web_search`. That server now answers `initialize` with an empty `capabilities.tools` and
115
+ > `tools/list` with `{"tools":[]}` — verified with a valid key on 2026-10-15 — and NaN removed every
116
+ > MCP mention from its documentation (the old `/docs/mcp` page now redirects to `/docs/agent-setup`).
117
+ > The bridge was removed in **1.2.1** because registering a server that exposes nothing only produces
118
+ > a tool-less server in `/mcp`.
119
+ >
120
+ > **Bring your own `web_search` and fetch.** Install an MCP server that provides them (e.g. the
121
+ > `fetch` + `brave-search` pair for the official MCP servers, or any other search/fetch MCP server)
122
+ > and declare it in your global `mcp.json`:
123
+ >
124
+ > ```json
125
+ > {
126
+ > "mcpServers": {
127
+ > "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] },
128
+ > "web-search": {
129
+ > "command": "npx",
130
+ > "args": ["-y", "@modelcontextprotocol/server-brave-search"],
131
+ > "env": { "BRAVE_API_KEY": "your-key" }
132
+ > }
133
+ > }
134
+ > }
135
+ > ```
136
+ >
137
+ > Re-check `https://api.nan.builders/mcp` periodically: if NaN publishes tools again, this package can
138
+ > restore the bridge.
110
139
 
111
140
  ### 🛠️ Management Command: `/nan-mcp`
112
141
 
113
142
  | Command | Effect |
114
143
  | :--- | :--- |
115
- | `/nan-mcp status` | Shows current state of both bridges and API key resolution. |
116
- | `/nan-mcp enable [target]` | Enables `web-search` or `nan-mcp-server` (persisted). |
117
- | `/nan-mcp disable [target]` | Disables a bridge persistently. |
144
+ | `/nan-mcp status` | Shows the media bridge state and API key resolution. |
145
+ | `/nan-mcp enable [target]` | Enables `nan-mcp-server` (persisted). |
146
+ | `/nan-mcp disable [target]` | Disables it persistently. |
118
147
 
119
- Both bridges resolve the stored `/login nan` credential (stored → env). If neither resolves, the bridge does not register and a warning is shown in the status output.
148
+ The bridge resolves the stored `/login nan` credential (stored → env). If neither resolves, the server does not register and a warning is shown in the status output.
120
149
 
121
150
  > [!NOTE]
122
151
  > Registration happens when the extension loads. If you run `/login nan` **after** the session started, the factory has already run — run `/reload` (or `/nan-mcp enable`) to register the bridges without restarting pi.
@@ -168,15 +197,7 @@ registering a server that can never connect. To get MCP in the CLI, enable the c
168
197
 
169
198
  ---
170
199
 
171
- ### 1. Official NaN MCP Server
172
- *Official remote MCP server registered natively via `pi.registerMcpServer()`.*
173
-
174
- - **`mcp__nan_search__web_search(query, ...)`**: Performs web searches through NaN's gateway.
175
- > pi 0.99.0–0.99.1 used the hyphenated form (`mcp__nan-search__web_search`); pi 0.99.2+ sanitizes
176
- > tool names to `[A-Za-z0-9_]` only, so hyphens became underscores. The underscore form applies
177
- > from 0.99.2 onward through pi 1.0.
178
-
179
- ### 2. Community Media MCP Server
200
+ ### Community Media MCP Server
180
201
  *Bridges [`nan-mcp-server`](https://github.com/luciferfran/nan-mcp-server) via a minimal local stdio client.*
181
202
 
182
203
  - **Session-scoped**: The server connects at registration time (eager). Appears in `/mcp` with source "extension".
@@ -194,10 +215,9 @@ registering a server that can never connect. To get MCP in the CLI, enable the c
194
215
  | `mcp__nan_media__rerank_documents` | Document reranking (Qwen3-Reranker) |
195
216
  | `mcp__nan_media__get_quota` | Per-model token quota (used / cap / remaining / reset) |
196
217
 
197
- > pi 0.99.0–0.99.1 used the hyphenated forms (`mcp__nan-search__web_search`,
198
- > `mcp__nan-media__*`); pi 0.99.2+ sanitizes tool names to `[A-Za-z0-9_]` only,
199
- > so hyphens became underscores. The underscore form applies from 0.99.2 onward
200
- > through pi 1.0.
218
+ > pi 0.99.0–0.99.1 used the hyphenated form (`mcp__nan-media__*`); pi 0.99.2+ sanitizes tool
219
+ > names to `[A-Za-z0-9_]` only, so hyphens became underscores. The underscore form applies from
220
+ > 0.99.2 onward through pi 1.2.
201
221
 
202
222
  #### 🔧 Media Bridge Configuration
203
223
 
@@ -207,7 +227,6 @@ registering a server that can never connect. To get MCP in the CLI, enable the c
207
227
  | `NAN_MEDIA_MCP_VERSION` | `1.3.0` | Pinned server version (recommended). |
208
228
  | `NAN_MEDIA_MCP_COMMAND` | — | Custom command override. |
209
229
  | `NAN_MEDIA_MCP_TIMEOUT_MS` | `120000` | Per-call timeout. |
210
- | `NAN_MCP_TOOLS` | — | Override for the official bridge (`0` to disable). |
211
230
 
212
231
  #### 🤖 Automated Update Detection
213
232
 
@@ -219,6 +238,11 @@ bun run check-nan-mcp-server --json # machine-readable JSON
219
238
  bun run check-nan-mcp-server --issue # create/refresh the issue
220
239
  ```
221
240
 
241
+ PI SDK updates (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent`) are automated
242
+ too: the weekly `check-pi-sdk-versions` workflow files the `sdk-monitor` issue **and**
243
+ prepares a devDependencies-bump PR (`bun test` + `bun run typecheck` + type-surface diff
244
+ included) for human review — see `docs/sdk-version-monitoring.md`.
245
+
222
246
  ---
223
247
 
224
248
  ## 📊 Quota Usage: `/nan-usage`
@@ -381,4 +405,4 @@ bun test # Run all tests
381
405
  bun run typecheck # Run typechecking
382
406
  ```
383
407
 
384
- *Releases follow strict semver. CI publishes automatically on merge to `main`.*
408
+ *Releases follow strict semver. CI publishes automatically on merge to `main`.*
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gtrabanco/pi-nan-provider",
3
- "version": "1.2.0",
4
- "description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and MCP bridges (official web search + optional community media server)",
3
+ "version": "1.3.0",
4
+ "description": "NaN Builders (api.nan.builders) model provider for pi - OpenAI-compatible registration with a models.dev-generated fallback, tier-aware live catalog, and an optional community media MCP bridge",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "pi-package",
@@ -46,8 +46,8 @@
46
46
  "@earendil-works/pi-coding-agent": ">=0.99.0 <2"
47
47
  },
48
48
  "devDependencies": {
49
- "@earendil-works/pi-ai": "1.2.0",
50
- "@earendil-works/pi-coding-agent": "1.2.0",
49
+ "@earendil-works/pi-ai": "1.3.0",
50
+ "@earendil-works/pi-coding-agent": "1.3.0",
51
51
  "@types/bun": "1.4",
52
52
  "typescript": "7"
53
53
  },
@@ -1,7 +1,7 @@
1
1
  // This file is auto-generated by scripts/generate-models.ts
2
2
  // Do not edit manually — run `bun run generate-models` to update.
3
3
  //
4
- // Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-14T15:59:01.497Z
4
+ // Source: https://models.dev/api.json (provider "nan"), fetched 2026-10-19T22:52:49.128Z
5
5
  // Provenance: every contextWindow/maxTokens/input/cost value traces to
6
6
  // models.dev or to the per-entry notes below. Nothing is invented; entries
7
7
  // models.dev documents incompletely are omitted and flagged instead.
@@ -459,7 +459,7 @@ export const NAN_GENERATED_MODELS: readonly GeneratedModelEntry[] = [
459
459
  export const GENERATED_CATALOG_META = {
460
460
  source: "https://models.dev/api.json",
461
461
  modelsDevProvider: "nan",
462
- fetchedAt: "2026-10-14T15:59:01.497Z",
462
+ fetchedAt: "2026-10-19T22:52:49.128Z",
463
463
  modelCount: 6,
464
464
  models: ["deepseek-v4-flash","gemma4","glm5.3-flash","mimo-v2.6-flash","qwen3.6","qwen3.8-flash"],
465
465
  notes: [
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Prepare an SDK update PR for the packages monitored by
4
+ * scripts/check-pi-sdk-versions.mjs: bump their devDependencies in
5
+ * package.json and emit the branch/PR metadata the
6
+ * `.github/workflows/check-pi-sdk-versions.yml` workflow needs to open the PR.
7
+ *
8
+ * This script ONLY edits package.json. Installing, testing, typechecking, the
9
+ * type-surface diff, git and `gh pr create` stay in the workflow so a failure
10
+ * anywhere aborts the PR.
11
+ *
12
+ * Usage:
13
+ * node scripts/prepare-sdk-update-pr.mjs --body-file /tmp/pr-body.md
14
+ *
15
+ * Output (stdout, single JSON line):
16
+ * {"updates":false}
17
+ * {"updates":true,"branch":"chore/pi-sdk-1.3.0","title":"...","bodyFile":"...",
18
+ * "bumps":[{"name":"@earendil-works/pi-ai","from":"1.2.0","to":"1.3.0"}]}
19
+ *
20
+ * Exit codes: 0 = nothing to do or prepared; 2 = unexpected failure.
21
+ */
22
+
23
+ import https from "node:https";
24
+ import { readFileSync, writeFileSync } from "node:fs";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const SDK_PACKAGES = ["@earendil-works/pi-ai", "@earendil-works/pi-coding-agent"];
28
+
29
+ function npmLatest(packageName) {
30
+ return new Promise((resolve, reject) => {
31
+ const req = https.get(
32
+ `https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
33
+ { headers: { accept: "application/json" } },
34
+ (res) => {
35
+ if (res.statusCode !== 200) {
36
+ reject(new Error(`npm registry returned ${res.statusCode} for ${packageName}`));
37
+ return;
38
+ }
39
+ let data = "";
40
+ res.on("data", (chunk) => (data += chunk));
41
+ res.on("end", () => {
42
+ const latest = JSON.parse(data)["dist-tags"]?.["latest"];
43
+ if (!latest) reject(new Error(`No "latest" dist-tag for ${packageName}`));
44
+ else resolve(latest);
45
+ });
46
+ },
47
+ );
48
+ req.on("error", reject);
49
+ req.setTimeout(15_000, () => {
50
+ req.destroy();
51
+ reject(new Error(`Timeout fetching ${packageName} from npm`));
52
+ });
53
+ });
54
+ }
55
+
56
+ async function main() {
57
+ const bodyFileArg = process.argv.indexOf("--body-file");
58
+ const bodyFile =
59
+ bodyFileArg !== -1 ? process.argv[bodyFileArg + 1] : "/tmp/sdk-update-pr-body.md";
60
+
61
+ const packageUrl = new URL("../package.json", import.meta.url);
62
+ const raw = readFileSync(packageUrl, "utf-8");
63
+ const pkg = JSON.parse(raw);
64
+
65
+ const bumps = [];
66
+ for (const name of SDK_PACKAGES) {
67
+ const from = pkg.devDependencies?.[name];
68
+ if (!from) throw new Error(`${name} is not a devDependency of this package`);
69
+ const to = await npmLatest(name);
70
+ if (to !== from) bumps.push({ name, from, to });
71
+ }
72
+
73
+ if (bumps.length === 0) {
74
+ console.log(JSON.stringify({ updates: false }));
75
+ return;
76
+ }
77
+
78
+ // Bump via exact-string replacement so the rest of package.json (ordering,
79
+ // indentation, other fields) is untouched.
80
+ let updated = raw;
81
+ for (const bump of bumps) {
82
+ const needle = `"${bump.name}": "${bump.from}"`;
83
+ if (!updated.includes(needle)) {
84
+ throw new Error(`Could not locate ${needle} in package.json to bump it`);
85
+ }
86
+ updated = updated.replace(needle, `"${bump.name}": "${bump.to}"`);
87
+ }
88
+ writeFileSync(packageUrl, updated);
89
+
90
+ const versionTag = bumps.map((b) => b.to).join("-");
91
+ const branch = `chore/pi-sdk-${versionTag}`;
92
+ const title = `chore: bump PI SDK to ${bumps.map((b) => `${b.name.split("/")[1]} ${b.to}`).join(" / ")}`;
93
+ const peer = pkg.peerDependencies ?? {};
94
+
95
+ const body = `## PI SDK update (automated)
96
+
97
+ Prepared from the weekly \`check-pi-sdk-versions\` run. Resolves the \`sdk-monitor\` issue
98
+ for this cycle. **A human must review the upstream changelog before merging** — a minor
99
+ pi release can change provider-relevant behavior (see \`AGENTS.md\` "Verified API facts").
100
+
101
+ ### Version bumps
102
+
103
+ | Package | From | To | Peer range |
104
+ | --- | --- | --- | --- |
105
+ ${bumps.map((b) => `| \`${b.name}\` | ${b.from} | **${b.to}** | \`${peer[b.name] ?? "n/a"}\` |`).join("\n")}
106
+
107
+ ### Review checklist
108
+
109
+ - [ ] Upstream release notes reviewed (https://pi.dev/changelog)
110
+ - [ ] \`bun test\` + \`bun run typecheck\` green (executed by CI before this PR was opened)
111
+ - [ ] Type-surface diff (below) contains no removals this package uses
112
+ - [ ] Version policy applied: package MAJOR.MINOR realigns with the pi release line
113
+ (\`package.json\` version bump included in this PR when the pi MINOR moved)
114
+
115
+ The results of the two sections below were produced by CI while preparing this PR.
116
+
117
+ ## Verification
118
+
119
+ <!-- VERIFICATION -->
120
+
121
+ ## Type-surface diff (previous → new)
122
+
123
+ <details><summary>Click to expand</summary>
124
+
125
+ <!-- SURFACE_DIFF -->
126
+
127
+ </details>
128
+ `;
129
+
130
+ writeFileSync(bodyFile, body);
131
+ console.log(
132
+ JSON.stringify({
133
+ updates: true,
134
+ branch,
135
+ title,
136
+ bodyFile,
137
+ bumps,
138
+ }),
139
+ );
140
+ }
141
+
142
+ main().catch((err) => {
143
+ console.error("Fatal:", err.message);
144
+ process.exit(2);
145
+ });
package/src/commands.ts CHANGED
@@ -1,24 +1,19 @@
1
1
  /**
2
- * `/nan-mcp` — slash command configuring both native MCP servers this
2
+ * `/nan-mcp` — slash command configuring the native MCP server this
3
3
  * package registers:
4
4
  *
5
- * - `nan-search` — the official NaN remote MCP server (api.nan.builders/mcp),
6
- * exposing `mcp__nan_search__web_search` (pi >=0.99.2). Default: enabled.
7
- * (pi 0.99.0–0.99.1 used the hyphenated form `mcp__nan-search__web_search`.)
8
5
  * - `nan-media` — the community stdio media server (flux-2-klein / kokoro /
9
6
  * whisper), exposing `mcp__nan_media__generate_image` etc. Default: enabled.
10
7
  * (pi 0.99.0–0.99.1 used the hyphenated form `mcp__nan-media__*`.)
11
8
  *
12
- * Both are session-scoped native servers (visible in /mcp, source "extension").
13
- * /nan-mcp enables/disables them via pi.registerMcpServer / pi.unregisterMcpServer,
9
+ * The server is session-scoped (visible in /mcp, source "extension").
10
+ * /nan-mcp enables/disables it via pi.registerMcpServer / pi.unregisterMcpServer,
14
11
  * persisting the toggle in nan-provider.json.
15
12
  *
16
- * Targets accept aliases: `web-search` (`search`, `web_search`, `official`,
17
- * `nan-web-search`) and `nan-mcp-server` (`media`, `media-mcp`, `nan-media`).
18
- * Explicit env vars (NAN_MCP_TOOLS, NAN_MEDIA_MCP) override the persisted
19
- * toggles for the session.
13
+ * Target aliases: `nan-mcp-server` (`media`, `media-mcp`, `nan-media`).
14
+ * Explicit env var (NAN_MEDIA_MCP) overrides the persisted toggle for the session.
20
15
  *
21
- * Native behavior changes from the old bridge:
16
+ * Native behavior:
22
17
  * - `disable` IMMEDIATELY hides tools (unregisterMcpServer disconnects the
23
18
  * server; the MCP extension hides tools when disconnected).
24
19
  * - `enable` IMMEDIATELY registers the server for the current session.
@@ -29,55 +24,31 @@
29
24
 
30
25
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
31
26
  import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
32
- import { webSearchBridgeEnabled, webSearchBridgeSource, mcpToolsEnvExplicit, mcpToolsDisabled } from "./mcp/api-key.ts";
33
27
  import { NAN_API_KEY_ENV, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey } from "./mcp/api-key.ts";
34
28
  import { NAN_STATE_FILE, MCP_SERVER_NAMES, readBridgeState, writeBridgeState, type BridgeKey, readState, writeState } from "./mcp/state.ts";
35
29
  import { connectorMissingMessage, hasMcpConnector } from "./mcp/host-support.ts";
36
30
 
37
31
  const USAGE =
38
- "Usage: /nan-mcp [status] · /nan-mcp enable [web-search|nan-mcp-server] · /nan-mcp disable [web-search|nan-mcp-server]";
32
+ "Usage: /nan-mcp [status] · /nan-mcp enable [nan-mcp-server] · /nan-mcp disable [nan-mcp-server]";
39
33
 
40
34
  /** Canonical bridge targets with aliases. */
41
35
  const TARGETS: Record<string, BridgeKey> = {
42
- "web-search": "webSearch",
43
- search: "webSearch",
44
- web_search: "webSearch",
45
- official: "webSearch",
46
- "nan-web-search": "webSearch",
47
36
  "nan-mcp-server": "mediaMcp",
48
37
  media: "mediaMcp",
49
38
  "media-mcp": "mediaMcp",
50
39
  "nan-media": "mediaMcp",
51
40
  };
52
41
 
53
- function targetName(bridge: BridgeKey): string {
54
- return bridge === "webSearch" ? "web-search" : "nan-mcp-server";
42
+ function envOverrideLabel(): string {
43
+ return `env NAN_MEDIA_MCP=${process.env.NAN_MEDIA_MCP ?? ""} (overrides persisted)`;
55
44
  }
56
45
 
57
- function envOverrideLabel(bridge: BridgeKey): string {
58
- return bridge === "webSearch"
59
- ? `env NAN_MCP_TOOLS=${process.env.NAN_MCP_TOOLS ?? ""} (overrides persisted)`
60
- : `env NAN_MEDIA_MCP=${process.env.NAN_MEDIA_MCP ?? ""} (overrides persisted)`;
61
- }
62
-
63
- function sourceLabel(bridge: BridgeKey, source: "env" | "persisted" | "default"): string {
64
- if (source === "env") return envOverrideLabel(bridge);
46
+ function sourceLabel(source: "env" | "persisted" | "default"): string {
47
+ if (source === "env") return envOverrideLabel();
65
48
  if (source === "persisted") {
66
- return `persisted in <agentDir>/${NAN_STATE_FILE} (${targetName(bridge)}: ${readBridgeState(bridge)})`;
49
+ return `persisted in <agentDir>/${NAN_STATE_FILE} (nan-mcp-server: ${readBridgeState("mediaMcp")})`;
67
50
  }
68
- return "default (both bridges are enabled by default)";
69
- }
70
-
71
- /** Resolve a bridge key to its native server name. */
72
- function serverName(bridge: BridgeKey): string {
73
- return MCP_SERVER_NAMES[bridge];
74
- }
75
-
76
- /** Describe the bridge in the status message. */
77
- function bridgeDescription(bridge: BridgeKey): string {
78
- return bridge === "webSearch"
79
- ? "web-search bridge (official NaN MCP → mcp__nan_search__web_search)"
80
- : "nan-mcp-server bridge (community media MCP → mcp__nan_media__generate_image/edit_image/text_to_speech/list_voices/speech_to_text)";
51
+ return "default (bridge is enabled by default)";
81
52
  }
82
53
 
83
54
  /** Check if a native server of this name is currently registered. */
@@ -89,39 +60,27 @@ function nativeServerRegistered(pi: ExtensionAPI, name: string): boolean {
89
60
  }
90
61
  }
91
62
 
92
- /** Status message listing both bridges' gate state and native registration.
63
+ /** Status message listing the media bridge gate state and native registration.
93
64
  * `ctx` is the command context (provides modelRegistry for key lookup).
94
65
  */
95
66
  async function statusMessage(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<string> {
96
67
  const lines: string[] = [];
97
68
 
98
69
  if (!hasMcpConnector(pi)) {
99
- lines.push(connectorMissingMessage([MCP_SERVER_NAMES.webSearch, MCP_SERVER_NAMES.mediaMcp]));
70
+ lines.push(connectorMissingMessage([MCP_SERVER_NAMES.mediaMcp]));
100
71
  }
101
72
 
102
- for (const bridge of ["webSearch" as BridgeKey, "mediaMcp" as BridgeKey]) {
103
- const enabled = bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
104
- const source = bridge === "webSearch" ? webSearchBridgeSource() : mediaMcpSource();
105
- const regStatus = nativeServerRegistered(pi, serverName(bridge)) ? "registered (native)" : "not registered";
106
- lines.push(`${bridgeDescription(bridge)}: ${enabled ? "enabled" : "disabled"} — ${sourceLabel(bridge, source)}, ${regStatus}.`);
107
-
108
- if (bridge === "webSearch") {
109
- // Key status for web-search
110
- const keyStatus = await resolveKeyStatus(ctx);
111
- lines.push(` API key: ${keyStatus}.`);
112
- }
113
- if (bridge === "mediaMcp") {
114
- lines.push(` Media spawn command: ${mediaMcpCommand().join(" ")}.`);
115
- }
116
- }
117
-
118
- lines.push("Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.");
73
+ const enabled = mediaMcpEnabled();
74
+ const source = mediaMcpSource();
75
+ const regStatus = nativeServerRegistered(pi, MCP_SERVER_NAMES.mediaMcp) ? "registered (native)" : "not registered";
76
+ lines.push(`nan-mcp-server bridge: ${enabled ? "enabled" : "disabled"} — ${sourceLabel(source)}, ${regStatus}.`);
77
+ lines.push(` Media spawn command: ${mediaMcpCommand().join(" ")}.`);
78
+ lines.push(` API key: ${await resolveKeyStatus(ctx)}.`);
79
+ lines.push("Configure with /nan-mcp enable|disable [nan-mcp-server]; no target for the media server.");
119
80
  return lines.join("\n");
120
81
  }
121
82
 
122
- /** Check API key resolution status for web-search.
123
- * `ctx` is the command context (provides modelRegistry).
124
- */
83
+ /** Check API key resolution status. */
125
84
  async function resolveKeyStatus(ctx: ExtensionCommandContext): Promise<string> {
126
85
  const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
127
86
  const storedKey = resolveStoredNanApiKey();
@@ -130,37 +89,22 @@ async function resolveKeyStatus(ctx: ExtensionCommandContext): Promise<string> {
130
89
  return "NOT SET — run /login nan or export NAN_API_KEY";
131
90
  }
132
91
 
133
- /** Parse an optional target argument; no target = both bridges. */
134
- function parseTarget(token: string | undefined): { bridges: BridgeKey[]; target?: string } | undefined {
135
- if (token === undefined) return { bridges: ["webSearch", "mediaMcp"] };
136
- const key = TARGETS[token.toLowerCase()];
137
- if (!key) return undefined;
138
- return { bridges: [key], target: token.toLowerCase() };
139
- }
140
-
141
- function describeBridges(bridges: BridgeKey[]): string {
142
- const names = bridges.map((bridge) => targetName(bridge));
143
- return names.length === 2 ? "both bridges" : names.join(" and ");
144
- }
145
-
146
- /**
147
- * Check if a bridge is effectively enabled (gate resolved).
148
- */
149
- function bridgeEffectivelyEnabled(bridge: BridgeKey): boolean {
150
- return bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
92
+ /** Parse an optional target argument; no target = media server. */
93
+ function parseTarget(token: string | undefined): string | undefined {
94
+ if (token === undefined) return "mediaMcp";
95
+ return TARGETS[token.toLowerCase()];
151
96
  }
152
97
 
153
98
  export function registerNanMcpCommand(pi: ExtensionAPI): void {
154
99
  pi.registerCommand("nan-mcp", {
155
100
  description:
156
- "NaN MCP configuration: enable/disable/status for both native servers (official web-search + community media)",
101
+ "NaN MCP configuration: enable/disable/status for the community media server",
157
102
  getArgumentCompletions: (argumentPrefix: string) => {
158
103
  const prefix = argumentPrefix.trim().toLowerCase();
159
104
  const items = [
160
- { value: "enable", label: "enable", description: "Enable a native server (or both) and persist it" },
161
- { value: "disable", label: "disable", description: "Disable a native server (or both) persistently" },
105
+ { value: "enable", label: "enable", description: "Enable the media server and persist it" },
106
+ { value: "disable", label: "disable", description: "Disable the media server persistently" },
162
107
  { value: "status", label: "status", description: "Show current server status and registration" },
163
- { value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search server" },
164
108
  { value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media server" },
165
109
  ];
166
110
  const filtered = items.filter((item) => item.value.startsWith(prefix));
@@ -181,20 +125,17 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
181
125
  if (parsed === undefined || rest.length > 0) {
182
126
  ctx.ui.notify(
183
127
  parsed === undefined
184
- ? `Unknown target "${[rawTarget, ...rest].filter(Boolean).join(" ")}". Targets: web-search, nan-mcp-server (or omit for both).`
128
+ ? `Unknown target "${[rawTarget, ...rest].filter(Boolean).join(" ")}". Target: nan-mcp-server (or omit).`
185
129
  : USAGE,
186
130
  "warning",
187
131
  );
188
132
  return;
189
133
  }
190
- const { bridges, target } = parsed;
191
134
  const enabled = subcommand === "enable";
135
+ const where = rawTarget ? `for "${rawTarget.toLowerCase()}"` : "for the media server";
192
136
 
193
- for (const bridge of bridges) {
194
- writeBridgeState(bridge, enabled);
195
- }
137
+ writeBridgeState("mediaMcp", enabled);
196
138
 
197
- const where = target ? `for "${target}"` : "for both bridges";
198
139
  const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
199
140
 
200
141
  // Without an extension that handles `mcp_servers_change` pi cannot connect the
@@ -203,84 +144,56 @@ export function registerNanMcpCommand(pi: ExtensionAPI): void {
203
144
  // so it applies as soon as a connector (built-in `mcp`) is available.
204
145
  if (enabled && !hasMcpConnector(pi)) {
205
146
  ctx.ui.notify(
206
- `Enabled ${describeBridges(bridges)} ${where}, ${persistence} — NOT registered. ` +
207
- connectorMissingMessage(bridges.map(serverName)),
147
+ `Enabled ${where}, ${persistence} — NOT registered. ` +
148
+ connectorMissingMessage([MCP_SERVER_NAMES.mediaMcp]),
208
149
  "warning",
209
150
  );
210
151
  return;
211
152
  }
212
153
 
213
154
  // Immediate native registration/unregistration.
214
- for (const bridge of bridges) {
215
- const name = serverName(bridge);
216
- if (enabled) {
217
- // Only register if gate allows it.
218
- if (bridgeEffectivelyEnabled(bridge)) {
219
- if (bridge === "webSearch") {
220
- // Resolve key: stored credential → registry → env.
221
- const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
222
- const storedKey = resolveStoredNanApiKey();
223
- const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
224
- if (apiKey) {
225
- pi.registerMcpServer(name, {
226
- type: "http",
227
- url: "https://api.nan.builders/mcp",
228
- headers: { Authorization: `Bearer ${apiKey}` },
229
- exposure: "direct",
230
- });
231
- } else {
232
- ctx.ui.notify(
233
- `${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
234
- "warning",
235
- );
236
- }
237
- } else {
238
- // Resolve key for media: stored credential → registry → env.
239
- const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
240
- const storedKey = resolveStoredNanApiKey();
241
- const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
242
- if (apiKey) {
243
- const mediaArgs = mediaMcpCommand();
244
- pi.registerMcpServer(name, {
245
- type: "stdio",
246
- command: mediaArgs[0]!,
247
- args: mediaArgs.slice(1),
248
- env: { [NAN_API_KEY_ENV]: apiKey },
249
- exposure: "direct",
250
- timeout: mediaMcpTimeoutSec(),
251
- });
252
- } else {
253
- ctx.ui.notify(
254
- `${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
255
- "warning",
256
- );
257
- }
258
- }
155
+ const name = MCP_SERVER_NAMES.mediaMcp;
156
+ if (enabled) {
157
+ if (mediaMcpEnabled()) {
158
+ const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
159
+ const storedKey = resolveStoredNanApiKey();
160
+ const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
161
+ if (apiKey) {
162
+ const mediaArgs = mediaMcpCommand();
163
+ pi.registerMcpServer(name, {
164
+ type: "stdio",
165
+ command: mediaArgs[0]!,
166
+ args: mediaArgs.slice(1),
167
+ env: { [NAN_API_KEY_ENV]: apiKey },
168
+ exposure: "direct",
169
+ timeout: mediaMcpTimeoutSec(),
170
+ });
171
+ } else {
172
+ ctx.ui.notify(
173
+ `${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
174
+ "warning",
175
+ );
259
176
  }
260
- } else {
261
- pi.unregisterMcpServer(name);
262
177
  }
178
+ } else {
179
+ pi.unregisterMcpServer(name);
263
180
  }
264
181
 
265
182
  if (enabled) {
266
- // Only claim success if servers were actually registered.
267
- const allRegistered = bridges.every((b: BridgeKey) =>
268
- nativeServerRegistered(pi, serverName(b)),
269
- );
270
- if (allRegistered) {
183
+ if (nativeServerRegistered(pi, name)) {
271
184
  ctx.ui.notify(
272
- `Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers registered for this session.`,
185
+ `Enabled ${where}, ${persistence}. Native server registered for this session.`,
273
186
  "info",
274
187
  );
275
188
  } else {
276
189
  ctx.ui.notify(
277
- `Enabled ${describeBridges(bridges)} ${where}, ${persistence} — API key missing; servers not registered.`,
190
+ `Enabled ${where}, ${persistence} — API key missing; server not registered.`,
278
191
  "warning",
279
192
  );
280
193
  }
281
194
  } else {
282
195
  ctx.ui.notify(
283
- `Disabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers unregistered — tools hidden immediately.`,
196
+ `Disabled ${where}, ${persistence}. Native server unregistered — tools hidden immediately.`,
284
197
  "warning",
285
198
  );
286
199
  }
@@ -27,14 +27,53 @@
27
27
  * provider stream). It therefore sees the original `thinking` blocks and can
28
28
  * remove them before they become text.
29
29
  *
30
- * This is a bounded mitigation, not the fix: the unbounded conversion (and pi
31
- * not re-checking the context size on a model switch) belongs upstream. See
32
- * https://github.com/gtrabanco/pi-nan-provider/issues/3.
30
+ * UPSTREAM UPDATE (pi-ai 1.3.0, verified 2026-10-15): `transformMessages` now
31
+ * drops cross-model `thinking` blocks itself (`if (!isSameModel) return []`),
32
+ * which is the upstream fix this guard was mitigating. The guard is therefore
33
+ * GATED BY VERSION: it only registers when the host's pi-ai is older than
34
+ * {@link CROSS_MODEL_GUARD_PI_AI_CEILING} (1.3.0). An unknown host version
35
+ * keeps the guard on — the conservative default, since the guard is harmless
36
+ * where pi-ai already drops the blocks and essential where it still replays
37
+ * them.
38
+ *
39
+ * DEPRECATED — REMOVE THIS MODULE when the peer range floor rises to
40
+ * `>=1.3.0`: at that point every supported pi ships the upstream drop and this
41
+ * file (plus the `NAN_THINKING_GUARD` opt-out) is dead code. Users on
42
+ * pi < 1.3.0 should update pi to keep getting the mitigation for free.
33
43
  */
34
44
 
45
+ import { parsePiAiVersion } from "./pi-ai-version.ts";
46
+
35
47
  /** Env var that opts out of the guard (`0`, `false`, `no` or `off`). Default: enabled. */
36
48
  export const NAN_THINKING_GUARD_ENV = "NAN_THINKING_GUARD";
37
49
 
50
+ /**
51
+ * Exclusive pi-ai version ceiling for this guard: it registers only while the
52
+ * host's pi-ai is OLDER than this version. pi-ai 1.3.0 drops cross-model
53
+ * thinking in `transformMessages` (upstream fix for issue #3), so from 1.3.0
54
+ * on the guard is redundant. Remove this constant together with the module
55
+ * once the peer range floor reaches this version.
56
+ */
57
+ export const CROSS_MODEL_GUARD_PI_AI_CEILING = "1.3.0";
58
+
59
+ /**
60
+ * Whether the guard still applies to a host running the given pi-ai version.
61
+ * `undefined`/`null` (unknown host version) and unparseable versions return
62
+ * `true` — the conservative older-pi default. `false` means the host's pi-ai
63
+ * already drops cross-model thinking itself and the guard must not register.
64
+ */
65
+ export function crossModelThinkingGuardCovers(piAiVersion: string | null | undefined): boolean {
66
+ if (piAiVersion === null || piAiVersion === undefined) return true;
67
+ const parsed = parsePiAiVersion(piAiVersion);
68
+ if (!parsed) return true;
69
+ const ceiling = parsePiAiVersion(CROSS_MODEL_GUARD_PI_AI_CEILING);
70
+ if (!ceiling) return true;
71
+ return (
72
+ parsed.major < ceiling.major ||
73
+ (parsed.major === ceiling.major && parsed.minor < ceiling.minor)
74
+ );
75
+ }
76
+
38
77
  export interface CrossModelThinkingGuardOptions {
39
78
  /** Provider ids this guard applies to (the caller's registered providers). */
40
79
  providerIds: ReadonlySet<string>;
package/src/index.ts CHANGED
@@ -15,17 +15,13 @@
15
15
  * 0.84 alike), and the openai-completions streaming implementation is
16
16
  * resolved dynamically — see provider-factory.ts for why.
17
17
  *
18
- * 2. MCP servers via pi's native registerMcpServer (pi >=0.99):
19
- * - `nan-search` — the official NaN remote MCP server
20
- * (https://api.nan.builders/mcp), exposing `mcp__nan_search__web_search`
21
- * (pi >=0.99.2; 0.99.0-0.99.1 kept the hyphen: `mcp__nan-search__web_search`).
22
- * Default: enabled. NAN_MCP_TOOLS=0 to disable.
18
+ * 2. MCP server via pi's native registerMcpServer (pi >=0.99):
23
19
  * - `nan-media` — the community stdio media server
24
20
  * (flux-2-klein / kokoro / whisper), exposing
25
21
  * `mcp__nan_media__generate_image` etc. Default: enabled,
26
22
  * NAN_MEDIA_MCP=0 to disable.
27
23
  *
28
- * Both are session-scoped (visible in /mcp with source "extension").
24
+ * It is session-scoped (visible in /mcp with source "extension").
29
25
  * Exposure: `direct` — tools are declared to the model like built-ins.
30
26
  */
31
27
 
@@ -34,13 +30,15 @@ import type { Provider } from "@earendil-works/pi-ai";
34
30
  import { registerNanMcpCommand } from "./commands.ts";
35
31
  import { registerNanUsageCommand } from "./usage.ts";
36
32
  import {
33
+ crossModelThinkingGuardCovers,
37
34
  crossModelThinkingGuardEnabled,
38
35
  stripCrossModelThinking,
39
36
  } from "./cross-model-thinking-guard.ts";
37
+ import { resolvePiAiVersion } from "./pi-ai-version.ts";
40
38
  import { baselineModels } from "./fetch-models.ts";
41
39
  import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
42
40
  import { PROVIDERS } from "./providers.ts";
43
- import { NAN_API_KEY_ENV, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
41
+ import { NAN_API_KEY_ENV, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey } from "./mcp/api-key.ts";
44
42
  import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
45
43
  import { registerMcpHostGuard } from "./mcp/host-support.ts";
46
44
 
@@ -95,26 +93,6 @@ async function registerProviderCompat(
95
93
  const NO_NATIVE_MCP_GUARD_MSG =
96
94
  "pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
97
95
 
98
- /** Register the official NaN web-search MCP server (session-scoped). */
99
- async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | undefined): Promise<boolean> {
100
- if (!webSearchBridgeEnabled()) return false;
101
- if (!apiKey) {
102
- console.warn(
103
- "[pi-nan-provider] NAN_API_KEY is not set — web-search server not registered. " +
104
- "Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
105
- );
106
- return false;
107
- }
108
- pi.registerMcpServer("nan-search", {
109
- type: "http",
110
- url: "https://api.nan.builders/mcp",
111
- headers: { Authorization: `Bearer ${apiKey}` },
112
- exposure: "direct",
113
- description: "Web search through NaN's gateway (official NaN MCP server)",
114
- });
115
- return true;
116
- }
117
-
118
96
  /** Register the community media stdio MCP server (session-scoped). */
119
97
  function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): boolean {
120
98
  if (!mediaMcpEnabled()) return false;
@@ -139,11 +117,11 @@ function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): b
139
117
  }
140
118
 
141
119
  /**
142
- * Register MCP servers via pi's native registerMcpServer.
120
+ * Register the MCP server via pi's native registerMcpServer.
143
121
  *
144
- * Session-scoped: servers appear in /mcp with source "extension", are
145
- * visible to the model (exposure: "direct"), and user mcp.json entries
146
- * with the same name take precedence.
122
+ * Session-scoped: the server appears in /mcp with source "extension", is
123
+ * visible to the model (exposure: "direct"), and a user mcp.json entry
124
+ * with the same name takes precedence.
147
125
  *
148
126
  * On pi <0.99 (registerMcpServer absent) this does NOT bridge — it
149
127
  * notifies once and skips (same loud-skip style as existing guards).
@@ -162,7 +140,6 @@ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
162
140
  const storedKey = resolveStoredNanApiKey();
163
141
  const apiKey = storedKey ?? process.env[NAN_API_KEY_ENV];
164
142
 
165
- await registerWebSearchMcpServer(pi, apiKey);
166
143
  registerMediaMcpServer(pi, apiKey);
167
144
  }
168
145
 
@@ -171,21 +148,34 @@ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
171
148
  /**
172
149
  * Drop the reasoning pi-ai replays across a model switch.
173
150
  *
174
- * pi-ai's `transformMessages` downgrades a previous model's `thinking` blocks
175
- * to plain text with no size bound (verified on 0.87.1), and nothing
176
- * bounds the sum across messages — measured at 30–60% of the whole context on
177
- * real sessions. Switching from a 1M-context model to a 262K one (`qwen3.6`)
178
- * then overflows the window, and NaN's gateway answers a generic
151
+ * On pi-ai < 1.3.0, `transformMessages` downgrades a previous model's
152
+ * `thinking` blocks to plain text with no size bound (verified on 0.87.1), and
153
+ * nothing bounds the sum across messages — measured at 30–60% of the whole
154
+ * context on real sessions. Switching from a 1M-context model to a 262K one
155
+ * (`qwen3.6`) then overflows the window, and NaN's gateway answers a generic
179
156
  * `400 Invalid request. Check your request parameters.` This hook runs before
180
157
  * pi-ai converts the blocks, so removing the cross-model reasoning here keeps
181
158
  * the replayed context small. Answers and tool results are untouched. See
182
159
  * src/cross-model-thinking-guard.ts.
183
160
  *
161
+ * VERSION-GATED: pi-ai 1.3.0 drops cross-model thinking itself (upstream fix
162
+ * for issue #3), so the guard only registers when the host's resolved pi-ai is
163
+ * older than 1.3.0; an undeterminable host version keeps it on (conservative
164
+ * older-pi default). DEPRECATED — remove this whole guard once the peer range
165
+ * floor rises to `>=1.3.0`; users on pi < 1.3.0 should update pi.
166
+ *
184
167
  * Scope: only requests targeting this package's providers are touched, and
185
168
  * only messages from a DIFFERENT model — same-model reasoning is never removed.
186
169
  */
187
- export function registerCrossModelThinkingGuard(pi: ExtensionAPI): void {
170
+ export function registerCrossModelThinkingGuard(
171
+ pi: ExtensionAPI,
172
+ options: { piAiVersion?: string | null } = {},
173
+ ): void {
188
174
  if (typeof pi.on !== "function") return; // old pi without the context hook
175
+ // `null` forces the unknown-version path (guard on); omitted resolves the
176
+ // host's pi-ai version at runtime; a string pins the version (tests).
177
+ const piAiVersion = options.piAiVersion !== undefined ? options.piAiVersion : resolvePiAiVersion();
178
+ if (!crossModelThinkingGuardCovers(piAiVersion)) return;
189
179
  const providerIds = new Set(PROVIDERS.map((provider) => provider.id));
190
180
  pi.on("context", (event, ctx) => {
191
181
  if (!crossModelThinkingGuardEnabled()) return;
@@ -1,8 +1,7 @@
1
- import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
2
1
  import { readStoredCredential } from "@earendil-works/pi-coding-agent";
3
2
 
4
3
  /**
5
- * NaN API key resolution — shared across every surface (web search, media, usage).
4
+ * NaN API key resolution — shared across every surface (media, usage).
6
5
  *
7
6
  * Strategy: pi's stored credential (modelRegistry) wins → env var fallback.
8
7
  * Never logs or embeds the key beyond the Authorization header.
@@ -40,8 +39,6 @@ export function resolveStoredNanApiKey(): string | undefined {
40
39
  }
41
40
  }
42
41
 
43
- /** Env var that overrides the official web_search bridge. */
44
- export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
45
42
  /** Env var that overrides the media bridge. */
46
43
  export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
47
44
  /** The NaN API key env var used everywhere. */
@@ -74,18 +71,6 @@ export async function tryResolveNanApiKeyViaRegistry(
74
71
  }
75
72
  }
76
73
 
77
- /** Whether MCP tool registration is disabled via env (NAN_MCP_TOOLS=0|false|off). */
78
- export function mcpToolsDisabled(): boolean {
79
- const value = process.env[NAN_MCP_TOOLS_ENV]?.trim().toLowerCase();
80
- return value === "0" || value === "false" || value === "off";
81
- }
82
-
83
- /** Whether NAN_MCP_TOOLS is explicitly set (any value). */
84
- export function mcpToolsEnvExplicit(): boolean {
85
- const value = process.env[NAN_MCP_TOOLS_ENV];
86
- return value !== undefined && value.trim() !== "";
87
- }
88
-
89
74
  /** Whether NAN_MEDIA_MCP is explicitly set (any value). */
90
75
  export function mediaMcpEnvExplicit(): boolean {
91
76
  const value = process.env[NAN_MEDIA_MCP_ENV];
@@ -97,13 +82,3 @@ export function mediaMcpEnvTruthy(): boolean {
97
82
  const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
98
83
  return value === "1" || value === "true" || value === "on";
99
84
  }
100
-
101
- /** Source of the effective web-search enablement (env / persisted / default). */
102
- export function webSearchBridgeSource(): import("./state.ts").BridgeSource {
103
- return bridgeSource("webSearch", mcpToolsEnvExplicit());
104
- }
105
-
106
- /** Whether the web-search bridge is enabled by gate (env → persisted → default). */
107
- export function webSearchBridgeEnabled(): boolean {
108
- return resolveBridgeEnabled("webSearch", mcpToolsEnvExplicit(), !mcpToolsDisabled(), true);
109
- }
package/src/mcp/state.ts CHANGED
@@ -17,8 +17,6 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
17
17
  export const NAN_STATE_FILE = "nan-provider.json";
18
18
 
19
19
  export interface NanProviderState {
20
- /** Persisted enablement of the official web_search MCP bridge. */
21
- webSearch?: boolean;
22
20
  /** Persisted enablement of the community nan-mcp-server media bridge. */
23
21
  mediaMcp?: boolean;
24
22
  }
@@ -27,7 +25,6 @@ export type BridgeKey = keyof NanProviderState;
27
25
 
28
26
  /** Server name each bridge registers with `pi.registerMcpServer()`. Single source of truth. */
29
27
  export const MCP_SERVER_NAMES: Record<BridgeKey, string> = {
30
- webSearch: "nan-search",
31
28
  mediaMcp: "nan-media",
32
29
  };
33
30
 
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Runtime pi-ai version detection.
3
+ *
4
+ * Resolves the `@earendil-works/pi-ai` package instance the HOST loaded — the
5
+ * same host-anchored strategy `src/pi-ai-loader.ts` uses, because an
6
+ * extension-relative resolve can return a stale copy from the extension's own
7
+ * npm tree (issue #8) — and reads that copy's package.json version. The result
8
+ * feeds version-gated behavior, currently the cross-model thinking guard
9
+ * (registered only while the host's pi-ai is older than 1.3.0, where pi-ai
10
+ * still replays cross-model thinking as unbounded text).
11
+ *
12
+ * Failure is NOT an error: an unresolvable package or an unreadable version
13
+ * returns `undefined`, and callers fall back to the conservative older-pi
14
+ * behavior (the guard stays on — harmless on modern hosts, essential on old
15
+ * ones). Guarded by `test/pi-ai-version.test.ts`.
16
+ */
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import { hostAnchorUrl, PI_AI_PACKAGE_SPECIFIER, resolvePiAiSpecifier } from "./pi-ai-loader.ts";
22
+
23
+ /** A parsed semver triple (`1.3.0-beta.1` → `{ major: 1, minor: 3, patch: 0 }`). */
24
+ export interface PiAiVersion {
25
+ major: number;
26
+ minor: number;
27
+ patch: number;
28
+ }
29
+
30
+ /** Parse a semver string; prerelease/build suffixes are ignored. */
31
+ export function parsePiAiVersion(version: string): PiAiVersion | undefined {
32
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
33
+ if (!match) return undefined;
34
+ return { major: Number(match[1]), minor: Number(match[2]), patch: Number(match[3]) };
35
+ }
36
+
37
+ /**
38
+ * Read the pi-ai version from a resolved specifier (path or `file://` URL) of
39
+ * any entry file inside the package. Walks up from the entry's directory until
40
+ * a package.json whose `name` is exactly `@earendil-works/pi-ai` is found —
41
+ * matching the name prevents picking up an unrelated package.json in a
42
+ * workspace layout — and returns its `version`. Bounded walk (8 levels);
43
+ * `undefined` when nothing matches.
44
+ */
45
+ export function piAiVersionFromResolvedEntry(entry: string): string | undefined {
46
+ let dir: string;
47
+ try {
48
+ dir = dirname(entry.startsWith("file://") ? fileURLToPath(entry) : entry);
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ for (let depth = 0; depth < 8; depth++) {
53
+ try {
54
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")) as {
55
+ name?: unknown;
56
+ version?: unknown;
57
+ };
58
+ if (pkg.name === PI_AI_PACKAGE_SPECIFIER && typeof pkg.version === "string") {
59
+ return pkg.version;
60
+ }
61
+ } catch {
62
+ // No readable package.json at this level — keep walking up.
63
+ }
64
+ const parent = dirname(dir);
65
+ if (parent === dir) return undefined;
66
+ dir = parent;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** Cache for {@link resolvePiAiVersion}; resolution runs once per process. */
72
+ let cachedVersion: string | undefined;
73
+ let resolved = false;
74
+
75
+ /**
76
+ * Version of the pi-ai instance the host loaded, or `undefined` when it cannot
77
+ * be determined (unresolvable specifier, unreadable package.json, odd runtime).
78
+ * Resolution happens once and is cached for the process lifetime.
79
+ */
80
+ export function resolvePiAiVersion(): string | undefined {
81
+ if (resolved) return cachedVersion;
82
+ resolved = true;
83
+ try {
84
+ const entry = resolvePiAiSpecifier(PI_AI_PACKAGE_SPECIFIER, {
85
+ anchorUrl: hostAnchorUrl(process.argv[1]),
86
+ });
87
+ cachedVersion = piAiVersionFromResolvedEntry(entry);
88
+ } catch {
89
+ cachedVersion = undefined;
90
+ }
91
+ return cachedVersion;
92
+ }