@duckmind/dm-darwin-arm64 0.32.9 → 0.33.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/dm CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "status": "ok",
3
- "prepared_at": "2026-06-04T18:26:42.473665+00:00",
3
+ "prepared_at": "2026-06-05T06:35:45.464363+00:00",
4
4
  "managed_entries": [
5
5
  {
6
6
  "id": "dm-context",
@@ -225,6 +225,21 @@
225
225
  ],
226
226
  "pinned_ref": "46360329656206d7cab505f65eb5ca31d680e6fe"
227
227
  },
228
+ {
229
+ "id": "dm-9router-ext",
230
+ "upstream_name": "pi-9router-ext",
231
+ "source_url": "https://github.com/irfansofyana/pi-9router-ext.git",
232
+ "upstream_revision": "e5c82d549ad7665aae566cda0e9f9cda3daec998",
233
+ "target_dir": "extensions/dm-9router-ext",
234
+ "bundle_mode": "source-package-with-runtime-deps",
235
+ "copied_paths": [
236
+ "README.md",
237
+ "package.json",
238
+ "tsconfig.json",
239
+ "src/"
240
+ ],
241
+ "pinned_ref": "e5c82d549ad7665aae566cda0e9f9cda3daec998"
242
+ },
228
243
  {
229
244
  "id": "dm-chime",
230
245
  "upstream_name": "pi-chime",
@@ -419,6 +434,15 @@
419
434
  "install_mode": "none",
420
435
  "dependency_patches": []
421
436
  },
437
+ {
438
+ "id": "dm-9router-ext",
439
+ "source_dir": "extensions/dm-9router-ext",
440
+ "staged_dir": "dist/extensions/dm-9router-ext",
441
+ "bundle_mode": "source-package-with-runtime-deps",
442
+ "dependencies_installed": false,
443
+ "install_mode": "none",
444
+ "dependency_patches": []
445
+ },
422
446
  {
423
447
  "id": "dm-chime",
424
448
  "source_dir": "extensions/dm-chime",
@@ -0,0 +1,142 @@
1
+ # dm-9router-ext
2
+
3
+ [![npm](https://img.shields.io/npm/v/dm-9router-ext?color=blue)](https://www.npmjs.com/package/dm-9router-ext)
4
+
5
+ DM extension for [9router](https://github.com/decolua/9router) — an open-source AI routing proxy.
6
+
7
+ **Install:** `dm install npm:dm-9router-ext`
8
+
9
+ Connects DM to your 9router instance via its OpenAI-compatible API, with dynamic model discovery and interactive configuration.
10
+
11
+ ## Features
12
+
13
+ - **Auto-discovery** — Fetches available models and combos from 9router on startup
14
+ - **Dynamic provider** — Registers 9router as a DM provider with live model list
15
+ - **DM-native streaming** — Uses DM's built-in OpenAI completions provider without overriding other providers
16
+ - **Status commands** — `/9router-status`, `/9router-models`, `/9router-config`, `/9router-reload`
17
+ - **User-wide persistence** — Configuration survives new DM instances via `~/.dm/agent/9router-config.json`
18
+ - **Routing detection** — Captures upstream model info from response headers when available
19
+
20
+ ## Installation
21
+
22
+ ### npm (Recommended)
23
+
24
+ ```bash
25
+ dm install npm:dm-9router-ext
26
+ ```
27
+
28
+ ### Via local path
29
+
30
+ ```bash
31
+ # Clone or download this repo
32
+ git clone https://github.com/irfansofyana/pi-9router-ext.git
33
+
34
+ # Install locally
35
+ dm install /path/to/dm-9router-ext
36
+
37
+ # Or try without installing
38
+ dm -e /path/to/dm-9router-ext
39
+ ```
40
+
41
+ ### Manual (copy to extensions)
42
+
43
+ ```bash
44
+ cp -r dm-9router-ext/src/index.ts ~/.dm/agent/extensions/dm-9router-ext.ts
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ ### Environment Variables
50
+
51
+ | Variable | Default | Description |
52
+ |----------|---------|-------------|
53
+ | `NINE_ROUTER_BASE_URL` | `http://localhost:20128` | Your 9router instance URL |
54
+ | `NINE_ROUTER_API_KEY` | — | API key if 9router has `REQUIRE_API_KEY=true` |
55
+
56
+ Set them in your shell profile or prefix your `dm` command:
57
+
58
+ ```bash
59
+ NINE_ROUTER_BASE_URL=http://my-vps:20128 NINE_ROUTER_API_KEY=nr-... dm
60
+ ```
61
+
62
+ ### Interactive Configuration
63
+
64
+ Use the `/9router-config` command inside DM to set base URL and API key interactively. This is saved to `~/.dm/agent/9router-config.json` and is shared by new DM instances. Environment variables still take precedence when set.
65
+
66
+ ```
67
+ /9router-config
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ### Selecting a 9router Model
73
+
74
+ After the extension loads, 9router models are available in DM's model picker under the dedicated `9router/` provider namespace:
75
+
76
+ ```
77
+ /model 9router/cc/claude-opus-4-7
78
+ ```
79
+
80
+ Built-in providers remain separate. To use Ollama Cloud, OpenRouter, opencode-go, etc., select their normal provider/model entries (for example `ollama-cloud/...`), not a `9router/...` model.
81
+
82
+ Or browse interactively:
83
+
84
+ ```
85
+ /9router-models
86
+ ```
87
+
88
+ ### Available Commands
89
+
90
+ | Command | Description |
91
+ |---------|-------------|
92
+ | `/9router-status` | Show connection status, model count, and config |
93
+ | `/9router-models` | Browse and select from available 9router models |
94
+ | `/9router-config` | Interactively configure base URL and API key |
95
+ | `/9router-reload` | Refresh model list from 9router |
96
+
97
+ ### Available Tool
98
+
99
+ The LLM can call `ninerouter_status` to check connection status and list models programmatically.
100
+
101
+ ## How It Works
102
+
103
+ ```
104
+ ┌─────────┐ OpenAI-compatible ┌──────────┐ ┌─────────────┐
105
+ │ DM │ ──────────────────────────▶│ 9router │ ──▶ │ Providers │
106
+ │ │ /v1/chat/completions │ (proxy) │ │ (40+) │
107
+ └─────────┘ └──────────┘ └─────────────┘
108
+
109
+ │ 1. Fetches /v1/models on startup
110
+ │ 2. Registers as provider "9router"
111
+ │ 3. Uses pi-ai's normal OpenAI implementation for only 9router models
112
+ │ 4. Leaves built-in/non-9router model switching untouched
113
+ ```
114
+
115
+ ## Troubleshooting
116
+
117
+ **"Failed to discover models from 9router"**
118
+ - Check that 9router is running: `curl http://localhost:20128/v1/models`
119
+ - Verify `NINE_ROUTER_BASE_URL` points to the correct host/port
120
+ - If `REQUIRE_API_KEY=true`, set `NINE_ROUTER_API_KEY`
121
+
122
+ **"No 9router models discovered"**
123
+ - 9router may have no active providers. Open the dashboard and connect a provider.
124
+ - Use `/9router-reload` to retry discovery.
125
+
126
+ **Models not showing in `/model` selector**
127
+ - Ensure the extension loaded: check for "9router connected" notification on startup
128
+ - Run `/9router-reload` to retry discovery
129
+ - Run `/reload` to refresh extensions
130
+
131
+ **Built-in provider returns 401 after installing this extension**
132
+ - Make sure the active model is the built-in provider (`ollama-cloud/...`, `openrouter/...`, etc.), not a `9router/...` route.
133
+ - Re-run `/login` for the built-in provider if its subscription token expired.
134
+ - This extension only registers provider id `9router`; it does not override built-in providers. If 9router is unreachable, the extension unregisters the `9router` provider instead of leaving an empty/broken provider around.
135
+
136
+ ## Similar Projects
137
+
138
+ - [omniroute-pi-extension](https://www.npmjs.com/package/omniroute-pi-extension) — DM extension for OmniRoute (a fork of 9router with additional features)
139
+
140
+ ## License
141
+
142
+ MIT
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "dm-9router-ext",
3
+ "version": "0.1.1",
4
+ "description": "DM extension for 9router AI routing proxy instances",
5
+ "keywords": [
6
+ "duckmind",
7
+ "dm",
8
+ "dm-package",
9
+ "dm-extension",
10
+ "9router",
11
+ "ai-router",
12
+ "openai-proxy"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Irfan Sofyana <mail@irfansp.dev>",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/que-nguyen/dm.git",
19
+ "directory": "extensions/dm-9router-ext"
20
+ },
21
+ "type": "module",
22
+ "main": "./src/index.ts",
23
+ "files": [
24
+ "src",
25
+ "README.md",
26
+ "package.json",
27
+ "tsconfig.json"
28
+ ],
29
+ "scripts": {
30
+ "typecheck": "tsc"
31
+ },
32
+ "pi": {
33
+ "extensions": [
34
+ "./src/index.ts"
35
+ ]
36
+ },
37
+ "peerDependencies": {
38
+ "typebox": "*",
39
+ "@mariozechner/pi-coding-agent": "*"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "typescript": "^5.0.0"
44
+ }
45
+ }
@@ -0,0 +1,541 @@
1
+ /**
2
+ * dm-9router-ext
3
+ *
4
+ * DM extension for 9router — an open-source AI routing proxy.
5
+ * Connects DM to your 9router instance via its OpenAI-compatible API.
6
+ *
7
+ * Features:
8
+ * - Auto-discovers models and combos from 9router on startup
9
+ * - Registers 9router as a DM provider with dynamic base URL and API key
10
+ * - Status commands to view connection info and available models
11
+ * - User-persisted configuration shared by all DM instances
12
+ *
13
+ * Environment variables:
14
+ * NINE_ROUTER_BASE_URL - 9router endpoint (default: http://localhost:20128)
15
+ * NINE_ROUTER_API_KEY - API key if 9router requires authentication
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
22
+ import { Type } from "typebox";
23
+
24
+ // =============================================================================
25
+ // Types
26
+ // =============================================================================
27
+
28
+ interface NineRouterConfig {
29
+ baseUrl: string;
30
+ apiKey: string | undefined;
31
+ }
32
+
33
+ interface NineRouterModel {
34
+ id: string;
35
+ object: string;
36
+ owned_by?: string;
37
+ kind?: string;
38
+ }
39
+
40
+ interface NineRouterModelsResponse {
41
+ object: string;
42
+ data: NineRouterModel[];
43
+ }
44
+
45
+ // =============================================================================
46
+ // Constants
47
+ // =============================================================================
48
+
49
+ const DEFAULT_BASE_URL = "http://localhost:20128";
50
+ const ENV_BASE_URL = process.env.NINE_ROUTER_BASE_URL;
51
+ const ENV_API_KEY = process.env.NINE_ROUTER_API_KEY;
52
+ const CONFIG_PATH = join(homedir(), ".dm", "agent", "9router-config.json");
53
+
54
+ const CUSTOM_TYPE_CONFIG = "9router-config";
55
+ const CUSTOM_TYPE_LAST_ROUTE = "9router-last-route";
56
+
57
+ // Headers that may indicate the actual upstream model used
58
+ const ROUTING_HEADERS = [
59
+ "x-9router-model",
60
+ "x-routed-model",
61
+ "x-actual-model",
62
+ "x-upstream-model",
63
+ "x-provider-model",
64
+ ];
65
+
66
+ // =============================================================================
67
+ // Config Helpers
68
+ // =============================================================================
69
+
70
+ function normalizeBaseUrl(url: string): string {
71
+ return url.replace(/\/$/, "");
72
+ }
73
+
74
+ function maskApiKey(key: string): string {
75
+ if (key.length <= 8) return "●".repeat(key.length);
76
+ return key.slice(0, 4) + "●".repeat(Math.max(0, key.length - 8)) + key.slice(-4);
77
+ }
78
+
79
+ function applyEnvOverrides(config: NineRouterConfig): NineRouterConfig {
80
+ return {
81
+ baseUrl: normalizeBaseUrl(ENV_BASE_URL || config.baseUrl),
82
+ apiKey: ENV_API_KEY || config.apiKey,
83
+ };
84
+ }
85
+
86
+ function loadConfigFromDisk(): NineRouterConfig | null {
87
+ try {
88
+ if (!existsSync(CONFIG_PATH)) return null;
89
+ const data = JSON.parse(readFileSync(CONFIG_PATH, "utf8")) as Partial<NineRouterConfig>;
90
+ if (!data.baseUrl || typeof data.baseUrl !== "string") return null;
91
+ return {
92
+ baseUrl: normalizeBaseUrl(data.baseUrl),
93
+ apiKey: typeof data.apiKey === "string" && data.apiKey.trim()
94
+ ? data.apiKey.trim()
95
+ : undefined,
96
+ };
97
+ } catch (err) {
98
+ console.error("[dm-9router-ext] Failed to load persisted config:", err);
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function saveConfigToDisk(config: NineRouterConfig) {
104
+ try {
105
+ mkdirSync(dirname(CONFIG_PATH), { recursive: true });
106
+ writeFileSync(
107
+ CONFIG_PATH,
108
+ `${JSON.stringify({ baseUrl: config.baseUrl, apiKey: config.apiKey }, null, 2)}\n`,
109
+ { mode: 0o600 },
110
+ );
111
+ } catch (err) {
112
+ console.error("[dm-9router-ext] Failed to persist config:", err);
113
+ }
114
+ }
115
+
116
+ function getInitialConfig(): NineRouterConfig {
117
+ return applyEnvOverrides(loadConfigFromDisk() || {
118
+ baseUrl: DEFAULT_BASE_URL,
119
+ apiKey: undefined,
120
+ });
121
+ }
122
+
123
+ function loadConfigFromSession(ctx: ExtensionContext): NineRouterConfig | null {
124
+ for (const entry of ctx.sessionManager.getEntries()) {
125
+ if (entry.type === "custom" && entry.customType === CUSTOM_TYPE_CONFIG) {
126
+ const data = entry.data as Partial<NineRouterConfig> | undefined;
127
+ if (data?.baseUrl) {
128
+ return applyEnvOverrides({
129
+ baseUrl: normalizeBaseUrl(data.baseUrl),
130
+ apiKey: data.apiKey,
131
+ });
132
+ }
133
+ }
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function persistConfig(pi: ExtensionAPI, config: NineRouterConfig) {
139
+ saveConfigToDisk(config);
140
+ pi.appendEntry(CUSTOM_TYPE_CONFIG, {
141
+ baseUrl: config.baseUrl,
142
+ apiKey: config.apiKey,
143
+ });
144
+ }
145
+
146
+ // =============================================================================
147
+ // 9router API Client
148
+ // =============================================================================
149
+
150
+ async function fetchModels(
151
+ config: NineRouterConfig,
152
+ signal?: AbortSignal,
153
+ ): Promise<NineRouterModel[]> {
154
+ const headers: Record<string, string> = {
155
+ Accept: "application/json",
156
+ };
157
+ if (config.apiKey) {
158
+ headers.Authorization = `Bearer ${config.apiKey}`;
159
+ }
160
+
161
+ const response = await fetch(`${config.baseUrl}/v1/models`, {
162
+ method: "GET",
163
+ headers,
164
+ signal,
165
+ });
166
+
167
+ if (!response.ok) {
168
+ const text = await response.text().catch(() => "");
169
+ throw new Error(
170
+ `9router returned ${response.status}: ${text || response.statusText}`,
171
+ );
172
+ }
173
+
174
+ const payload = (await response.json()) as NineRouterModelsResponse;
175
+ return payload.data || [];
176
+ }
177
+
178
+ async function testConnection(
179
+ config: NineRouterConfig,
180
+ signal?: AbortSignal,
181
+ ): Promise<{ ok: boolean; error?: string }> {
182
+ try {
183
+ const headers: Record<string, string> = {};
184
+ if (config.apiKey) {
185
+ headers.Authorization = `Bearer ${config.apiKey}`;
186
+ }
187
+
188
+ const response = await fetch(`${config.baseUrl}/v1/models`, {
189
+ method: "GET",
190
+ headers,
191
+ signal,
192
+ });
193
+
194
+ if (response.ok) {
195
+ return { ok: true };
196
+ }
197
+ return { ok: false, error: `HTTP ${response.status}: ${response.statusText}` };
198
+ } catch (err) {
199
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
200
+ }
201
+ }
202
+
203
+ // =============================================================================
204
+ // Model Mapping
205
+ // =============================================================================
206
+
207
+ function mapNineRouterModel(model: NineRouterModel) {
208
+ const isCombo = model.owned_by === "combo";
209
+
210
+ return {
211
+ id: model.id,
212
+ name: isCombo ? `🔀 ${model.id}` : model.id,
213
+ reasoning: false,
214
+ input: ["text"] as ("text" | "image")[],
215
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
216
+ contextWindow: 128000,
217
+ maxTokens: 4096,
218
+ compat: {
219
+ // 9router is an OpenAI-compatible proxy; keep requests conservative so
220
+ // using this extension does not force built-in-provider-specific features.
221
+ supportsDeveloperRole: false,
222
+ supportsReasoningEffort: false,
223
+ },
224
+ };
225
+ }
226
+
227
+ // =============================================================================
228
+ // Provider Registration
229
+ // =============================================================================
230
+
231
+ function registerNineRouterProvider(
232
+ pi: ExtensionAPI,
233
+ config: NineRouterConfig,
234
+ models: NineRouterModel[],
235
+ ) {
236
+ // Always use a dedicated provider id ("9router") and never override built-in
237
+ // providers like ollama-cloud/openrouter. DM requires apiKey for custom
238
+ // providers with models; 9router receives the real key only when configured,
239
+ // otherwise a harmless placeholder is scoped to the 9router provider.
240
+ pi.registerProvider("9router", {
241
+ name: "9router",
242
+ baseUrl: `${config.baseUrl}/v1`,
243
+ apiKey: config.apiKey || "9router-no-api-key",
244
+ api: "openai-completions",
245
+ models: models.map(mapNineRouterModel),
246
+ });
247
+ }
248
+
249
+ function unregisterNineRouterProvider(pi: ExtensionAPI) {
250
+ pi.unregisterProvider("9router");
251
+ }
252
+
253
+ // =============================================================================
254
+ // Extension Factory
255
+ // =============================================================================
256
+
257
+ export default async function (pi: ExtensionAPI) {
258
+ // ---------------------------------------------------------------------------
259
+ // Load configuration (env vars are defaults; session config applied later)
260
+ // ---------------------------------------------------------------------------
261
+ let config: NineRouterConfig = getInitialConfig();
262
+
263
+ // State that survives across the extension lifetime
264
+ let discoveredModels: NineRouterModel[] = [];
265
+ let lastRoutedModel: string | undefined;
266
+ let activeProvider: string | undefined;
267
+ let isConnected = false;
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // Provider registration (async factory = models available immediately)
271
+ // ---------------------------------------------------------------------------
272
+ try {
273
+ const models = await fetchModels(config);
274
+ discoveredModels = models;
275
+ isConnected = true;
276
+ registerNineRouterProvider(pi, config, models);
277
+ } catch (err) {
278
+ // Do not register an empty/broken provider on startup. Leaving the provider
279
+ // absent is safer for built-in DM providers and model selection. Commands
280
+ // remain available so the user can configure/reload 9router later.
281
+ unregisterNineRouterProvider(pi);
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Session start: rehydrate config from session
286
+ // ---------------------------------------------------------------------------
287
+ pi.on("session_start", async (_event, ctx) => {
288
+ const restored = loadConfigFromSession(ctx);
289
+ if (!loadConfigFromDisk() && restored) {
290
+ // Migrate old session-persisted config to the new user-wide config file.
291
+ config = restored;
292
+ persistConfig(pi, config);
293
+ try {
294
+ const models = await fetchModels(config, ctx.signal);
295
+ discoveredModels = models;
296
+ isConnected = true;
297
+ registerNineRouterProvider(pi, config, models);
298
+ } catch (err) {
299
+ isConnected = false;
300
+ console.error("[dm-9router-ext] Failed to refresh migrated config:", err);
301
+ }
302
+ }
303
+
304
+ if (isConnected && discoveredModels.length > 0) {
305
+ ctx.ui.notify(
306
+ `9router connected — ${discoveredModels.length} models available`,
307
+ "info",
308
+ );
309
+ }
310
+ });
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Detect routed model from response headers
314
+ // ---------------------------------------------------------------------------
315
+ pi.on("after_provider_response", (event) => {
316
+ if (event.status >= 400 || activeProvider !== "9router") {
317
+ return;
318
+ }
319
+
320
+ for (const header of ROUTING_HEADERS) {
321
+ const value = event.headers[header];
322
+ if (value && typeof value === "string") {
323
+ lastRoutedModel = value;
324
+ pi.appendEntry(CUSTOM_TYPE_LAST_ROUTE, {
325
+ model: value,
326
+ timestamp: Date.now(),
327
+ });
328
+ break;
329
+ }
330
+ }
331
+ });
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Clear routing info when model changes
335
+ // ---------------------------------------------------------------------------
336
+ pi.on("model_select", async (event) => {
337
+ activeProvider = event.model.provider;
338
+ if (event.model.provider !== "9router") {
339
+ lastRoutedModel = undefined;
340
+ }
341
+ });
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Command: /9router-status
345
+ // ---------------------------------------------------------------------------
346
+ pi.registerCommand("9router-status", {
347
+ description: "Show 9router connection status and configuration",
348
+ handler: async (_args, ctx) => {
349
+ const test = await testConnection(config, ctx.signal);
350
+ const lines: string[] = [
351
+ `🔗 9router Status`,
352
+ ``,
353
+ `Base URL: ${config.baseUrl}`,
354
+ `API Key: ${config.apiKey ? maskApiKey(config.apiKey) : "not set"}`,
355
+ `Connection: ${test.ok ? "🟢 connected" : `🔴 ${test.error || "disconnected"}`}`,
356
+ `Models: ${discoveredModels.length} available`,
357
+ ];
358
+
359
+ if (lastRoutedModel) {
360
+ lines.push(`Last routed: ${lastRoutedModel}`);
361
+ }
362
+
363
+ const combos = discoveredModels.filter((m) => m.owned_by === "combo");
364
+ const regular = discoveredModels.filter((m) => m.owned_by !== "combo");
365
+ if (regular.length > 0) {
366
+ lines.push(``, `Regular models: ${regular.length}`);
367
+ }
368
+ if (combos.length > 0) {
369
+ lines.push(`Combos: ${combos.length}`);
370
+ }
371
+
372
+ ctx.ui.notify(lines.join("\n"), test.ok ? "info" : "warning");
373
+ },
374
+ });
375
+
376
+ // ---------------------------------------------------------------------------
377
+ // Command: /9router-models
378
+ // ---------------------------------------------------------------------------
379
+ pi.registerCommand("9router-models", {
380
+ description: "Browse 9router available models and combos",
381
+ handler: async (_args, ctx) => {
382
+ if (discoveredModels.length === 0) {
383
+ ctx.ui.notify(
384
+ "No 9router models discovered. Check connection with /9router-status",
385
+ "warning",
386
+ );
387
+ return;
388
+ }
389
+
390
+ const items = discoveredModels.map((m) => {
391
+ const isCombo = m.owned_by === "combo";
392
+ return {
393
+ value: m.id,
394
+ label: isCombo ? `🔀 ${m.id}` : m.id,
395
+ };
396
+ });
397
+
398
+ const selected = await ctx.ui.select(
399
+ "Select a 9router model to use:",
400
+ items.map((i) => i.label),
401
+ );
402
+ if (!selected) return;
403
+
404
+ const modelId = items.find((i) => i.label === selected)?.value;
405
+ if (!modelId) return;
406
+
407
+ const fullModelId = `9router/${modelId}`;
408
+ ctx.ui.notify(`Switching to ${fullModelId}...`, "info");
409
+ pi.sendUserMessage(`/model ${fullModelId}`, { deliverAs: "followUp" });
410
+ },
411
+ });
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Command: /9router-config
415
+ // ---------------------------------------------------------------------------
416
+ pi.registerCommand("9router-config", {
417
+ description: "Configure 9router base URL and API key",
418
+ handler: async (_args, ctx) => {
419
+ // Show current config first
420
+ const test = await testConnection(config, ctx.signal);
421
+ const currentStatus = test.ok ? "🟢 connected" : "🔴 disconnected";
422
+ const currentApiKeyDisplay = config.apiKey ? "●●●●●●●● (set)" : "not set";
423
+
424
+ const currentLines = [
425
+ "Current config:",
426
+ ` Base URL: ${config.baseUrl}`,
427
+ ` API Key: ${config.apiKey ? maskApiKey(config.apiKey) : "not set"}`,
428
+ ` Status: ${currentStatus}`,
429
+ "",
430
+ "Enter new values (press Enter to keep current):",
431
+ ].join("\n");
432
+
433
+ ctx.ui.notify(currentLines, "info");
434
+ const newBaseUrl = await ctx.ui.input("Base URL", config.baseUrl);
435
+ if (newBaseUrl === undefined) return; // cancelled
436
+
437
+ const newApiKey = await ctx.ui.input(
438
+ "API key (press Enter to keep current, leave blank to remove):",
439
+ config.apiKey || "",
440
+ );
441
+ if (newApiKey === undefined) return; // cancelled
442
+
443
+ config = {
444
+ baseUrl: normalizeBaseUrl(newBaseUrl),
445
+ apiKey: newApiKey.trim() || undefined,
446
+ };
447
+
448
+ persistConfig(pi, config);
449
+
450
+ // Try to refresh models and re-register provider
451
+ try {
452
+ const models = await fetchModels(config, ctx.signal);
453
+ discoveredModels = models;
454
+ isConnected = true;
455
+
456
+ registerNineRouterProvider(pi, config, models);
457
+
458
+ ctx.ui.notify(
459
+ `9router updated — ${models.length} models at ${config.baseUrl}`,
460
+ "info",
461
+ );
462
+ } catch (err) {
463
+ isConnected = false;
464
+ unregisterNineRouterProvider(pi);
465
+ const msg = err instanceof Error ? err.message : String(err);
466
+ ctx.ui.notify(`Failed to connect: ${msg}`, "error");
467
+ }
468
+ },
469
+ });
470
+
471
+ // ---------------------------------------------------------------------------
472
+ // Command: /9router-reload
473
+ // ---------------------------------------------------------------------------
474
+ pi.registerCommand("9router-reload", {
475
+ description: "Reload models from 9router",
476
+ handler: async (_args, ctx) => {
477
+ try {
478
+ const models = await fetchModels(config, ctx.signal);
479
+ discoveredModels = models;
480
+ isConnected = true;
481
+
482
+ registerNineRouterProvider(pi, config, models);
483
+
484
+ ctx.ui.notify(
485
+ `9router reloaded — ${models.length} models`,
486
+ "info",
487
+ );
488
+ } catch (err) {
489
+ isConnected = false;
490
+ unregisterNineRouterProvider(pi);
491
+ const msg = err instanceof Error ? err.message : String(err);
492
+ ctx.ui.notify(`Reload failed: ${msg}`, "error");
493
+ }
494
+ },
495
+ });
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Tool: ninerouter_status
499
+ // ---------------------------------------------------------------------------
500
+ pi.registerTool({
501
+ name: "ninerouter_status",
502
+ label: "9router Status",
503
+ description:
504
+ "Check 9router connection status and list available models",
505
+ parameters: Type.Object({}),
506
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
507
+ const test = await testConnection(config, ctx.signal);
508
+ const combos = discoveredModels.filter((m) => m.owned_by === "combo");
509
+ const regular = discoveredModels.filter((m) => m.owned_by !== "combo");
510
+
511
+ return {
512
+ content: [
513
+ {
514
+ type: "text",
515
+ text: [
516
+ `9router: ${test.ok ? "connected" : `disconnected (${test.error})`}`,
517
+ `Base URL: ${config.baseUrl}`,
518
+ `Total models: ${discoveredModels.length}`,
519
+ ` Regular: ${regular.length}`,
520
+ ` Combos: ${combos.length}`,
521
+ lastRoutedModel
522
+ ? `Last routed model: ${lastRoutedModel}`
523
+ : "",
524
+ ]
525
+ .filter(Boolean)
526
+ .join("\n"),
527
+ },
528
+ ],
529
+ details: {
530
+ connected: test.ok,
531
+ baseUrl: config.baseUrl,
532
+ modelCount: discoveredModels.length,
533
+ regularCount: regular.length,
534
+ comboCount: combos.length,
535
+ lastRoutedModel,
536
+ models: discoveredModels.map((m) => m.id),
537
+ },
538
+ };
539
+ },
540
+ });
541
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "noEmit": true,
8
+ "allowImportingTsExtensions": true,
9
+ "skipLibCheck": true,
10
+ "types": [
11
+ "node"
12
+ ]
13
+ },
14
+ "include": [
15
+ "src/**/*.ts"
16
+ ]
17
+ }
@@ -4006,9 +4006,9 @@
4006
4006
  "license": "0BSD"
4007
4007
  },
4008
4008
  "node_modules/typebox": {
4009
- "version": "1.2.0",
4010
- "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.0.tgz",
4011
- "integrity": "sha512-5zmwtMQArPorr1PVqo5Ye3GRmaiHQ7iicRnlmRXWWDLg5k5fky8OG40B12ckVq5YIxpGM8s1qzcfcA8SKNjPVw==",
4009
+ "version": "1.2.1",
4010
+ "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.1.tgz",
4011
+ "integrity": "sha512-0upGv6+mxJR7/Wc7yoxjc/U6SjOk2aNDNzbihYacSHh+JfOsf28IJ8ggW4/3tRlDKfbInvEDPVneEywjOWYCzw==",
4012
4012
  "dev": true,
4013
4013
  "license": "MIT"
4014
4014
  },
@@ -5,9 +5,9 @@
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "node_modules/typebox": {
8
- "version": "1.2.0",
9
- "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.0.tgz",
10
- "integrity": "sha512-5zmwtMQArPorr1PVqo5Ye3GRmaiHQ7iicRnlmRXWWDLg5k5fky8OG40B12ckVq5YIxpGM8s1qzcfcA8SKNjPVw==",
8
+ "version": "1.2.1",
9
+ "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.1.tgz",
10
+ "integrity": "sha512-0upGv6+mxJR7/Wc7yoxjc/U6SjOk2aNDNzbihYacSHh+JfOsf28IJ8ggW4/3tRlDKfbInvEDPVneEywjOWYCzw==",
11
11
  "license": "MIT"
12
12
  }
13
13
  }
@@ -309,8 +309,8 @@ export type TPatternBigIntMapping<Input extends '-?(?:0|[1-9][0-9]*)n'> = (T.TBi
309
309
  export declare function PatternBigIntMapping(input: '-?(?:0|[1-9][0-9]*)n'): unknown;
310
310
  export type TPatternStringMapping<Input extends '.*'> = (T.TString);
311
311
  export declare function PatternStringMapping(input: '.*'): unknown;
312
- export type TPatternNumberMapping<Input extends '-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?'> = (T.TNumber);
313
- export declare function PatternNumberMapping(input: '-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?'): unknown;
312
+ export type TPatternNumberMapping<Input extends '-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?'> = (T.TNumber);
313
+ export declare function PatternNumberMapping(input: '-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?'): unknown;
314
314
  export type TPatternIntegerMapping<Input extends '-?(?:0|[1-9][0-9]*)'> = (T.TInteger);
315
315
  export declare function PatternIntegerMapping(input: '-?(?:0|[1-9][0-9]*)'): unknown;
316
316
  export type TPatternNeverMapping<Input extends '(?!)'> = (T.TNever);
@@ -106,10 +106,10 @@ export type TJsonArray<Input extends string> = (Token.TConst<'[', Input> extends
106
106
  export type TJson<Input extends string> = (TJsonNumber<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TJsonBoolean<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TJsonString<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TJsonNull<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TJsonObject<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TJsonArray<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : []) extends [infer _0 extends unknown, infer Input extends string] ? [S.TJsonMapping<_0>, Input] : [];
107
107
  export type TPatternBigInt<Input extends string> = Token.TConst<'-?(?:0|[1-9][0-9]*)n', Input> extends [infer _0 extends '-?(?:0|[1-9][0-9]*)n', infer Input extends string] ? [S.TPatternBigIntMapping<_0>, Input] : [];
108
108
  export type TPatternString<Input extends string> = Token.TConst<'.*', Input> extends [infer _0 extends '.*', infer Input extends string] ? [S.TPatternStringMapping<_0>, Input] : [];
109
- export type TPatternNumber<Input extends string> = Token.TConst<'-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?', Input> extends [infer _0 extends '-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?', infer Input extends string] ? [S.TPatternNumberMapping<_0>, Input] : [];
109
+ export type TPatternNumber<Input extends string> = Token.TConst<'-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?', Input> extends [infer _0 extends '-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?', infer Input extends string] ? [S.TPatternNumberMapping<_0>, Input] : [];
110
110
  export type TPatternInteger<Input extends string> = Token.TConst<'-?(?:0|[1-9][0-9]*)', Input> extends [infer _0 extends '-?(?:0|[1-9][0-9]*)', infer Input extends string] ? [S.TPatternIntegerMapping<_0>, Input] : [];
111
111
  export type TPatternNever<Input extends string> = Token.TConst<'(?!)', Input> extends [infer _0 extends '(?!)', infer Input extends string] ? [S.TPatternNeverMapping<_0>, Input] : [];
112
- export type TPatternText<Input extends string> = Token.TUntil_1<['-?(?:0|[1-9][0-9]*)n', '.*', '-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?', '-?(?:0|[1-9][0-9]*)', '(?!)', '(', ')', '$', '|'], Input> extends [infer _0 extends string, infer Input extends string] ? [S.TPatternTextMapping<_0>, Input] : [];
112
+ export type TPatternText<Input extends string> = Token.TUntil_1<['-?(?:0|[1-9][0-9]*)n', '.*', '-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?', '-?(?:0|[1-9][0-9]*)', '(?!)', '(', ')', '$', '|'], Input> extends [infer _0 extends string, infer Input extends string] ? [S.TPatternTextMapping<_0>, Input] : [];
113
113
  export type TPatternBase<Input extends string> = (TPatternBigInt<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TPatternString<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TPatternNumber<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TPatternInteger<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TPatternNever<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TPatternGroup<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : TPatternText<Input> extends [infer _0, infer Input extends string] ? [_0, Input] : []) extends [infer _0 extends unknown, infer Input extends string] ? [S.TPatternBaseMapping<_0>, Input] : [];
114
114
  export type TPatternGroup<Input extends string> = (Token.TConst<'(', Input> extends [infer _0, infer Input extends string] ? (TPatternBody<Input> extends [infer _1, infer Input extends string] ? (Token.TConst<')', Input> extends [infer _2, infer Input extends string] ? [[_0, _1, _2], Input] : []) : []) : []) extends [infer _0 extends [unknown, unknown, unknown], infer Input extends string] ? [S.TPatternGroupMapping<_0>, Input] : [];
115
115
  export type TPatternUnion<Input extends string> = ((TPatternTerm<Input> extends [infer _0, infer Input extends string] ? (Token.TConst<'|', Input> extends [infer _1, infer Input extends string] ? (TPatternUnion<Input> extends [infer _2, infer Input extends string] ? [[_0, _1, _2], Input] : []) : []) : []) extends [infer _0, infer Input extends string] ? [_0, Input] : (TPatternTerm<Input> extends [infer _0, infer Input extends string] ? [[_0], Input] : []) extends [infer _0, infer Input extends string] ? [_0, Input] : [[], Input] extends [infer _0, infer Input extends string] ? [_0, Input] : []) extends [infer _0 extends [unknown, unknown, unknown] | [unknown] | [], infer Input extends string] ? [S.TPatternUnionMapping<_0>, Input] : [];
@@ -110,10 +110,10 @@ export const JsonArray = (input) => If(If(Token.Const('[', input), ([_0, input])
110
110
  export const Json = (input) => If(If(JsonNumber(input), ([_0, input]) => [_0, input], () => If(JsonBoolean(input), ([_0, input]) => [_0, input], () => If(JsonString(input), ([_0, input]) => [_0, input], () => If(JsonNull(input), ([_0, input]) => [_0, input], () => If(JsonObject(input), ([_0, input]) => [_0, input], () => If(JsonArray(input), ([_0, input]) => [_0, input], () => [])))))), ([_0, input]) => [S.JsonMapping(_0), input]);
111
111
  export const PatternBigInt = (input) => If(Token.Const('-?(?:0|[1-9][0-9]*)n', input), ([_0, input]) => [S.PatternBigIntMapping(_0), input]);
112
112
  export const PatternString = (input) => If(Token.Const('.*', input), ([_0, input]) => [S.PatternStringMapping(_0), input]);
113
- export const PatternNumber = (input) => If(Token.Const('-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?', input), ([_0, input]) => [S.PatternNumberMapping(_0), input]);
113
+ export const PatternNumber = (input) => If(Token.Const('-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?', input), ([_0, input]) => [S.PatternNumberMapping(_0), input]);
114
114
  export const PatternInteger = (input) => If(Token.Const('-?(?:0|[1-9][0-9]*)', input), ([_0, input]) => [S.PatternIntegerMapping(_0), input]);
115
115
  export const PatternNever = (input) => If(Token.Const('(?!)', input), ([_0, input]) => [S.PatternNeverMapping(_0), input]);
116
- export const PatternText = (input) => If(Token.Until_1(['-?(?:0|[1-9][0-9]*)n', '.*', '-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?', '-?(?:0|[1-9][0-9]*)', '(?!)', '(', ')', '$', '|'], input), ([_0, input]) => [S.PatternTextMapping(_0), input]);
116
+ export const PatternText = (input) => If(Token.Until_1(['-?(?:0|[1-9][0-9]*)n', '.*', '-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?', '-?(?:0|[1-9][0-9]*)', '(?!)', '(', ')', '$', '|'], input), ([_0, input]) => [S.PatternTextMapping(_0), input]);
117
117
  export const PatternBase = (input) => If(If(PatternBigInt(input), ([_0, input]) => [_0, input], () => If(PatternString(input), ([_0, input]) => [_0, input], () => If(PatternNumber(input), ([_0, input]) => [_0, input], () => If(PatternInteger(input), ([_0, input]) => [_0, input], () => If(PatternNever(input), ([_0, input]) => [_0, input], () => If(PatternGroup(input), ([_0, input]) => [_0, input], () => If(PatternText(input), ([_0, input]) => [_0, input], () => []))))))), ([_0, input]) => [S.PatternBaseMapping(_0), input]);
118
118
  export const PatternGroup = (input) => If(If(Token.Const('(', input), ([_0, input]) => If(PatternBody(input), ([_1, input]) => If(Token.Const(')', input), ([_2, input]) => [[_0, _1, _2], input]))), ([_0, input]) => [S.PatternGroupMapping(_0), input]);
119
119
  export const PatternUnion = (input) => If(If(If(PatternTerm(input), ([_0, input]) => If(Token.Const('|', input), ([_1, input]) => If(PatternUnion(input), ([_2, input]) => [[_0, _1, _2], input]))), ([_0, input]) => [_0, input], () => If(If(PatternTerm(input), ([_0, input]) => [[_0], input]), ([_0, input]) => [_0, input], () => If([[], input], ([_0, input]) => [_0, input], () => []))), ([_0, input]) => [S.PatternUnionMapping(_0), input]);
@@ -1,5 +1,5 @@
1
1
  import { type TSchema, type TNumberOptions } from './schema.mjs';
2
- export declare const NumberPattern = "-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?";
2
+ export declare const NumberPattern = "-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?";
3
3
  export type StaticNumber = number;
4
4
  /** Represents a Number type. */
5
5
  export interface TNumber extends TSchema {
@@ -4,7 +4,7 @@ import { IsKind } from './schema.mjs';
4
4
  // ------------------------------------------------------------------
5
5
  // Pattern
6
6
  // ------------------------------------------------------------------
7
- export const NumberPattern = '-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?';
7
+ export const NumberPattern = '-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?';
8
8
  // ------------------------------------------------------------------
9
9
  // Factory
10
10
  // ------------------------------------------------------------------
@@ -14,7 +14,7 @@ export type TStringKey = typeof StringKey;
14
14
  export type TIntegerKey = typeof IntegerKey;
15
15
  export type TNumberKey = typeof NumberKey;
16
16
  export declare const IntegerKey = "^-?(?:0|[1-9][0-9]*)$";
17
- export declare const NumberKey = "^-?(?:0|[1-9][0-9]*)(?:.[0-9]+)?$";
17
+ export declare const NumberKey = "^-?(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?$";
18
18
  export declare const StringKey = "^.*$";
19
19
  export interface TRecord<Key extends string = string, Value extends TSchema = TSchema> extends TSchema {
20
20
  '~kind': 'Record';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "typebox",
3
3
  "description": "Json Schema Type Builder with Static Type Resolution for TypeScript",
4
- "version": "1.2.0",
4
+ "version": "1.2.1",
5
5
  "keywords": [
6
6
  "typescript",
7
7
  "jsonschema"
@@ -1256,9 +1256,9 @@
1256
1256
  "optional": true
1257
1257
  },
1258
1258
  "node_modules/typebox": {
1259
- "version": "1.2.0",
1260
- "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.0.tgz",
1261
- "integrity": "sha512-5zmwtMQArPorr1PVqo5Ye3GRmaiHQ7iicRnlmRXWWDLg5k5fky8OG40B12ckVq5YIxpGM8s1qzcfcA8SKNjPVw==",
1259
+ "version": "1.2.1",
1260
+ "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.2.1.tgz",
1261
+ "integrity": "sha512-0upGv6+mxJR7/Wc7yoxjc/U6SjOk2aNDNzbihYacSHh+JfOsf28IJ8ggW4/3tRlDKfbInvEDPVneEywjOWYCzw==",
1262
1262
  "license": "MIT"
1263
1263
  },
1264
1264
  "node_modules/typescript": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duckmind/dm-darwin-arm64",
3
- "version": "0.32.9",
3
+ "version": "0.33.0",
4
4
  "description": "DuckMind (dm) binary payload for darwin arm64",
5
5
  "license": "MIT",
6
6
  "os": [