@gtrabanco/pi-nan-provider 0.8.0 → 0.10.2

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/src/commands.ts CHANGED
@@ -1,45 +1,41 @@
1
1
  /**
2
- * `/nan-mcp` — slash command configuring BOTH MCP bridges this package
3
- * registers as pi tools:
2
+ * `/nan-mcp` — slash command configuring both native MCP servers this
3
+ * package registers:
4
4
  *
5
- * - `web-search` — the official NaN remote MCP server (api.nan.builders/mcp),
6
- * exposing `nan_web_search`. Default: enabled.
7
- * - `nan-mcp-server` — the community stdio media server (image generation /
8
- * editing, TTS, STT). Default: enabled, lazy (spawned per tool call).
5
+ * - `nan-search` — the official NaN remote MCP server (api.nan.builders/mcp),
6
+ * exposing `mcp__nan-search__web_search`. Default: enabled.
7
+ * - `nan-media` — the community stdio media server (flux-2-klein / kokoro /
8
+ * whisper), exposing `mcp__nan-media__generate_image` etc. Default: enabled.
9
9
  *
10
- * pi has no MCP client and therefore no `/mcp` command of its own, so this
11
- * command owns the toggles:
12
- *
13
- * - `/nan-mcp` or `/nan-mcp status` — state of both bridges and their source.
14
- * - `/nan-mcp enable [target]` — enable a bridge (or both, when no target is
15
- * given) and persist it in `<agentDir>/nan-provider.json`; enabled bridges
16
- * register their tools immediately for the current session.
17
- * - `/nan-mcp disable [target]` — disable persistently. pi has no
18
- * unregisterTool, so already-registered tools remain until the next
19
- * session/restart; the persisted toggle governs future sessions.
10
+ * Both are session-scoped native servers (visible in /mcp, source "extension").
11
+ * /nan-mcp enables/disables them via pi.registerMcpServer / pi.unregisterMcpServer,
12
+ * persisting the toggle in nan-provider.json.
20
13
  *
21
14
  * Targets accept aliases: `web-search` (`search`, `web_search`, `official`,
22
15
  * `nan-web-search`) and `nan-mcp-server` (`media`, `media-mcp`, `nan-media`).
23
16
  * Explicit env vars (NAN_MCP_TOOLS, NAN_MEDIA_MCP) override the persisted
24
17
  * toggles for the session.
18
+ *
19
+ * Native behavior changes from the old bridge:
20
+ * - `disable` IMMEDIATELY hides tools (unregisterMcpServer disconnects the
21
+ * server; the MCP extension hides tools when disconnected).
22
+ * - `enable` IMMEDIATELY registers the server for the current session.
23
+ * - `status` reads the server state from pi.getMcpServers().
24
+ * - Missing API key at load: server is NOT registered; guidance notification
25
+ * replaces the old "errors at call time" behavior.
25
26
  */
26
27
 
27
28
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
28
- import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource } from "./mcp/nan-media.ts";
29
- import { webSearchBridgeEnabled, webSearchBridgeSource } from "./mcp/nan-search.ts";
30
- import { NAN_STATE_FILE, readBridgeState, writeBridgeState, type BridgeKey } from "./mcp/state.ts";
31
-
32
- export interface NanMcpCommandOptions {
33
- /** Register web-search bridge tools now; must skip already-registered tools. */
34
- registerWebSearchTools: () => void;
35
- /** Register media bridge tools now; must skip already-registered tools. */
36
- registerMediaTools: () => void;
37
- }
29
+ import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
30
+ import { webSearchBridgeEnabled, webSearchBridgeSource, mcpToolsEnvExplicit, mcpToolsDisabled } from "./mcp/api-key.ts";
31
+ import { NAN_API_KEY_ENV, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey } from "./mcp/api-key.ts";
32
+ import { NAN_STATE_FILE, MCP_SERVER_NAMES, readBridgeState, writeBridgeState, type BridgeKey, readState, writeState } from "./mcp/state.ts";
33
+ import { connectorMissingMessage, hasMcpConnector } from "./mcp/host-support.ts";
38
34
 
39
35
  const USAGE =
40
36
  "Usage: /nan-mcp [status] · /nan-mcp enable [web-search|nan-mcp-server] · /nan-mcp disable [web-search|nan-mcp-server]";
41
37
 
42
- /** Canonical bridge targets with aliases (e.g. `/mcp enable nan-mcp-server` muscle memory). */
38
+ /** Canonical bridge targets with aliases. */
43
39
  const TARGETS: Record<string, BridgeKey> = {
44
40
  "web-search": "webSearch",
45
41
  search: "webSearch",
@@ -67,19 +63,72 @@ function sourceLabel(bridge: BridgeKey, source: "env" | "persisted" | "default")
67
63
  if (source === "persisted") {
68
64
  return `persisted in <agentDir>/${NAN_STATE_FILE} (${targetName(bridge)}: ${readBridgeState(bridge)})`;
69
65
  }
70
- return "default (both bridges are enabled and lazy by default)";
66
+ return "default (both bridges are enabled by default)";
67
+ }
68
+
69
+ /** Resolve a bridge key to its native server name. */
70
+ function serverName(bridge: BridgeKey): string {
71
+ return MCP_SERVER_NAMES[bridge];
72
+ }
73
+
74
+ /** Describe the bridge in the status message. */
75
+ function bridgeDescription(bridge: BridgeKey): string {
76
+ return bridge === "webSearch"
77
+ ? "web-search bridge (official NaN MCP → mcp__nan-search__web_search)"
78
+ : "nan-mcp-server bridge (community media MCP → mcp__nan-media__generate_image/edit_image/text_to_speech/list_voices/speech_to_text)";
79
+ }
80
+
81
+ /** Check if a native server of this name is currently registered. */
82
+ function nativeServerRegistered(pi: ExtensionAPI, name: string): boolean {
83
+ try {
84
+ return pi.getMcpServers().some((s) => s.name === name);
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /** Status message listing both bridges' gate state and native registration.
91
+ * `ctx` is the command context (provides modelRegistry for key lookup).
92
+ */
93
+ async function statusMessage(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<string> {
94
+ const lines: string[] = [];
95
+
96
+ if (!hasMcpConnector(pi)) {
97
+ lines.push(connectorMissingMessage([MCP_SERVER_NAMES.webSearch, MCP_SERVER_NAMES.mediaMcp]));
98
+ }
99
+
100
+ for (const bridge of ["webSearch" as BridgeKey, "mediaMcp" as BridgeKey]) {
101
+ const enabled = bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
102
+ const source = bridge === "webSearch" ? webSearchBridgeSource() : mediaMcpSource();
103
+ const regStatus = nativeServerRegistered(pi, serverName(bridge)) ? "registered (native)" : "not registered";
104
+ lines.push(`${bridgeDescription(bridge)}: ${enabled ? "enabled" : "disabled"} — ${sourceLabel(bridge, source)}, ${regStatus}.`);
105
+
106
+ if (bridge === "webSearch") {
107
+ // Key status for web-search
108
+ const keyStatus = await resolveKeyStatus(ctx);
109
+ lines.push(` API key: ${keyStatus}.`);
110
+ }
111
+ if (bridge === "mediaMcp") {
112
+ lines.push(` Media spawn command: ${mediaMcpCommand().join(" ")}.`);
113
+ }
114
+ }
115
+
116
+ lines.push("Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.");
117
+ return lines.join("\n");
71
118
  }
72
119
 
73
- function statusMessage(): string {
74
- return [
75
- `web-search bridge (official NaN MCP → nan_web_search): ${webSearchBridgeEnabled() ? "enabled" : "disabled"} — ${sourceLabel("webSearch", webSearchBridgeSource())}.`,
76
- `nan-mcp-server bridge (community media MCP → nan_generate_image/nan_edit_image/nan_text_to_speech/nan_list_voices/nan_speech_to_text): ${mediaMcpEnabled() ? "enabled" : "disabled"} — ${sourceLabel("mediaMcp", mediaMcpSource())}.`,
77
- `Media spawn command: ${mediaMcpCommand().join(" ")} (per tool call, terminated after).`,
78
- "Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.",
79
- ].join("\n");
120
+ /** Check API key resolution status for web-search.
121
+ * `ctx` is the command context (provides modelRegistry).
122
+ */
123
+ async function resolveKeyStatus(ctx: ExtensionCommandContext): Promise<string> {
124
+ const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
125
+ const storedKey = resolveStoredNanApiKey();
126
+ const envKey = process.env[NAN_API_KEY_ENV];
127
+ if (registryKey || storedKey || envKey) return "resolved";
128
+ return "NOT SET — run /login nan or export NAN_API_KEY";
80
129
  }
81
130
 
82
- /** Parse an optional target argument; no target = both bridges. Returns undefined target name on unknown. */
131
+ /** Parse an optional target argument; no target = both bridges. */
83
132
  function parseTarget(token: string | undefined): { bridges: BridgeKey[]; target?: string } | undefined {
84
133
  if (token === undefined) return { bridges: ["webSearch", "mediaMcp"] };
85
134
  const key = TARGETS[token.toLowerCase()];
@@ -92,18 +141,25 @@ function describeBridges(bridges: BridgeKey[]): string {
92
141
  return names.length === 2 ? "both bridges" : names.join(" and ");
93
142
  }
94
143
 
95
- export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOptions): void {
144
+ /**
145
+ * Check if a bridge is effectively enabled (gate resolved).
146
+ */
147
+ function bridgeEffectivelyEnabled(bridge: BridgeKey): boolean {
148
+ return bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
149
+ }
150
+
151
+ export function registerNanMcpCommand(pi: ExtensionAPI): void {
96
152
  pi.registerCommand("nan-mcp", {
97
153
  description:
98
- "NaN MCP configuration: enable/disable/status for both bridges (official web-search + community nan-mcp-server)",
154
+ "NaN MCP configuration: enable/disable/status for both native servers (official web-search + community media)",
99
155
  getArgumentCompletions: (argumentPrefix: string) => {
100
156
  const prefix = argumentPrefix.trim().toLowerCase();
101
157
  const items = [
102
- { value: "enable", label: "enable", description: "Enable a bridge (or both) and persist it" },
103
- { value: "disable", label: "disable", description: "Disable a bridge (or both) persistently" },
104
- { value: "status", label: "status", description: "Show current bridge status" },
105
- { value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search bridge" },
106
- { value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media bridge" },
158
+ { value: "enable", label: "enable", description: "Enable a native server (or both) and persist it" },
159
+ { value: "disable", label: "disable", description: "Disable a native server (or both) persistently" },
160
+ { value: "status", label: "status", description: "Show current server status and registration" },
161
+ { value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search server" },
162
+ { value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media server" },
107
163
  ];
108
164
  const filtered = items.filter((item) => item.value.startsWith(prefix));
109
165
  return filtered.length > 0 ? filtered : null;
@@ -114,12 +170,11 @@ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOp
114
170
  const subcommand = rawSubcommand.toLowerCase();
115
171
 
116
172
  if (subcommand === "status") {
117
- ctx.ui.notify(statusMessage(), "info");
173
+ ctx.ui.notify(await statusMessage(pi, ctx), "info");
118
174
  return;
119
175
  }
120
176
 
121
177
  if (subcommand === "enable" || subcommand === "disable") {
122
- // Trailing tokens beyond the target (e.g. duplicate target names) are ignored.
123
178
  const parsed = parseTarget(rawTarget);
124
179
  if (parsed === undefined || rest.length > 0) {
125
180
  ctx.ui.notify(
@@ -136,23 +191,94 @@ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOp
136
191
  for (const bridge of bridges) {
137
192
  writeBridgeState(bridge, enabled);
138
193
  }
139
- if (enabled) {
140
- if (bridges.includes("webSearch")) options.registerWebSearchTools();
141
- if (bridges.includes("mediaMcp")) options.registerMediaTools();
142
- }
143
194
 
144
195
  const where = target ? `for "${target}"` : "for both bridges";
145
196
  const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
146
- if (enabled) {
197
+
198
+ // Without an extension that handles `mcp_servers_change` pi cannot connect the
199
+ // servers; registering would only trigger its "no loaded extension connects MCP
200
+ // servers" error (see src/mcp/host-support.ts). The toggle above still persists,
201
+ // so it applies as soon as a connector (built-in `mcp`) is available.
202
+ if (enabled && !hasMcpConnector(pi)) {
147
203
  ctx.ui.notify(
148
- `Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Tools are available now; both bridges are lazy (web-search calls the endpoint per request, nan-mcp-server spawns per call), so nothing runs until invoked.` +
149
- " An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var would override this toggle.",
150
- "info",
204
+ `Enabled ${describeBridges(bridges)} ${where}, ${persistence} — NOT registered. ` +
205
+ connectorMissingMessage(bridges.map(serverName)),
206
+ "warning",
207
+ );
208
+ return;
209
+ }
210
+
211
+ // Immediate native registration/unregistration.
212
+ for (const bridge of bridges) {
213
+ const name = serverName(bridge);
214
+ if (enabled) {
215
+ // Only register if gate allows it.
216
+ if (bridgeEffectivelyEnabled(bridge)) {
217
+ if (bridge === "webSearch") {
218
+ // Resolve key: stored credential → registry → env.
219
+ const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
220
+ const storedKey = resolveStoredNanApiKey();
221
+ const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
222
+ if (apiKey) {
223
+ pi.registerMcpServer(name, {
224
+ type: "http",
225
+ url: "https://api.nan.builders/mcp",
226
+ headers: { Authorization: `Bearer ${apiKey}` },
227
+ exposure: "direct",
228
+ });
229
+ } else {
230
+ ctx.ui.notify(
231
+ `${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
232
+ "warning",
233
+ );
234
+ }
235
+ } else {
236
+ // Resolve key for media: stored credential → registry → env.
237
+ const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
238
+ const storedKey = resolveStoredNanApiKey();
239
+ const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
240
+ if (apiKey) {
241
+ const mediaArgs = mediaMcpCommand();
242
+ pi.registerMcpServer(name, {
243
+ type: "stdio",
244
+ command: mediaArgs[0]!,
245
+ args: mediaArgs.slice(1),
246
+ env: { [NAN_API_KEY_ENV]: apiKey },
247
+ exposure: "direct",
248
+ timeout: mediaMcpTimeoutSec(),
249
+ });
250
+ } else {
251
+ ctx.ui.notify(
252
+ `${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
253
+ "warning",
254
+ );
255
+ }
256
+ }
257
+ }
258
+ } else {
259
+ pi.unregisterMcpServer(name);
260
+ }
261
+ }
262
+
263
+ if (enabled) {
264
+ // Only claim success if servers were actually registered.
265
+ const allRegistered = bridges.every((b: BridgeKey) =>
266
+ nativeServerRegistered(pi, serverName(b)),
151
267
  );
268
+ if (allRegistered) {
269
+ ctx.ui.notify(
270
+ `Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers registered for this session.`,
271
+ "info",
272
+ );
273
+ } else {
274
+ ctx.ui.notify(
275
+ `Enabled ${describeBridges(bridges)} ${where}, ${persistence} — API key missing; servers not registered.`,
276
+ "warning",
277
+ );
278
+ }
152
279
  } else {
153
280
  ctx.ui.notify(
154
- `Disabled ${describeBridges(bridges)} ${where}, ${persistence}. pi has no unregisterTool, so tools already registered in this session remain until restart (or /reload); future sessions will not register them.` +
155
- " An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var overrides this toggle.",
281
+ `Disabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers unregistered — tools hidden immediately.`,
156
282
  "warning",
157
283
  );
158
284
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * NaN-aware context-overflow classification.
3
3
  *
4
- * Root cause (pi-ai, still present on 0.85.1 / main): when history is replayed
4
+ * Root cause (pi-ai, verified on 0.87.1): when history is replayed
5
5
  * into a DIFFERENT model, `transformMessages` downgrades every non-redacted
6
6
  * `thinking` block to plain `text` verbatim, with no size bound — and nothing
7
7
  * bounds the SUM across messages. Switching from a 1M-context model
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Cross-model thinking guard.
3
3
  *
4
- * Root cause this guards against (pi-ai, still present on 0.85.1 / main): when
4
+ * Root cause this guards against (pi-ai, verified on 0.87.1): when
5
5
  * history is replayed into a DIFFERENT model, `transformMessages` downgrades
6
6
  * every non-redacted `thinking` block to a plain `text` block verbatim
7
7
  * (`packages/ai/src/api/transform-messages.ts`), and `openai-completions`
@@ -65,6 +65,14 @@ export function crossModelThinkingGuardEnabled(env: NodeJS.ProcessEnv = process.
65
65
  return value !== "0" && value !== "false" && value !== "no" && value !== "off";
66
66
  }
67
67
 
68
+ /**
69
+ * API id pi 0.99 assigns to virtual catalog entries (`core/virtual-models.js`,
70
+ * `VIRTUAL_MODEL_API`). A virtual selection routes each request to a physical
71
+ * model; the physical target is not exposed to the `context` hook, so the guard
72
+ * cannot tell which physical model will answer.
73
+ */
74
+ const VIRTUAL_MODEL_API = "pi-virtual";
75
+
68
76
  function isGuardMessage(value: unknown): value is GuardMessage {
69
77
  return typeof value === "object" && value !== null;
70
78
  }
@@ -85,6 +93,16 @@ export function stripCrossModelThinking<T>(
85
93
  ): readonly T[] {
86
94
  if (!target?.provider || !options.providerIds.has(target.provider)) return messages;
87
95
 
96
+ // Virtual selection (pi 0.99): `ctx.model` is the selectable virtual entry
97
+ // (api "pi-virtual"), not the physical model the router picks for this
98
+ // request. Its api/id never match a physical assistant message, so the
99
+ // same-model check below would strip reasoning on EVERY continuation —
100
+ // including when the router keeps the same physical model — losing the
101
+ // prompt cache and thinking continuity the guard promises to preserve.
102
+ // The routed physical model is not available here, so skip virtual
103
+ // selections entirely; cross-model trimming is the router's concern there.
104
+ if (target.api === VIRTUAL_MODEL_API) return messages;
105
+
88
106
  let changed = false;
89
107
 
90
108
  const next = messages.map((raw) => {
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
25
+ import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
25
26
  import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
26
27
 
27
28
  export type { Model };
@@ -46,6 +47,15 @@ export interface GeneratedModelEntry {
46
47
  * An undefined array means the field was not set (legacy / uncatalogued).
47
48
  */
48
49
  reasoningEffortValues?: string[];
50
+ /**
51
+ * Mapping from pi-ai's user-facing thinking levels to model-specific effort
52
+ * strings. When `reasoning: "off"` maps to undefined in pi-ai,
53
+ * thinkingLevelMap.off supplies the effort value that NaN understands
54
+ * (e.g. "none" for deepseek-v4-flash, "minimal" for glm5.3-flash).
55
+ * Without this, no reasoning_effort is sent and NaN does NOT disable
56
+ * reasoning, causing runaway reasoning (issue #16).
57
+ */
58
+ thinkingLevelMap?: ThinkingLevelMap;
49
59
  /** Compat applied to every NaN-compatible model (LiteLLM-confirmed, see scripts/generate-models.ts). */
50
60
  compat?: OpenAICompletionsCompat;
51
61
  /** Provenance notes for values overriding models.dev or needing manual confirmation. */
@@ -92,6 +102,7 @@ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Mode
92
102
  contextWindow: entry.contextWindow,
93
103
  maxTokens: entry.maxTokens,
94
104
  ...(entry.compat ? { compat: { ...entry.compat } } : {}),
105
+ ...(entry.thinkingLevelMap ? { thinkingLevelMap: { ...entry.thinkingLevelMap } } : {}),
95
106
  };
96
107
  }
97
108
 
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @gtrabanco/pi-nan-provider — NaN Builders provider + MCP bridges for pi.
2
+ * @gtrabanco/pi-nan-provider — NaN Builders provider + native MCP servers for pi.
3
3
  *
4
4
  * What this extension registers:
5
5
  *
@@ -15,14 +15,17 @@
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 tools over pi's registerTool (pi intentionally has no MCP client):
19
- * - `nan_web_search` via NaN's official remote MCP server
20
- * (https://api.nan.builders/mcp) — on by default, NAN_MCP_TOOLS=0 to
21
- * disable.
22
- * - Media tools bridging the optional community `nan-mcp-server`
23
- * (stdio, spawned per call) — on by default, NAN_MEDIA_MCP=0 to
24
- * disable. The server spawns per tool call, so nothing runs unless
25
- * audio/image/transcription is actually invoked.
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
+ * Default: enabled. NAN_MCP_TOOLS=0 to disable.
22
+ * - `nan-media` — the community stdio media server
23
+ * (flux-2-klein / kokoro / whisper), exposing
24
+ * `mcp__nan-media__generate_image` etc. Default: enabled,
25
+ * NAN_MEDIA_MCP=0 to disable.
26
+ *
27
+ * Both are session-scoped (visible in /mcp with source "extension").
28
+ * Exposure: `direct` — tools are declared to the model like built-ins.
26
29
  */
27
30
 
28
31
  import type { ContextEvent, ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
@@ -34,10 +37,11 @@ import {
34
37
  stripCrossModelThinking,
35
38
  } from "./cross-model-thinking-guard.ts";
36
39
  import { baselineModels } from "./fetch-models.ts";
37
- import { createNanWebSearchTool, webSearchBridgeEnabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
38
- import { createNanMediaTools, mediaMcpEnabled } from "./mcp/nan-media.ts";
39
40
  import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
40
41
  import { PROVIDERS } from "./providers.ts";
42
+ import { NAN_API_KEY_ENV, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
43
+ import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
44
+ import { registerMcpHostGuard } from "./mcp/host-support.ts";
41
45
 
42
46
  /**
43
47
  * Register a provider on any pi version: the native full-Provider overload
@@ -75,50 +79,97 @@ async function registerProviderCompat(
75
79
  contextWindow: model.contextWindow,
76
80
  maxTokens: model.maxTokens,
77
81
  ...(model.compat ? { compat: { ...model.compat } } : {}),
82
+ ...(model.thinkingLevelMap ? { thinkingLevelMap: { ...model.thinkingLevelMap } } : {}),
78
83
  })),
79
84
  };
80
85
  pi.registerProvider(config.id, legacy);
81
86
  }
82
87
 
88
+ // ── Native MCP registration ──────────────────────────────────────────────
89
+
83
90
  /**
84
- * Register MCP-bridged tools when the runtime supports them. Both bridges
85
- * are enabled by default and lazy (nothing runs until a tool is invoked);
86
- * `/nan-mcp` (and the NAN_MCP_TOOLS / NAN_MEDIA_MCP env vars) toggle them.
87
- * Old pi versions without registerTool/registerCommand skip gracefully.
88
- *
89
- * Registration is tracked in `registeredToolNames` so `/nan-mcp enable` can
90
- * add tools mid-session without double-registering.
91
+ * Guard message shown when pi lacks native MCP support (pi <0.99 despite peers).
92
+ * Matches the loud-skip style of existing guards (pi-ai, registerTool).
91
93
  */
92
- function registerMcpToolsCompat(pi: ExtensionAPI): void {
93
- if (typeof pi.registerTool !== "function") return;
94
- const registeredToolNames = new Set<string>();
95
- const registerSearchTool = () => {
96
- const search = createNanWebSearchTool();
97
- if (registeredToolNames.has(search.name)) return;
98
- registeredToolNames.add(search.name);
99
- pi.registerTool(search);
100
- };
101
- const registerMediaTools = () => {
102
- for (const tool of createNanMediaTools()) {
103
- if (registeredToolNames.has(tool.name)) continue;
104
- registeredToolNames.add(tool.name);
105
- pi.registerTool(tool);
106
- }
107
- };
94
+ const NO_NATIVE_MCP_GUARD_MSG =
95
+ "pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
108
96
 
109
- if (webSearchBridgeEnabled()) registerSearchTool();
110
- if (mediaMcpEnabled()) registerMediaTools();
111
- if (typeof pi.registerCommand === "function") {
112
- registerNanMcpCommand(pi, { registerWebSearchTools: registerSearchTool, registerMediaTools });
113
- registerNanUsageCommand(pi);
97
+ /** Register the official NaN web-search MCP server (session-scoped). */
98
+ async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | undefined): Promise<boolean> {
99
+ if (!webSearchBridgeEnabled()) return false;
100
+ if (!apiKey) {
101
+ console.warn(
102
+ "[pi-nan-provider] NAN_API_KEY is not set — web-search server not registered. " +
103
+ "Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
104
+ );
105
+ return false;
106
+ }
107
+ pi.registerMcpServer("nan-search", {
108
+ type: "http",
109
+ url: "https://api.nan.builders/mcp",
110
+ headers: { Authorization: `Bearer ${apiKey}` },
111
+ exposure: "direct",
112
+ });
113
+ return true;
114
+ }
115
+
116
+ /** Register the community media stdio MCP server (session-scoped). */
117
+ function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): boolean {
118
+ if (!mediaMcpEnabled()) return false;
119
+ if (!apiKey) {
120
+ console.warn(
121
+ "[pi-nan-provider] NAN_API_KEY is not set — media server not registered. " +
122
+ "Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
123
+ );
124
+ return false;
114
125
  }
126
+ const mediaArgs = mediaMcpCommand();
127
+ pi.registerMcpServer("nan-media", {
128
+ type: "stdio",
129
+ command: mediaArgs[0]!,
130
+ args: mediaArgs.slice(1),
131
+ env: { [NAN_API_KEY_ENV]: apiKey },
132
+ exposure: "direct",
133
+ timeout: mediaMcpTimeoutSec(),
134
+ });
135
+ return true;
115
136
  }
116
137
 
138
+ /**
139
+ * Register MCP servers via pi's native registerMcpServer.
140
+ *
141
+ * Session-scoped: servers appear in /mcp with source "extension", are
142
+ * visible to the model (exposure: "direct"), and user mcp.json entries
143
+ * with the same name take precedence.
144
+ *
145
+ * On pi <0.99 (registerMcpServer absent) this does NOT bridge — it
146
+ * notifies once and skips (same loud-skip style as existing guards).
147
+ * Hard cutover: users on older pi stay on package 0.9.x.
148
+ */
149
+ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
150
+ // Guard: pi >= 0.99 required for native MCP.
151
+ if (typeof pi.registerMcpServer !== "function") {
152
+ console.warn(NO_NATIVE_MCP_GUARD_MSG);
153
+ return;
154
+ }
155
+
156
+ // Resolve the API key ONCE: stored credential (auth.json) → env fallback.
157
+ // The factory-time ExtensionAPI has NO modelRegistry; stored credentials
158
+ // must be read through the coding-agent's readStoredCredential API.
159
+ const storedKey = resolveStoredNanApiKey();
160
+ const apiKey = storedKey ?? process.env[NAN_API_KEY_ENV];
161
+
162
+ await registerWebSearchMcpServer(pi, apiKey);
163
+ registerMediaMcpServer(pi, apiKey);
164
+ }
165
+
166
+ // ── Extension entrypoint ─────────────────────────────────────────────────
167
+
117
168
  /**
118
169
  * Drop the reasoning pi-ai replays across a model switch.
119
170
  *
120
171
  * pi-ai's `transformMessages` downgrades a previous model's `thinking` blocks
121
- * to plain text with no size bound (still true on 0.85.1 / main), and nothing
172
+ * to plain text with no size bound (verified on 0.87.1), and nothing
122
173
  * bounds the sum across messages — measured at 30–60% of the whole context on
123
174
  * real sessions. Switching from a 1M-context model to a 262K one (`qwen3.6`)
124
175
  * then overflows the window, and NaN's gateway answers a generic
@@ -143,10 +194,17 @@ export function registerCrossModelThinkingGuard(pi: ExtensionAPI): void {
143
194
 
144
195
  export default async function nanProviderExtension(pi: ExtensionAPI): Promise<void> {
145
196
  registerCrossModelThinkingGuard(pi);
197
+ // Replaces pi's cryptic "no loaded extension connects MCP servers" error with
198
+ // actionable guidance when the host loads no built-in `mcp` extension (PI WEB).
199
+ registerMcpHostGuard(pi);
146
200
  for (const config of PROVIDERS) {
147
201
  await registerProviderCompat(pi, config);
148
202
  }
149
- registerMcpToolsCompat(pi);
203
+ await registerMcpServersNative(pi);
204
+ if (typeof pi.registerCommand === "function") {
205
+ registerNanMcpCommand(pi);
206
+ registerNanUsageCommand(pi);
207
+ }
150
208
  }
151
209
 
152
210
  /** Exposed for tests: the env var this package uses for every NaN surface. */