@gtrabanco/pi-nan-provider 0.7.0 → 0.10.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/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,10 @@ 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, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
43
+ import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
41
44
 
42
45
  /**
43
46
  * Register a provider on any pi version: the native full-Provider overload
@@ -80,45 +83,78 @@ async function registerProviderCompat(
80
83
  pi.registerProvider(config.id, legacy);
81
84
  }
82
85
 
86
+ // ── Native MCP registration ──────────────────────────────────────────────
87
+
83
88
  /**
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.
89
+ * Guard message shown when pi lacks native MCP support (pi <0.99 despite peers).
90
+ * Matches the loud-skip style of existing guards (pi-ai, registerTool).
91
91
  */
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
- };
92
+ const NO_NATIVE_MCP_GUARD_MSG =
93
+ "pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
108
94
 
109
- if (webSearchBridgeEnabled()) registerSearchTool();
110
- if (mediaMcpEnabled()) registerMediaTools();
111
- if (typeof pi.registerCommand === "function") {
112
- registerNanMcpCommand(pi, { registerWebSearchTools: registerSearchTool, registerMediaTools });
113
- registerNanUsageCommand(pi);
95
+ /** Register the official NaN web-search MCP server (session-scoped). */
96
+ async function registerWebSearchMcpServer(pi: ExtensionAPI): Promise<boolean> {
97
+ if (!webSearchBridgeEnabled()) return false;
98
+ const apiKey = process.env[NAN_API_KEY_ENV];
99
+ if (!apiKey) {
100
+ return false;
114
101
  }
102
+ pi.registerMcpServer("nan-search", {
103
+ type: "http",
104
+ url: "https://api.nan.builders/mcp",
105
+ headers: { Authorization: `Bearer ${apiKey}` },
106
+ exposure: "direct",
107
+ });
108
+ return true;
109
+ }
110
+
111
+ /** Register the community media stdio MCP server (session-scoped). */
112
+ function registerMediaMcpServer(pi: ExtensionAPI): boolean {
113
+ if (!mediaMcpEnabled()) return false;
114
+ const mediaArgs = mediaMcpCommand();
115
+ pi.registerMcpServer("nan-media", {
116
+ type: "stdio",
117
+ command: mediaArgs[0]!,
118
+ args: mediaArgs.slice(1),
119
+ env: { [NAN_API_KEY_ENV]: process.env[NAN_API_KEY_ENV] ?? "" },
120
+ exposure: "direct",
121
+ timeout: mediaMcpTimeoutSec(),
122
+ });
123
+ return true;
115
124
  }
116
125
 
126
+ /**
127
+ * Register MCP servers via pi's native registerMcpServer.
128
+ *
129
+ * Session-scoped: servers appear in /mcp with source "extension", are
130
+ * visible to the model (exposure: "direct"), and user mcp.json entries
131
+ * with the same name take precedence.
132
+ *
133
+ * On pi <0.99 (registerMcpServer absent) this does NOT bridge — it
134
+ * notifies once and skips (same loud-skip style as existing guards).
135
+ * Hard cutover: users on older pi stay on package 0.9.x.
136
+ */
137
+ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
138
+ // Guard: pi >= 0.99 required for native MCP.
139
+ if (typeof pi.registerMcpServer !== "function") {
140
+ console.warn(NO_NATIVE_MCP_GUARD_MSG);
141
+ return;
142
+ }
143
+
144
+
145
+ const didWebSearch = await registerWebSearchMcpServer(pi);
146
+ registerMediaMcpServer(pi);
147
+
148
+
149
+ }
150
+
151
+ // ── Extension entrypoint ─────────────────────────────────────────────────
152
+
117
153
  /**
118
154
  * Drop the reasoning pi-ai replays across a model switch.
119
155
  *
120
156
  * 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
157
+ * to plain text with no size bound (verified on 0.87.1), and nothing
122
158
  * bounds the sum across messages — measured at 30–60% of the whole context on
123
159
  * real sessions. Switching from a 1M-context model to a 262K one (`qwen3.6`)
124
160
  * then overflows the window, and NaN's gateway answers a generic
@@ -146,7 +182,11 @@ export default async function nanProviderExtension(pi: ExtensionAPI): Promise<vo
146
182
  for (const config of PROVIDERS) {
147
183
  await registerProviderCompat(pi, config);
148
184
  }
149
- registerMcpToolsCompat(pi);
185
+ await registerMcpServersNative(pi);
186
+ if (typeof pi.registerCommand === "function") {
187
+ registerNanMcpCommand(pi);
188
+ registerNanUsageCommand(pi);
189
+ }
150
190
  }
151
191
 
152
192
  /** Exposed for tests: the env var this package uses for every NaN surface. */
@@ -0,0 +1,76 @@
1
+ import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
2
+
3
+ /**
4
+ * NaN API key resolution — shared across every surface (web search, media, usage).
5
+ *
6
+ * Strategy: pi's stored credential (modelRegistry) wins → env var fallback.
7
+ * Never logs or embeds the key beyond the Authorization header.
8
+ */
9
+
10
+ /** Env var that overrides the official web_search bridge. */
11
+ export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
12
+ /** Env var that overrides the media bridge. */
13
+ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
14
+ /** The NaN API key env var used everywhere. */
15
+ export const NAN_API_KEY_ENV = "NAN_API_KEY";
16
+ /** NaN provider id used for stored-credential lookup. */
17
+ const NAN_PROVIDER_ID = "nan";
18
+
19
+ /**
20
+ * Resolve the NaN API key: pi's registry first (covers stored auth.json),
21
+ * then env fallback. `registryKey` is the result of an attempted registry
22
+ * lookup (already caught and converted to string | undefined by the caller).
23
+ */
24
+ export function resolveNanApiKey(registryKey: string | undefined): string | undefined {
25
+ if (registryKey) return registryKey;
26
+ return process.env[NAN_API_KEY_ENV] || undefined;
27
+ }
28
+
29
+ /**
30
+ * Attempt to resolve the API key through pi's model registry.
31
+ * Catches any error and returns undefined so the caller can fall back to env.
32
+ */
33
+ export async function tryResolveNanApiKeyViaRegistry(
34
+ modelRegistry: { getApiKeyForProvider(provider: string): Promise<string | undefined> } | undefined,
35
+ ): Promise<string | undefined> {
36
+ try {
37
+ return await modelRegistry?.getApiKeyForProvider(NAN_PROVIDER_ID);
38
+ } catch {
39
+ // Registry unavailable — caller falls back to env.
40
+ return undefined;
41
+ }
42
+ }
43
+
44
+ /** Whether MCP tool registration is disabled via env (NAN_MCP_TOOLS=0|false|off). */
45
+ export function mcpToolsDisabled(): boolean {
46
+ const value = process.env[NAN_MCP_TOOLS_ENV]?.trim().toLowerCase();
47
+ return value === "0" || value === "false" || value === "off";
48
+ }
49
+
50
+ /** Whether NAN_MCP_TOOLS is explicitly set (any value). */
51
+ export function mcpToolsEnvExplicit(): boolean {
52
+ const value = process.env[NAN_MCP_TOOLS_ENV];
53
+ return value !== undefined && value.trim() !== "";
54
+ }
55
+
56
+ /** Whether NAN_MEDIA_MCP is explicitly set (any value). */
57
+ export function mediaMcpEnvExplicit(): boolean {
58
+ const value = process.env[NAN_MEDIA_MCP_ENV];
59
+ return value !== undefined && value.trim() !== "";
60
+ }
61
+
62
+ /** Whether NAN_MEDIA_MCP is explicitly set and truthy. */
63
+ export function mediaMcpEnvTruthy(): boolean {
64
+ const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
65
+ return value === "1" || value === "true" || value === "on";
66
+ }
67
+
68
+ /** Source of the effective web-search enablement (env / persisted / default). */
69
+ export function webSearchBridgeSource(): import("./state.ts").BridgeSource {
70
+ return bridgeSource("webSearch", mcpToolsEnvExplicit());
71
+ }
72
+
73
+ /** Whether the web-search bridge is enabled by gate (env → persisted → default). */
74
+ export function webSearchBridgeEnabled(): boolean {
75
+ return resolveBridgeEnabled("webSearch", mcpToolsEnvExplicit(), !mcpToolsDisabled(), true);
76
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Media server construction helpers — version pin, command, timeout.
3
+ * Also exports bridge gate resolution (mediaMcpEnabled / mediaMcpSource)
4
+ * to keep the native MCP registration in index.ts minimal.
5
+ */
6
+ import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
7
+
8
+ /**
9
+ * Default spawn: `npx -y nan-mcp-server@<pinned version>` (npx caches after first use).
10
+ */
11
+ export function mediaMcpCommand(version = process.env[NAN_MEDIA_MCP_VERSION_ENV] || DEFAULT_NAN_MEDIA_MCP_VERSION): string[] {
12
+ const custom = process.env[NAN_MEDIA_MCP_COMMAND_ENV]?.trim();
13
+ if (custom) return custom.split(/\s+/);
14
+ return ["npx", "-y", `nan-mcp-server@${version}`];
15
+ }
16
+
17
+ /** Per-request timeout in milliseconds. Native MCP uses seconds. */
18
+ export function mediaMcpTimeoutMs(): number {
19
+ const parsed = Number.parseInt(process.env[NAN_MEDIA_MCP_TIMEOUT_ENV] ?? "", 10);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MEDIA_MCP_TIMEOUT_MS;
21
+ }
22
+
23
+ /** Per-request timeout in **seconds** for the native MCP config. */
24
+ export function mediaMcpTimeoutSec(): number {
25
+ return Math.ceil(mediaMcpTimeoutMs() / 1000);
26
+ }
27
+
28
+ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
29
+
30
+ export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.1.2";
31
+ export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
32
+ export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
33
+ export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
34
+ export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
35
+
36
+ /** Whether NAN_MEDIA_MCP is explicitly set (any value). */
37
+ export function mediaMcpEnvExplicit(): boolean {
38
+ const value = process.env[NAN_MEDIA_MCP_ENV];
39
+ return value !== undefined && value.trim() !== "";
40
+ }
41
+
42
+ /** Whether NAN_MEDIA_MCP is explicitly set and truthy. */
43
+ export function mediaMcpEnvTruthy(): boolean {
44
+ const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
45
+ return value === "1" || value === "true" || value === "on";
46
+ }
47
+
48
+ /** Whether the media bridge is enabled by gate (env → persisted → default). */
49
+ export function mediaMcpEnabled(): boolean {
50
+ return resolveBridgeEnabled("mediaMcp", mediaMcpEnvExplicit(), mediaMcpEnvTruthy(), true);
51
+ }
52
+
53
+ /** Source of the effective media enablement. */
54
+ export function mediaMcpSource(): BridgeSource {
55
+ return bridgeSource("mediaMcp", mediaMcpEnvExplicit());
56
+ }
@@ -14,8 +14,8 @@
14
14
  * statically import ONLY the bare `@earendil-works/pi-ai` root. pi's
15
15
  * extension loader maps that specifier to the compat entrypoint on every
16
16
  * supported runtime (bundled CLI, Node-mode aliases, compiled-binary
17
- * virtualModules; pi 0.83 and 0.84 alike), and compat re-exports every lazy
18
- * API factory. Subpath specifiers (`@earendil-works/pi-ai/api/...`) get the
17
+ * virtualModules; pi 0.83 through 0.87.1 alike), and compat re-exports every
18
+ * lazy API factory. Subpath specifiers (`@earendil-works/pi-ai/api/...`) get the
19
19
  * alias applied as a prefix and resolve to `<compat.js>/api/...`, which does
20
20
  * not exist — the extension then fails to load entirely.
21
21
  *
@@ -79,6 +79,11 @@ export interface NanCompatibleProviderOptions {
79
79
  * Any caller-supplied `onPayload` (e.g. pi's own debug/passthrough hook) is
80
80
  * preserved and chained AFTER sanitization, so the final payload is always
81
81
  * schema-valid.
82
+ *
83
+ * Note (pi-ai ≥ 0.86): this wrapper forwards the `TranscriptContext` parameter
84
+ * unchanged and only rewrites `options.onPayload` — the `TranscriptContext`
85
+ * migration (branded `TranscriptContext = { messages: Message[] }` replacing
86
+ * `Context`) does not affect this function's behavior.
82
87
  */
83
88
  function isObject(value: unknown): value is Record<string, unknown> {
84
89
  return typeof value === "object" && value !== null;