@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/AGENTS.md +104 -23
- package/README.es.md +68 -15
- package/README.md +54 -20
- package/package.json +5 -5
- package/scripts/check-nan-mcp-server.ts +11 -3
- package/scripts/generate-models.ts +66 -12
- package/scripts/manual-overrides.ts +78 -14
- package/scripts/models.generated.ts +82 -58
- package/src/commands.ts +181 -55
- package/src/context-overflow-classifier.ts +1 -1
- package/src/cross-model-thinking-guard.ts +19 -1
- package/src/fetch-models.ts +11 -0
- package/src/index.ts +99 -41
- package/src/mcp/api-key.ts +109 -0
- package/src/mcp/host-support.ts +94 -0
- package/src/mcp/media-server.ts +56 -0
- package/src/mcp/state.ts +6 -0
- package/src/provider-factory.ts +7 -2
- package/src/usage.ts +10 -5
- package/src/mcp/nan-media.ts +0 -236
- package/src/mcp/nan-search.ts +0 -241
- package/src/mcp/stdio-client.ts +0 -222
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
|
|
2
|
+
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* NaN API key resolution — shared across every surface (web search, media, usage).
|
|
6
|
+
*
|
|
7
|
+
* Strategy: pi's stored credential (modelRegistry) wins → env var fallback.
|
|
8
|
+
* Never logs or embeds the key beyond the Authorization header.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the NaN API key through pi's stored credential (auth.json).
|
|
13
|
+
* Reads synchronously via `readStoredCredential("nan")` — the factory-time
|
|
14
|
+
* ExtensionAPI has NO modelRegistry (verified pi 0.99.1); stored credentials
|
|
15
|
+
* must be resolved at load time through the coding-agent API.
|
|
16
|
+
*
|
|
17
|
+
* Guards the credential shape: only accepts `{ type: "api_key", key: "sk-..." }`
|
|
18
|
+
* with a non-empty string key. Wraps in try/catch → undefined on any error.
|
|
19
|
+
*
|
|
20
|
+
* Precedence: this function is the **stored-credential** layer; callers compose
|
|
21
|
+
* it with the env var fallback to achieve stored → env order.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveStoredNanApiKey(): string | undefined {
|
|
24
|
+
try {
|
|
25
|
+
const credential = readStoredCredential(NAN_PROVIDER_ID);
|
|
26
|
+
if (
|
|
27
|
+
credential != null &&
|
|
28
|
+
typeof credential === "object" &&
|
|
29
|
+
"type" in credential &&
|
|
30
|
+
credential.type === "api_key" &&
|
|
31
|
+
"key" in credential &&
|
|
32
|
+
typeof credential.key === "string" &&
|
|
33
|
+
credential.key.length > 0
|
|
34
|
+
) {
|
|
35
|
+
return credential.key;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Env var that overrides the official web_search bridge. */
|
|
44
|
+
export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
|
|
45
|
+
/** Env var that overrides the media bridge. */
|
|
46
|
+
export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
|
|
47
|
+
/** The NaN API key env var used everywhere. */
|
|
48
|
+
export const NAN_API_KEY_ENV = "NAN_API_KEY";
|
|
49
|
+
/** NaN provider id used for stored-credential lookup. */
|
|
50
|
+
const NAN_PROVIDER_ID = "nan";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the NaN API key: pi's registry first (covers stored auth.json),
|
|
54
|
+
* then env fallback. `registryKey` is the result of an attempted registry
|
|
55
|
+
* lookup (already caught and converted to string | undefined by the caller).
|
|
56
|
+
*/
|
|
57
|
+
export function resolveNanApiKey(registryKey: string | undefined): string | undefined {
|
|
58
|
+
if (registryKey) return registryKey;
|
|
59
|
+
return process.env[NAN_API_KEY_ENV] || undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Attempt to resolve the API key through pi's model registry.
|
|
64
|
+
* Catches any error and returns undefined so the caller can fall back to env.
|
|
65
|
+
*/
|
|
66
|
+
export async function tryResolveNanApiKeyViaRegistry(
|
|
67
|
+
modelRegistry: { getApiKeyForProvider(provider: string): Promise<string | undefined> } | undefined,
|
|
68
|
+
): Promise<string | undefined> {
|
|
69
|
+
try {
|
|
70
|
+
return await modelRegistry?.getApiKeyForProvider(NAN_PROVIDER_ID);
|
|
71
|
+
} catch {
|
|
72
|
+
// Registry unavailable — caller falls back to env.
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
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
|
+
/** Whether NAN_MEDIA_MCP is explicitly set (any value). */
|
|
90
|
+
export function mediaMcpEnvExplicit(): boolean {
|
|
91
|
+
const value = process.env[NAN_MEDIA_MCP_ENV];
|
|
92
|
+
return value !== undefined && value.trim() !== "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether NAN_MEDIA_MCP is explicitly set and truthy. */
|
|
96
|
+
export function mediaMcpEnvTruthy(): boolean {
|
|
97
|
+
const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
|
|
98
|
+
return value === "1" || value === "true" || value === "on";
|
|
99
|
+
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side MCP connector detection plus the `session_start` guard that keeps
|
|
3
|
+
* this package's registrations from tripping pi's cryptic extension error.
|
|
4
|
+
*
|
|
5
|
+
* pi connects servers registered with `pi.registerMcpServer()` from the
|
|
6
|
+
* built-in `mcp` extension: it registers the `/mcp` command and handles
|
|
7
|
+
* `mcp_servers_change`. When NO loaded extension handles that event, pi reports
|
|
8
|
+
* every registration as an extension error right after the `session_start`
|
|
9
|
+
* emit (`ExtensionRunner.reportUnhandledMcpServers()` called from
|
|
10
|
+
* `AgentSession.bindExtensions()`):
|
|
11
|
+
*
|
|
12
|
+
* MCP server "nan-media" is registered, but no loaded extension connects MCP
|
|
13
|
+
* servers; another extension may have replaced the built-in MCP support
|
|
14
|
+
*
|
|
15
|
+
* Hosts that build pi's `DefaultResourceLoader` without `extensionFactories`
|
|
16
|
+
* load no `builtin:*` extension at all — including `builtin:mcp`. PI WEB does
|
|
17
|
+
* exactly that (`createAgentSessionServices` → `new DefaultResourceLoader(...)`,
|
|
18
|
+
* verified on pi 0.99.1 / pi-web 1.202609.1), so in every PI WEB session this
|
|
19
|
+
* package's registrations are dead and pi reports the error above.
|
|
20
|
+
*
|
|
21
|
+
* Detection: the built-in MCP extension always registers `/mcp`, and pi's
|
|
22
|
+
* replacement contract is phrased in the same terms ("an extension that
|
|
23
|
+
* registers `/mcp` ... replaces the built-in one"). When `pi.getCommands()` is
|
|
24
|
+
* unavailable (older pi, minimal mocks) the answer is unknowable, so we stay
|
|
25
|
+
* conservative and assume a connector exists — pi's own behavior is untouched.
|
|
26
|
+
*
|
|
27
|
+
* With no connector we claim `mcp_servers_change` ourselves: handling the event
|
|
28
|
+
* is pi's signal that somebody connects the servers, so its report stays silent
|
|
29
|
+
* and our warning (with the fix) replaces the cryptic one. The claim is
|
|
30
|
+
* released by a later `session_start` that finds a connector. Known trade-off:
|
|
31
|
+
* while claimed, pi's report is silent for every extension's registration, not
|
|
32
|
+
* only ours — that is the price of replacing an error we cannot scope, and our
|
|
33
|
+
* warning still names the missing connector.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import { MCP_SERVER_NAMES } from "./state.ts";
|
|
38
|
+
|
|
39
|
+
/** Built-in MCP command; duplicate registrations resolve to `mcp:<n>`. */
|
|
40
|
+
const MCP_COMMAND_PATTERN = /^mcp(?::\d+)?$/;
|
|
41
|
+
|
|
42
|
+
/** Servers this package registers; only they are ours to explain or claim for. */
|
|
43
|
+
const OWNED_SERVER_NAMES: string[] = Object.values(MCP_SERVER_NAMES);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether some loaded extension connects MCP servers.
|
|
47
|
+
*
|
|
48
|
+
* `true` whenever it cannot be determined (no `pi.getCommands()`), so callers
|
|
49
|
+
* only ever act on a positively missing connector.
|
|
50
|
+
*/
|
|
51
|
+
export function hasMcpConnector(pi: ExtensionAPI): boolean {
|
|
52
|
+
if (typeof pi.getCommands !== "function") return true;
|
|
53
|
+
try {
|
|
54
|
+
const commands = pi.getCommands();
|
|
55
|
+
if (!Array.isArray(commands)) return true;
|
|
56
|
+
return commands.some((command) => MCP_COMMAND_PATTERN.test(command?.name ?? ""));
|
|
57
|
+
} catch {
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Actionable explanation of a missing connector, naming the affected servers. */
|
|
63
|
+
export function connectorMissingMessage(serverNames: string[]): string {
|
|
64
|
+
const names = serverNames.map((name) => `\`${name}\``).join(" and ");
|
|
65
|
+
return (
|
|
66
|
+
"MCP connector: MISSING — no loaded extension connects MCP servers in this session " +
|
|
67
|
+
"(pi's built-in `/mcp` command is not loaded), so " +
|
|
68
|
+
`${names} cannot expose tools. Load the built-in connector with \`pi config\` ` +
|
|
69
|
+
"→ Built-in extensions → `mcp`; hosts that load no built-in extensions " +
|
|
70
|
+
"(PI WEB sessions) have no MCP support at all."
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Guard against pi's "registered, but no loaded extension connects MCP servers"
|
|
76
|
+
* error. Registered from the extension entrypoint; see the module comment.
|
|
77
|
+
*/
|
|
78
|
+
export function registerMcpHostGuard(pi: ExtensionAPI): void {
|
|
79
|
+
if (typeof pi.on !== "function") return; // old pi without event hooks
|
|
80
|
+
let releaseClaim: (() => void) | undefined;
|
|
81
|
+
pi.on("session_start", (_event, ctx) => {
|
|
82
|
+
releaseClaim?.();
|
|
83
|
+
releaseClaim = undefined;
|
|
84
|
+
const registered = typeof pi.getMcpServers === "function" ? pi.getMcpServers() : [];
|
|
85
|
+
const owned = registered
|
|
86
|
+
.map((server) => server.name)
|
|
87
|
+
.filter((name) => OWNED_SERVER_NAMES.includes(name));
|
|
88
|
+
// Nothing registered (pi <0.99 guard, gates off) → nothing to explain.
|
|
89
|
+
if (owned.length === 0) return;
|
|
90
|
+
if (hasMcpConnector(pi)) return;
|
|
91
|
+
releaseClaim = pi.on("mcp_servers_change", () => {});
|
|
92
|
+
ctx.ui.notify(connectorMissingMessage(owned), "warning");
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -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
|
+
}
|
package/src/mcp/state.ts
CHANGED
|
@@ -25,6 +25,12 @@ export interface NanProviderState {
|
|
|
25
25
|
|
|
26
26
|
export type BridgeKey = keyof NanProviderState;
|
|
27
27
|
|
|
28
|
+
/** Server name each bridge registers with `pi.registerMcpServer()`. Single source of truth. */
|
|
29
|
+
export const MCP_SERVER_NAMES: Record<BridgeKey, string> = {
|
|
30
|
+
webSearch: "nan-search",
|
|
31
|
+
mediaMcp: "nan-media",
|
|
32
|
+
};
|
|
33
|
+
|
|
28
34
|
export type BridgeSource = "env" | "persisted" | "default";
|
|
29
35
|
|
|
30
36
|
function stateFilePath(): string {
|
package/src/provider-factory.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/usage.ts
CHANGED
|
@@ -13,8 +13,11 @@
|
|
|
13
13
|
* (NaN docs, checked 2026-09-27).
|
|
14
14
|
*
|
|
15
15
|
* The endpoint reports consumption, never caps: quotas below come from
|
|
16
|
-
* https://nan.builders/docs/models (checked 2026-09-
|
|
17
|
-
* the returned per-model totals.
|
|
16
|
+
* https://nan.builders/docs/models (checked 2026-09-29) and are merged with
|
|
17
|
+
* the returned per-model totals. mimo-v2.5 has no row: NaN removed it from
|
|
18
|
+
* the docs (checked 2026-09-29), so a cap for it would have no source —
|
|
19
|
+
* historical consumption still appears under the undocumented-models section.
|
|
20
|
+
* `/usage` returns daily rows (paginated) plus
|
|
18
21
|
* `totals` covering the whole requested window — max 90 inclusive days, wider
|
|
19
22
|
* windows are rejected with 400 — so the command reads `totals.by_model` and
|
|
20
23
|
* asks for a single-row page. Rate limit: 30 requests/min, separate from the
|
|
@@ -22,7 +25,7 @@
|
|
|
22
25
|
*/
|
|
23
26
|
|
|
24
27
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
25
|
-
import { NAN_API_KEY_ENV, resolveNanApiKey } from "./mcp/
|
|
28
|
+
import { NAN_API_KEY_ENV, resolveNanApiKey, tryResolveNanApiKeyViaRegistry } from "./mcp/api-key.ts";
|
|
26
29
|
|
|
27
30
|
// ── Known quota limits per model (from NaN docs) ──────────────────────────
|
|
28
31
|
|
|
@@ -43,7 +46,6 @@ export interface ModelQuota {
|
|
|
43
46
|
|
|
44
47
|
export const MODEL_QUOTAS: readonly ModelQuota[] = [
|
|
45
48
|
{ model: "deepseek-v4-flash", label: "DeepSeek V4 Flash", monthlyCap: 3_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
46
|
-
{ model: "mimo-v2.5", label: "MiMo V2.5", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
47
49
|
{ model: "mimo-v2.6-flash", label: "MiMo V2.6 Flash", monthlyCap: 1_000_000_000, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
48
50
|
{ model: "qwen3.6", label: "Qwen 3.6", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
49
51
|
{ model: "gemma4", label: "Gemma 4", monthlyCap: 0, rollingWindowCap: 0, rollingWindowHours: 0, premium: false },
|
|
@@ -429,7 +431,10 @@ export interface NanUsageCommandOptions {
|
|
|
429
431
|
export function registerNanUsageCommand(pi: ExtensionAPI, options: NanUsageCommandOptions = {}): void {
|
|
430
432
|
if (typeof pi.registerCommand !== "function") return;
|
|
431
433
|
|
|
432
|
-
const resolveApiKey = options.resolveApiKey ?? ((ctx: ExtensionCommandContext) =>
|
|
434
|
+
const resolveApiKey = options.resolveApiKey ?? (async (ctx: ExtensionCommandContext): Promise<string | undefined> => {
|
|
435
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
436
|
+
return resolveNanApiKey(registryKey);
|
|
437
|
+
});
|
|
433
438
|
|
|
434
439
|
pi.registerCommand("nan-usage", {
|
|
435
440
|
description: "Show NaN token usage vs monthly caps, window/all-time totals, and time until billing reset",
|
package/src/mcp/nan-media.ts
DELETED
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Optional bridge to the community stdio MCP server
|
|
3
|
-
* `nan-mcp-server` (https://github.com/luciferfran/nan-mcp-server), which
|
|
4
|
-
* exposes NaN's media tools: image generation/editing (flux-2-klein), TTS
|
|
5
|
-
* (kokoro), STT (whisper), plus voice listing.
|
|
6
|
-
*
|
|
7
|
-
* Both MCP bridges are enabled by default and fully lazy:
|
|
8
|
-
* - ON by default. NAN_MEDIA_MCP=0 (or false/off) disables it for one
|
|
9
|
-
* session; `/nan-mcp disable nan-mcp-server` persists the toggle.
|
|
10
|
-
* - The server process is spawned per tool call and terminated right after —
|
|
11
|
-
* zero startup cost, nothing runs unless audio/image/transcription is
|
|
12
|
-
* actually invoked.
|
|
13
|
-
* - NAN_API_KEY is forwarded to the child; NAN_OUTPUT_DIR and the server's
|
|
14
|
-
* other env vars inherit from your environment (generated files land in
|
|
15
|
-
* ~/nan-mcp-output/ by default).
|
|
16
|
-
* - Version pinning follows the upstream server's own supply-chain guidance:
|
|
17
|
-
* NAN_MEDIA_MCP_VERSION (default "1.1.2"), or pass a custom command with
|
|
18
|
-
* NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.1.2").
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import { Type, type TSchema } from "@earendil-works/pi-ai";
|
|
22
|
-
import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import { callStdioMcpTool } from "./stdio-client.ts";
|
|
24
|
-
import { bridgeSource, resolveBridgeEnabled, type BridgeSource } from "./state.ts";
|
|
25
|
-
import { NAN_API_KEY_ENV, resolveNanApiKey, type NapiKeyContext } from "./nan-search.ts";
|
|
26
|
-
|
|
27
|
-
export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
|
|
28
|
-
export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
|
|
29
|
-
export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
|
|
30
|
-
export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
|
|
31
|
-
export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.1.2";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* The MCP tool names on the stdio nan-mcp-server that this package bridges as
|
|
35
|
-
* pi tools. Kept as the single source of truth for the check script
|
|
36
|
-
* (scripts/check-nan-mcp-server.ts) and to type each spec's `mcpTool` so the
|
|
37
|
-
* bridge cannot drift from the set it claims to expose.
|
|
38
|
-
*/
|
|
39
|
-
export const NAN_MEDIA_MCP_SERVER_TOOLS = [
|
|
40
|
-
"generate_image",
|
|
41
|
-
"edit_image",
|
|
42
|
-
"text_to_speech",
|
|
43
|
-
"list_voices",
|
|
44
|
-
"speech_to_text",
|
|
45
|
-
] as const;
|
|
46
|
-
export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
|
|
47
|
-
|
|
48
|
-
/** Media tools bridged from the stdio MCP server (audio/image/transcription scope). */
|
|
49
|
-
export const NAN_MEDIA_TOOLS = [
|
|
50
|
-
"nan_generate_image",
|
|
51
|
-
"nan_edit_image",
|
|
52
|
-
"nan_text_to_speech",
|
|
53
|
-
"nan_list_voices",
|
|
54
|
-
"nan_speech_to_text",
|
|
55
|
-
] as const;
|
|
56
|
-
|
|
57
|
-
/** Truthy env parse for the media bridge env var: 1/true/on (case-insensitive). */
|
|
58
|
-
function envTruthy(): boolean {
|
|
59
|
-
return envValueIsTruthy(process.env[NAN_MEDIA_MCP_ENV]);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Whether the env var is explicitly set (any value) — it overrides the persisted toggle. */
|
|
63
|
-
function envExplicit(): boolean {
|
|
64
|
-
const value = process.env[NAN_MEDIA_MCP_ENV];
|
|
65
|
-
return value !== undefined && value.trim() !== "";
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function envValueIsTruthy(value: string | undefined): boolean {
|
|
69
|
-
return value?.trim().toLowerCase() === "1" || value?.trim().toLowerCase() === "true" || value?.trim().toLowerCase() === "on";
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Effective enablement of the community media MCP bridge: an explicit
|
|
74
|
-
* `NAN_MEDIA_MCP` env var (any value, e.g. `0` to force one session off)
|
|
75
|
-
* wins; then the toggle persisted by `/nan-mcp`; default: **enabled** (both
|
|
76
|
-
* bridges are on and lazy by default — the server spawns per tool call).
|
|
77
|
-
*/
|
|
78
|
-
export function mediaMcpEnabled(): boolean {
|
|
79
|
-
return resolveBridgeEnabled("mediaMcp", envExplicit(), envTruthy(), true);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Source of the effective media enablement (env / persisted / default). */
|
|
83
|
-
export function mediaMcpSource(): BridgeSource {
|
|
84
|
-
return bridgeSource("mediaMcp", envExplicit());
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Default spawn: `npx -y nan-mcp-server@<pinned version>` (npx caches after first use). */
|
|
88
|
-
export function mediaMcpCommand(version = process.env[NAN_MEDIA_MCP_VERSION_ENV] || DEFAULT_NAN_MEDIA_MCP_VERSION): string[] {
|
|
89
|
-
const custom = process.env[NAN_MEDIA_MCP_COMMAND_ENV]?.trim();
|
|
90
|
-
if (custom) return custom.split(/\s+/);
|
|
91
|
-
return ["npx", "-y", `nan-mcp-server@${version}`];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function mediaMcpTimeoutMs(): number {
|
|
95
|
-
const parsed = Number.parseInt(process.env[NAN_MEDIA_MCP_TIMEOUT_ENV] ?? "", 10);
|
|
96
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MEDIA_MCP_TIMEOUT_MS;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface MediaToolSpec<TParams extends TSchema = TSchema> {
|
|
100
|
-
name: (typeof NAN_MEDIA_TOOLS)[number];
|
|
101
|
-
mcpTool: (typeof NAN_MEDIA_MCP_SERVER_TOOLS)[number];
|
|
102
|
-
label: string;
|
|
103
|
-
description: string;
|
|
104
|
-
promptSnippet: string;
|
|
105
|
-
parameters: TParams;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const sizeProperty = () =>
|
|
109
|
-
Type.Optional(
|
|
110
|
-
Type.String({
|
|
111
|
-
description: 'Image size "WxH" divisible by 16, e.g. 1024x1024, 1536x1024, 1024x1536. Default 1024x1024',
|
|
112
|
-
}),
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
function defineMediaTool<TParams extends TSchema>(spec: MediaToolSpec<TParams>): ToolDefinition<TParams> {
|
|
116
|
-
return {
|
|
117
|
-
name: spec.name,
|
|
118
|
-
label: spec.label,
|
|
119
|
-
description: spec.description,
|
|
120
|
-
promptSnippet: spec.promptSnippet,
|
|
121
|
-
parameters: spec.parameters,
|
|
122
|
-
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
|
|
123
|
-
const apiKey = await resolveNanApiKey(ctx as unknown as NapiKeyContext);
|
|
124
|
-
if (!apiKey) {
|
|
125
|
-
throw new Error(`${NAN_API_KEY_ENV} is not set. Export it or run /login nan to use NaN media tools.`);
|
|
126
|
-
}
|
|
127
|
-
const result = await callStdioMcpTool(spec.mcpTool, params as Record<string, unknown>, {
|
|
128
|
-
command: mediaMcpCommand(),
|
|
129
|
-
env: { [NAN_API_KEY_ENV]: apiKey },
|
|
130
|
-
timeoutMs: mediaMcpTimeoutMs(),
|
|
131
|
-
...(signal ? { signal } : {}),
|
|
132
|
-
});
|
|
133
|
-
if (!result.ok) {
|
|
134
|
-
throw new Error(
|
|
135
|
-
`${result.error ?? "nan-mcp-server call failed"}${result.stderrTail ? ` (server stderr: ${result.stderrTail})` : ""}`,
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
return { content: [{ type: "text", text: result.text }], details: undefined } as const;
|
|
139
|
-
},
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* Build the media tool set. Registered only when NAN_MEDIA_MCP=1 and the
|
|
145
|
-
* runtime supports registerTool; execution spawns the MCP server per call.
|
|
146
|
-
* Schemas mirror nan-mcp-server's zod input schemas (v1.1.2).
|
|
147
|
-
*/
|
|
148
|
-
export function createNanMediaTools(): ToolDefinition[] {
|
|
149
|
-
return [
|
|
150
|
-
defineMediaTool({
|
|
151
|
-
name: "nan_generate_image",
|
|
152
|
-
mcpTool: "generate_image",
|
|
153
|
-
label: "NaN Generate Image",
|
|
154
|
-
description:
|
|
155
|
-
"Generate an image with flux-2-klein (NaN API). Returns the saved image file path (under ~/nan-mcp-output/ by default) and its URL.",
|
|
156
|
-
promptSnippet: "nan_generate_image(prompt, size?, n?, seed?, guidance?, outputName?): generate an image via NaN (flux-2-klein)",
|
|
157
|
-
parameters: Type.Object({
|
|
158
|
-
prompt: Type.String({ description: "Textual description of the image to generate" }),
|
|
159
|
-
size: sizeProperty(),
|
|
160
|
-
n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
|
|
161
|
-
seed: Type.Optional(Type.Number({ description: "Base seed for reproducibility" })),
|
|
162
|
-
guidance: Type.Optional(Type.Number({ description: "FLUX guidance scale" })),
|
|
163
|
-
outputName: Type.Optional(Type.String({ description: "Optional base name for the output file(s)" })),
|
|
164
|
-
}),
|
|
165
|
-
}),
|
|
166
|
-
defineMediaTool({
|
|
167
|
-
name: "nan_edit_image",
|
|
168
|
-
mcpTool: "edit_image",
|
|
169
|
-
label: "NaN Edit Image",
|
|
170
|
-
description:
|
|
171
|
-
"Edit an image with flux-2-klein image-to-image (NaN API). Takes reference image files and applies a transformation. Returns the saved output image path.",
|
|
172
|
-
promptSnippet: "nan_edit_image(prompt, images, size?, n?, seed?, guidance?, outputName?): edit images via NaN (flux-2-klein)",
|
|
173
|
-
parameters: Type.Object({
|
|
174
|
-
prompt: Type.String({ description: "Description of the edit or transformation to apply" }),
|
|
175
|
-
images: Type.Array(Type.String(), {
|
|
176
|
-
description: "Absolute paths to reference image files (PNG, JPEG, WebP; up to 4, each < 25MB)",
|
|
177
|
-
minItems: 1,
|
|
178
|
-
maxItems: 4,
|
|
179
|
-
}),
|
|
180
|
-
size: sizeProperty(),
|
|
181
|
-
n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
|
|
182
|
-
seed: Type.Optional(Type.Number({ description: "Base seed for reproducibility" })),
|
|
183
|
-
guidance: Type.Optional(Type.Number({ description: "FLUX guidance scale" })),
|
|
184
|
-
outputName: Type.Optional(Type.String({ description: "Optional base name for the output file(s)" })),
|
|
185
|
-
}),
|
|
186
|
-
}),
|
|
187
|
-
defineMediaTool({
|
|
188
|
-
name: "nan_text_to_speech",
|
|
189
|
-
mcpTool: "text_to_speech",
|
|
190
|
-
label: "NaN Text To Speech",
|
|
191
|
-
description:
|
|
192
|
-
"Synthesize audio from text with kokoro (NaN API TTS). Returns the saved audio file path. Use nan_list_voices to see all available voices per language.",
|
|
193
|
-
promptSnippet: "nan_text_to_speech(text, voice?, format?, speed?, outputName?): synthesize speech via NaN (kokoro)",
|
|
194
|
-
parameters: Type.Object({
|
|
195
|
-
text: Type.String({ description: "Text to synthesize" }),
|
|
196
|
-
voice: Type.Optional(
|
|
197
|
-
Type.String({
|
|
198
|
-
description:
|
|
199
|
-
'Voice to use, e.g. "af_heart" (American English female), "ef_dora" (Spanish female), "em_alex" (Spanish male). Use nan_list_voices for the full catalog',
|
|
200
|
-
}),
|
|
201
|
-
),
|
|
202
|
-
format: Type.Optional(
|
|
203
|
-
Type.Unsafe<"mp3" | "wav" | "flac" | "aac" | "pcm" | "opus">({
|
|
204
|
-
type: "string",
|
|
205
|
-
enum: ["mp3", "wav", "flac", "aac", "pcm", "opus"],
|
|
206
|
-
description: "Audio format. Default mp3",
|
|
207
|
-
}),
|
|
208
|
-
),
|
|
209
|
-
speed: Type.Optional(Type.Number({ description: "Speech speed. Default 1.0" })),
|
|
210
|
-
outputName: Type.Optional(Type.String({ description: "Optional base name for the output file" })),
|
|
211
|
-
}),
|
|
212
|
-
}),
|
|
213
|
-
defineMediaTool({
|
|
214
|
-
name: "nan_list_voices",
|
|
215
|
-
mcpTool: "list_voices",
|
|
216
|
-
label: "NaN List Voices",
|
|
217
|
-
description: "List all available kokoro TTS voices grouped by language.",
|
|
218
|
-
promptSnippet: "nan_list_voices(): list available kokoro TTS voices",
|
|
219
|
-
parameters: Type.Object({}),
|
|
220
|
-
}),
|
|
221
|
-
defineMediaTool({
|
|
222
|
-
name: "nan_speech_to_text",
|
|
223
|
-
mcpTool: "speech_to_text",
|
|
224
|
-
label: "NaN Speech To Text",
|
|
225
|
-
description: "Transcribe an audio file with whisper (NaN API STT). Returns the transcript.",
|
|
226
|
-
promptSnippet: "nan_speech_to_text(file, language?, verbose?): transcribe an audio file via NaN (whisper)",
|
|
227
|
-
parameters: Type.Object({
|
|
228
|
-
file: Type.String({ description: "Absolute path to the audio file to transcribe" }),
|
|
229
|
-
language: Type.Optional(
|
|
230
|
-
Type.String({ description: 'ISO-639-1 language code, e.g. "es", "en". Auto-detected if omitted' }),
|
|
231
|
-
),
|
|
232
|
-
verbose: Type.Optional(Type.Boolean({ description: "Return verbose JSON with segments instead of plain text" })),
|
|
233
|
-
}),
|
|
234
|
-
}),
|
|
235
|
-
];
|
|
236
|
-
}
|