@xynogen/pix-9router 0.6.0 → 0.8.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/README.md +22 -32
- package/package.json +4 -5
- package/src/data.ts +3 -3
- package/src/index.ts +5 -14
- package/src/provider.ts +152 -4
- package/src/catalog.ts +0 -45
- package/src/command.ts +0 -212
- package/src/defaults.ts +0 -44
- package/src/http.ts +0 -102
- package/src/recorder.ts +0 -103
- package/src/render.ts +0 -151
- package/src/stt-command.ts +0 -127
- package/src/transcribe.ts +0 -472
- package/src/tts.ts +0 -210
package/README.md
CHANGED
|
@@ -1,37 +1,13 @@
|
|
|
1
1
|
# pix-9router
|
|
2
2
|
|
|
3
|
-
Pi extension with a 9Router model provider
|
|
3
|
+
Pi extension with a 9Router model provider.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Other 9Router features live in separate packages. Each one reuses
|
|
6
|
+
`NINEROUTER_URL` and `NINEROUTER_KEY`:
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
| `tts` | TTS model or voice, plus automatic playback |
|
|
11
|
-
|
|
12
|
-
Tool arguments override saved defaults. Tool calls never fetch the model catalog.
|
|
13
|
-
The `/9router` menu fetches live models or voices only when you open a setting,
|
|
14
|
-
then saves the choice in `~/.pi/agent/9router.json`.
|
|
15
|
-
|
|
16
|
-
## Settings
|
|
17
|
-
|
|
18
|
-
Run:
|
|
19
|
-
|
|
20
|
-
```text
|
|
21
|
-
/9router
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
The menu controls:
|
|
25
|
-
|
|
26
|
-
- STT model
|
|
27
|
-
- TTS model or voice
|
|
28
|
-
- TTS playback (`on` by default)
|
|
29
|
-
|
|
30
|
-
Catalogs come from `/v1/models/stt` and `/v1/models/tts`.
|
|
31
|
-
|
|
32
|
-
Web search and fetch moved to `@xynogen/pix-web`. Its `/search` and `/fetch`
|
|
33
|
-
commands include a 9Router provider that reuses `NINEROUTER_URL` and
|
|
34
|
-
`NINEROUTER_KEY`.
|
|
8
|
+
- Speech tools (`transcribe`, `speak`, `/stt`) moved to `@xynogen/pix-voice` in
|
|
9
|
+
0.7.0. Pick `9router` as the provider in its `/voice` command.
|
|
10
|
+
- Web search and fetch live in `@xynogen/pix-web`.
|
|
35
11
|
|
|
36
12
|
## Environment
|
|
37
13
|
|
|
@@ -57,8 +33,22 @@ The standard names win when both sets exist.
|
|
|
57
33
|
pi install npm:@xynogen/pix-9router
|
|
58
34
|
```
|
|
59
35
|
|
|
60
|
-
|
|
36
|
+
> Standalone and opt-in. [`@xynogen/pix-core`](https://www.npmjs.com/package/@xynogen/pix-core) does not bundle it. It needs a 9Router API key.
|
|
37
|
+
|
|
38
|
+
## Full distro
|
|
39
|
+
|
|
40
|
+
This package is part of [Pix](https://github.com/xynogen/pix-mono). The installer sets up Pi and the full distro. See [Install](https://github.com/xynogen/pix-mono#install) for the notes for each OS.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# Linux / macOS
|
|
44
|
+
curl -fsSL https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.sh | sh
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```powershell
|
|
48
|
+
# Windows
|
|
49
|
+
irm https://raw.githubusercontent.com/xynogen/pix-mono/main/scripts/install.ps1 | iex
|
|
50
|
+
```
|
|
61
51
|
|
|
62
52
|
## License
|
|
63
53
|
|
|
64
|
-
MIT
|
|
54
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xynogen/pix-9router",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension — 9Router model provider
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Pi extension — 9Router model provider",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -44,8 +44,7 @@
|
|
|
44
44
|
"@earendil-works/pi-tui": "*"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@xynogen/pix-
|
|
48
|
-
"
|
|
49
|
-
"@xynogen/pix-runtime": "^0.10.1"
|
|
47
|
+
"@xynogen/pix-data": "^0.5.1",
|
|
48
|
+
"@xynogen/pix-runtime": "^0.12.0"
|
|
50
49
|
}
|
|
51
50
|
}
|
package/src/data.ts
CHANGED
|
@@ -17,20 +17,20 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
|
-
import { CACHE_DIR, DataSource } from "
|
|
20
|
+
import { CACHE_DIR, DataSource } from "@xynogen/pix-data";
|
|
21
21
|
|
|
22
22
|
export type {
|
|
23
23
|
ModelGrepModel,
|
|
24
24
|
ModelsDevApi,
|
|
25
25
|
ModelsDevModel,
|
|
26
|
-
} from "
|
|
26
|
+
} from "@xynogen/pix-data";
|
|
27
27
|
// Re-export the shared models.dev API so existing consumers (provider.ts)
|
|
28
28
|
// can keep importing these names from "./data".
|
|
29
29
|
export {
|
|
30
30
|
buildModelsDevIndex,
|
|
31
31
|
fetchModelsDevIndex,
|
|
32
32
|
lookupInIndex,
|
|
33
|
-
} from "
|
|
33
|
+
} from "@xynogen/pix-data";
|
|
34
34
|
|
|
35
35
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
36
36
|
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pix-9router — Pi extension
|
|
2
|
+
* pix-9router — Pi extension
|
|
3
3
|
*
|
|
4
|
-
* Registers
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* - tts tool (text-to-speech via audio speech API)
|
|
4
|
+
* Registers the 9router model provider (model list from the self-hosted router API).
|
|
5
|
+
* Speech tools moved to @xynogen/pix-voice. Web search and fetch live in
|
|
6
|
+
* @xynogen/pix-web. Both reuse the same 9Router environment variables.
|
|
8
7
|
*
|
|
9
8
|
* Environment:
|
|
10
9
|
* NINEROUTER_URL — canonical base URL
|
|
@@ -13,16 +12,8 @@
|
|
|
13
12
|
*/
|
|
14
13
|
|
|
15
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import
|
|
17
|
-
import registerProvider from "./provider.js";
|
|
18
|
-
import registerSttCommand from "./stt-command.js";
|
|
19
|
-
import registerTranscribe from "./transcribe.js";
|
|
20
|
-
import registerTts from "./tts.js";
|
|
15
|
+
import registerProvider from "./provider.ts";
|
|
21
16
|
|
|
22
17
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
23
18
|
await registerProvider(pi);
|
|
24
|
-
registerRouterCommand(pi);
|
|
25
|
-
registerSttCommand(pi);
|
|
26
|
-
registerTranscribe(pi);
|
|
27
|
-
registerTts(pi);
|
|
28
19
|
}
|
package/src/provider.ts
CHANGED
|
@@ -9,11 +9,11 @@
|
|
|
9
9
|
* ROUTER_API_KEY — bearer token (required for live model list)
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type { RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
12
|
+
import type { ClassifierResult, RefreshModelsContext } from "@earendil-works/pi-ai";
|
|
13
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { ioTimeoutSignal } from "@xynogen/pix-runtime/io";
|
|
15
|
-
import type { ModelsDevModel, RouterModel } from "./data.
|
|
16
|
-
import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.
|
|
15
|
+
import type { ModelsDevModel, RouterModel } from "./data.ts";
|
|
16
|
+
import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.ts";
|
|
17
17
|
|
|
18
18
|
const DEFAULT_CONTEXT_WINDOW = 128_000;
|
|
19
19
|
const DEFAULT_MAX_TOKENS = 16_384;
|
|
@@ -28,6 +28,28 @@ const ZERO_COST = {
|
|
|
28
28
|
// Fallback pattern-based detection if models.dev lookup fails
|
|
29
29
|
const IMAGE_CAPABLE_PATTERNS = [/claude/i, /gpt-5/i, /gpt-4/i, /kimi-k2/i, /hy3/i];
|
|
30
30
|
|
|
31
|
+
/** Known patterns for classifier models (System One protocol) */
|
|
32
|
+
export const CLASSIFIER_PATTERNS = [
|
|
33
|
+
/\bjev\b/i,
|
|
34
|
+
/\bspan\b/i,
|
|
35
|
+
/\bsolar-decide\b/i,
|
|
36
|
+
/\bclassifier\b/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function isClassifierModel(model: RouterModel): boolean {
|
|
40
|
+
const id = model.id ?? "";
|
|
41
|
+
const name = model.name ?? "";
|
|
42
|
+
const custom = (process.env.NINEROUTER_CLASSIFIER_MODELS || "")
|
|
43
|
+
.split(",")
|
|
44
|
+
.map((s) => s.trim().toLowerCase())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
|
|
47
|
+
if (custom.some((c) => id.toLowerCase().includes(c) || name.toLowerCase().includes(c))) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return CLASSIFIER_PATTERNS.some((p) => p.test(id) || p.test(name));
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
interface RouterModelsResponse {
|
|
32
54
|
data?: RouterModel[];
|
|
33
55
|
}
|
|
@@ -38,10 +60,24 @@ const COMPAT = {
|
|
|
38
60
|
maxTokensField: "max_tokens",
|
|
39
61
|
} as const;
|
|
40
62
|
|
|
41
|
-
function toModelConfig(devIndex: Map<string, ModelsDevModel>) {
|
|
63
|
+
export function toModelConfig(devIndex: Map<string, ModelsDevModel>) {
|
|
42
64
|
return (model: RouterModel) => {
|
|
43
65
|
const id = model.id ?? "";
|
|
44
66
|
const devModel = lookupInIndex(id, devIndex);
|
|
67
|
+
|
|
68
|
+
if (isClassifierModel(model)) {
|
|
69
|
+
return {
|
|
70
|
+
type: "classifier" as const,
|
|
71
|
+
id,
|
|
72
|
+
name: getModelName(model, devModel),
|
|
73
|
+
api: "typesafe-system-one",
|
|
74
|
+
baseUrl: routerBaseUrl(),
|
|
75
|
+
input: ["text" as const],
|
|
76
|
+
cost: ZERO_COST,
|
|
77
|
+
contextWindow: getContextWindow(model, devModel),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
45
81
|
return {
|
|
46
82
|
id,
|
|
47
83
|
name: getModelName(model, devModel),
|
|
@@ -129,6 +165,109 @@ export default async function registerProvider(pi: ExtensionAPI): Promise<void>
|
|
|
129
165
|
.catch(() => {});
|
|
130
166
|
}
|
|
131
167
|
|
|
168
|
+
export async function classifySystemOne(
|
|
169
|
+
model: { id: string; baseUrl?: string; api?: string; provider?: string },
|
|
170
|
+
context: { state: Record<string, unknown>; questions: Record<string, any> },
|
|
171
|
+
apiKey: string,
|
|
172
|
+
options?: { signal?: AbortSignal; headers?: Record<string, string> },
|
|
173
|
+
): Promise<ClassifierResult> {
|
|
174
|
+
const output = {
|
|
175
|
+
api: model.api ?? "typesafe-system-one",
|
|
176
|
+
provider: model.provider ?? "9router",
|
|
177
|
+
model: model.id,
|
|
178
|
+
answers: {} as Record<string, any>,
|
|
179
|
+
stopReason: "stop" as const,
|
|
180
|
+
timestamp: Date.now(),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const wireQuestions = Object.fromEntries(
|
|
185
|
+
Object.entries(context.questions ?? {}).map(([key, q]) => [
|
|
186
|
+
key,
|
|
187
|
+
q.type === "bool" ? { ...q, type: "noul" } : q,
|
|
188
|
+
]),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const base = (model.baseUrl || routerBaseUrl()).replace(/\/+$/, "");
|
|
192
|
+
const url = `${base}/systemone`;
|
|
193
|
+
const res = await fetch(url, {
|
|
194
|
+
method: "POST",
|
|
195
|
+
signal: ioTimeoutSignal(options?.signal),
|
|
196
|
+
headers: {
|
|
197
|
+
Authorization: `Bearer ${apiKey}`,
|
|
198
|
+
"Content-Type": "application/json",
|
|
199
|
+
"User-Agent": "pi-coding-agent",
|
|
200
|
+
...(options?.headers ?? {}),
|
|
201
|
+
},
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
model: model.id,
|
|
204
|
+
state: context.state,
|
|
205
|
+
questions: wireQuestions,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
const errText = await res.text().catch(() => "");
|
|
211
|
+
return {
|
|
212
|
+
...output,
|
|
213
|
+
stopReason: "error" as const,
|
|
214
|
+
errorMessage: `9router /systemone returned ${res.status}: ${errText}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const data = (await res.json()) as any;
|
|
219
|
+
const answers: Record<string, any> = {};
|
|
220
|
+
|
|
221
|
+
for (const [key, q] of Object.entries(context.questions ?? {})) {
|
|
222
|
+
const ans = data?.answers?.[key];
|
|
223
|
+
if (!ans) continue;
|
|
224
|
+
if (q.type === "bool") {
|
|
225
|
+
answers[key] = {
|
|
226
|
+
type: "bool",
|
|
227
|
+
probability: typeof ans.noul === "number" ? ans.noul : (ans.probability ?? 0),
|
|
228
|
+
};
|
|
229
|
+
} else if (q.type === "choice") {
|
|
230
|
+
answers[key] = {
|
|
231
|
+
type: "choice",
|
|
232
|
+
choice: ans.choice,
|
|
233
|
+
probabilities: ans.probabilities ?? {},
|
|
234
|
+
confidence: ans.confidence ?? 0,
|
|
235
|
+
};
|
|
236
|
+
} else if (q.type === "score") {
|
|
237
|
+
answers[key] = {
|
|
238
|
+
type: "score",
|
|
239
|
+
score: ans.score ?? 0,
|
|
240
|
+
confidence: ans.confidence ?? 0,
|
|
241
|
+
};
|
|
242
|
+
} else {
|
|
243
|
+
answers[key] = ans;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const inputTokens = data?.usage?.input_tokens ?? 0;
|
|
248
|
+
const outputTokens = data?.usage?.output_tokens ?? 0;
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
...output,
|
|
252
|
+
answers,
|
|
253
|
+
usage: {
|
|
254
|
+
input: inputTokens,
|
|
255
|
+
output: outputTokens,
|
|
256
|
+
cacheRead: 0,
|
|
257
|
+
cacheWrite: 0,
|
|
258
|
+
totalTokens: inputTokens + outputTokens,
|
|
259
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
} catch (error) {
|
|
263
|
+
return {
|
|
264
|
+
...output,
|
|
265
|
+
stopReason: "error" as const,
|
|
266
|
+
errorMessage: error instanceof Error ? error.message : String(error),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
132
271
|
function providerConfig(
|
|
133
272
|
apiKey: string,
|
|
134
273
|
models: RouterModel[],
|
|
@@ -141,6 +280,15 @@ function providerConfig(
|
|
|
141
280
|
api: "openai-completions",
|
|
142
281
|
headers: { "User-Agent": "pi-coding-agent" },
|
|
143
282
|
models: models.map(toModelConfig(devIndex)),
|
|
283
|
+
classifiers: {
|
|
284
|
+
"typesafe-system-one": {
|
|
285
|
+
classify: (
|
|
286
|
+
model: Parameters<typeof classifySystemOne>[0],
|
|
287
|
+
context: Parameters<typeof classifySystemOne>[1],
|
|
288
|
+
options?: Parameters<typeof classifySystemOne>[3],
|
|
289
|
+
) => classifySystemOne(model, context, apiKey, options),
|
|
290
|
+
},
|
|
291
|
+
},
|
|
144
292
|
// Live fetch on /model refresh — bypasses the disk cache.
|
|
145
293
|
// Pi calls refreshModels once per provider at every startup with
|
|
146
294
|
// allowNetwork:false (awaited, no timeout) — never touch the network
|
package/src/catalog.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { routerBaseUrl } from "./data.js";
|
|
2
|
-
import { auth } from "./http.js";
|
|
3
|
-
|
|
4
|
-
interface CatalogItem {
|
|
5
|
-
id?: string;
|
|
6
|
-
kind?: string;
|
|
7
|
-
model?: string;
|
|
8
|
-
name?: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
async function get(path: string): Promise<unknown> {
|
|
12
|
-
const key = auth();
|
|
13
|
-
const response = await fetch(`${routerBaseUrl()}${path}`, {
|
|
14
|
-
headers: key ? { Authorization: `Bearer ${key}` } : undefined,
|
|
15
|
-
});
|
|
16
|
-
if (!response.ok) throw new Error(`9Router catalog ${response.status}`);
|
|
17
|
-
return response.json();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function items(value: unknown): CatalogItem[] {
|
|
21
|
-
if (!value || typeof value !== "object") return [];
|
|
22
|
-
const data = (value as { data?: unknown }).data;
|
|
23
|
-
return Array.isArray(data) ? (data as CatalogItem[]) : [];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export async function catalogModels(kind: "stt" | "tts"): Promise<string[]> {
|
|
27
|
-
return items(await get(`/models/${kind}`)).flatMap((item) => (item.id ? [item.id] : []));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export interface VoiceChoice {
|
|
31
|
-
id: string;
|
|
32
|
-
language: string;
|
|
33
|
-
provider: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function groupVoice(id: string): VoiceChoice {
|
|
37
|
-
const [provider = "other", ...rest] = id.split("/");
|
|
38
|
-
const voice = rest.join("/") || provider;
|
|
39
|
-
const locale = voice.match(/(?:^|[^A-Za-z])([a-z]{2,3})[-_]([A-Z]{2})(?:[^A-Za-z]|$)/);
|
|
40
|
-
return { id, language: locale ? `${locale[1]}-${locale[2]}` : "Other", provider };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export async function catalogVoices(): Promise<VoiceChoice[]> {
|
|
44
|
-
return (await catalogModels("tts")).map(groupVoice);
|
|
45
|
-
}
|
package/src/command.ts
DELETED
|
@@ -1,212 +0,0 @@
|
|
|
1
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Key, matchesKey, type SelectItem, SelectList } from "@earendil-works/pi-tui";
|
|
3
|
-
import { icon } from "@xynogen/pix-pretty/icon-catalog";
|
|
4
|
-
import {
|
|
5
|
-
frameModal,
|
|
6
|
-
MIN_MODAL_HEIGHT,
|
|
7
|
-
ModalPager,
|
|
8
|
-
modalOverlayOptions,
|
|
9
|
-
modalWidth,
|
|
10
|
-
selectListTheme,
|
|
11
|
-
terminalModalHeight,
|
|
12
|
-
} from "@xynogen/pix-pretty/modal-frame";
|
|
13
|
-
import { catalogModels, catalogVoices } from "./catalog.js";
|
|
14
|
-
import { routerDefaults, saveDefaults } from "./defaults.js";
|
|
15
|
-
|
|
16
|
-
async function pick(
|
|
17
|
-
ctx: ExtensionContext,
|
|
18
|
-
title: string,
|
|
19
|
-
values: string[],
|
|
20
|
-
current?: string,
|
|
21
|
-
): Promise<string | undefined> {
|
|
22
|
-
const items: SelectItem[] = [...new Set(current ? [current, ...values] : values)].map(
|
|
23
|
-
(value) => ({
|
|
24
|
-
value,
|
|
25
|
-
label: value,
|
|
26
|
-
description: value === current ? "current" : undefined,
|
|
27
|
-
}),
|
|
28
|
-
);
|
|
29
|
-
if (items.length === 0) return undefined;
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
(await ctx.ui.custom<string | null>(
|
|
33
|
-
(tui, theme, _keybindings, done) => {
|
|
34
|
-
const list = new SelectList(items, items.length, selectListTheme(theme));
|
|
35
|
-
const currentIndex = items.findIndex((item) => item.value === current);
|
|
36
|
-
if (currentIndex >= 0) list.setSelectedIndex(currentIndex);
|
|
37
|
-
list.onSelect = (item) => done(item.value);
|
|
38
|
-
list.onCancel = () => done(null);
|
|
39
|
-
const pager = new ModalPager();
|
|
40
|
-
|
|
41
|
-
return {
|
|
42
|
-
render(width: number) {
|
|
43
|
-
const result = frameModal({
|
|
44
|
-
width: modalWidth(width),
|
|
45
|
-
maxHeight: terminalModalHeight(tui.terminal?.rows),
|
|
46
|
-
minHeight: MIN_MODAL_HEIGHT,
|
|
47
|
-
header: [theme.fg("accent", theme.bold(title)), ""],
|
|
48
|
-
body: list.render(modalWidth(width) - 4),
|
|
49
|
-
bodyOffset: pager.bodyOffset,
|
|
50
|
-
footer: ["", theme.fg("muted", "↑↓ navigate · enter select · esc cancel")],
|
|
51
|
-
color: (text) => theme.fg("accent", text),
|
|
52
|
-
bg: (text) => theme.bg("customMessageBg", text),
|
|
53
|
-
fg: (text) => theme.fg("text", text),
|
|
54
|
-
});
|
|
55
|
-
pager.sync(result);
|
|
56
|
-
return result.lines;
|
|
57
|
-
},
|
|
58
|
-
invalidate: () => list.invalidate(),
|
|
59
|
-
handleInput(data: string) {
|
|
60
|
-
if (pager.handleInput(data, undefined, true)) tui.requestRender();
|
|
61
|
-
else if (matchesKey(data, "escape")) done(null);
|
|
62
|
-
else list.handleInput(data);
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
},
|
|
66
|
-
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
67
|
-
)) ?? undefined
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
interface SettingRow {
|
|
72
|
-
key: string;
|
|
73
|
-
section: string;
|
|
74
|
-
label: string;
|
|
75
|
-
value: string;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function pickSetting(ctx: ExtensionContext, rows: SettingRow[]): Promise<string | undefined> {
|
|
79
|
-
return (
|
|
80
|
-
(await ctx.ui.custom<string | null>(
|
|
81
|
-
(tui, theme, keybindings, done) => {
|
|
82
|
-
let selected = 0;
|
|
83
|
-
const move = (direction: -1 | 1) => {
|
|
84
|
-
selected = (selected + direction + rows.length) % rows.length;
|
|
85
|
-
};
|
|
86
|
-
return {
|
|
87
|
-
render(width: number) {
|
|
88
|
-
const labelWidth = Math.max(...rows.map((row) => row.label.length));
|
|
89
|
-
const body: string[] = [];
|
|
90
|
-
const rowLines: number[] = [];
|
|
91
|
-
let section = "";
|
|
92
|
-
for (let index = 0; index < rows.length; index++) {
|
|
93
|
-
const row = rows[index];
|
|
94
|
-
if (!row) continue;
|
|
95
|
-
if (row.section !== section) {
|
|
96
|
-
if (section) body.push("");
|
|
97
|
-
body.push(theme.fg("dim", ` ${row.section}`));
|
|
98
|
-
section = row.section;
|
|
99
|
-
}
|
|
100
|
-
const active = index === selected;
|
|
101
|
-
rowLines[index] = body.length;
|
|
102
|
-
body.push(
|
|
103
|
-
`${active ? theme.fg("accent", "→") : " "} ${theme.fg(active ? "accent" : "text", row.label.padEnd(labelWidth))} ${theme.fg("success", row.value)}`,
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
const result = frameModal({
|
|
107
|
-
width: modalWidth(width),
|
|
108
|
-
maxHeight: terminalModalHeight(tui.terminal?.rows),
|
|
109
|
-
minHeight: MIN_MODAL_HEIGHT,
|
|
110
|
-
title: `${icon("settings")} 9Router Settings`,
|
|
111
|
-
titleColor: (text) => theme.fg("accent", theme.bold(text)),
|
|
112
|
-
header: [""],
|
|
113
|
-
body,
|
|
114
|
-
footer: ["", theme.fg("muted", "↑↓ move · enter change · esc close")],
|
|
115
|
-
selectedBodyLine: rowLines[selected],
|
|
116
|
-
color: (text) => theme.fg("accent", text),
|
|
117
|
-
bg: (text) => theme.bg("customMessageBg", text),
|
|
118
|
-
});
|
|
119
|
-
return result.lines;
|
|
120
|
-
},
|
|
121
|
-
invalidate: () => {},
|
|
122
|
-
handleInput(data: string) {
|
|
123
|
-
if (keybindings.matches(data, "tui.select.cancel")) done(null);
|
|
124
|
-
else if (keybindings.matches(data, "tui.select.up")) move(-1);
|
|
125
|
-
else if (keybindings.matches(data, "tui.select.down")) move(1);
|
|
126
|
-
else if (matchesKey(data, "enter")) done(rows[selected]?.key ?? null);
|
|
127
|
-
else if (matchesKey(data, Key.left) || matchesKey(data, Key.right))
|
|
128
|
-
done(rows[selected]?.key ?? null);
|
|
129
|
-
else return;
|
|
130
|
-
tui.requestRender();
|
|
131
|
-
},
|
|
132
|
-
};
|
|
133
|
-
},
|
|
134
|
-
{ overlay: true, overlayOptions: modalOverlayOptions() },
|
|
135
|
-
)) ?? undefined
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function choose(
|
|
140
|
-
ctx: ExtensionContext,
|
|
141
|
-
title: string,
|
|
142
|
-
current: string,
|
|
143
|
-
load: () => Promise<string[]>,
|
|
144
|
-
): Promise<string | undefined> {
|
|
145
|
-
return pick(ctx, `${title} · current: ${current}`, await load(), current);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export default function registerRouterCommand(pi: ExtensionAPI): void {
|
|
149
|
-
pi.registerCommand("9router", {
|
|
150
|
-
description: "Set default models, voice, and TTS playback",
|
|
151
|
-
handler: async (_args, ctx) => {
|
|
152
|
-
while (true) {
|
|
153
|
-
const setting = await pickSetting(ctx, [
|
|
154
|
-
{ key: "stt", section: "Audio", label: "STT model", value: routerDefaults.sttModel },
|
|
155
|
-
{
|
|
156
|
-
key: "tts",
|
|
157
|
-
section: "Audio",
|
|
158
|
-
label: "TTS voice",
|
|
159
|
-
value: routerDefaults.ttsModel,
|
|
160
|
-
},
|
|
161
|
-
{
|
|
162
|
-
key: "playback",
|
|
163
|
-
section: "Audio",
|
|
164
|
-
label: "play after generation",
|
|
165
|
-
value: routerDefaults.ttsPlay ? "on" : "off",
|
|
166
|
-
},
|
|
167
|
-
]);
|
|
168
|
-
if (!setting) return;
|
|
169
|
-
|
|
170
|
-
try {
|
|
171
|
-
let value: string | undefined;
|
|
172
|
-
if (setting === "stt") {
|
|
173
|
-
value = await choose(ctx, "STT model", routerDefaults.sttModel, () =>
|
|
174
|
-
catalogModels("stt"),
|
|
175
|
-
);
|
|
176
|
-
if (value) routerDefaults.sttModel = value;
|
|
177
|
-
} else if (setting === "tts") {
|
|
178
|
-
const voices = await catalogVoices();
|
|
179
|
-
const language = await pick(
|
|
180
|
-
ctx,
|
|
181
|
-
"TTS · 1/3 · language",
|
|
182
|
-
[...new Set(voices.map((voice) => voice.language))].sort(),
|
|
183
|
-
);
|
|
184
|
-
if (!language) continue;
|
|
185
|
-
const languageVoices = voices.filter((voice) => voice.language === language);
|
|
186
|
-
const provider = await pick(
|
|
187
|
-
ctx,
|
|
188
|
-
`TTS · 2/3 · ${language} · provider`,
|
|
189
|
-
[...new Set(languageVoices.map((voice) => voice.provider))].sort(),
|
|
190
|
-
);
|
|
191
|
-
if (!provider) continue;
|
|
192
|
-
value = await pick(
|
|
193
|
-
ctx,
|
|
194
|
-
`TTS · 3/3 · ${language} · ${provider}`,
|
|
195
|
-
languageVoices
|
|
196
|
-
.filter((voice) => voice.provider === provider)
|
|
197
|
-
.map((voice) => voice.id),
|
|
198
|
-
routerDefaults.ttsModel,
|
|
199
|
-
);
|
|
200
|
-
if (value) routerDefaults.ttsModel = value;
|
|
201
|
-
} else if (setting === "playback") {
|
|
202
|
-
value = await pick(ctx, "Play generated speech by default", ["on", "off"]);
|
|
203
|
-
if (value) routerDefaults.ttsPlay = value === "on";
|
|
204
|
-
}
|
|
205
|
-
if (value) saveDefaults(routerDefaults);
|
|
206
|
-
} catch (error) {
|
|
207
|
-
ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
}
|
package/src/defaults.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
4
|
-
|
|
5
|
-
export interface RouterDefaults {
|
|
6
|
-
sttModel: string;
|
|
7
|
-
ttsModel: string;
|
|
8
|
-
ttsPlay: boolean;
|
|
9
|
-
sttDevice: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const DEFAULTS_PATH = join(homedir(), ".pi", "agent", "9router.json");
|
|
13
|
-
|
|
14
|
-
const FALLBACK: RouterDefaults = {
|
|
15
|
-
sttModel: "dg/nova-3",
|
|
16
|
-
ttsModel: "edge-tts/en-US-AriaNeural",
|
|
17
|
-
ttsPlay: true,
|
|
18
|
-
sttDevice: "default",
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
export function loadDefaults(path = DEFAULTS_PATH): RouterDefaults {
|
|
22
|
-
let value: unknown;
|
|
23
|
-
try {
|
|
24
|
-
value = JSON.parse(readFileSync(path, "utf8"));
|
|
25
|
-
} catch {
|
|
26
|
-
return { ...FALLBACK };
|
|
27
|
-
}
|
|
28
|
-
if (!value || typeof value !== "object") return { ...FALLBACK };
|
|
29
|
-
const item = value as Record<string, unknown>;
|
|
30
|
-
return {
|
|
31
|
-
sttModel: typeof item.sttModel === "string" ? item.sttModel : FALLBACK.sttModel,
|
|
32
|
-
ttsModel: typeof item.ttsModel === "string" ? item.ttsModel : FALLBACK.ttsModel,
|
|
33
|
-
ttsPlay: typeof item.ttsPlay === "boolean" ? item.ttsPlay : FALLBACK.ttsPlay,
|
|
34
|
-
sttDevice: typeof item.sttDevice === "string" ? item.sttDevice : FALLBACK.sttDevice,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function saveDefaults(defaults: RouterDefaults, path = DEFAULTS_PATH): void {
|
|
39
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
-
writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { mode: 0o600 });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Shared in-memory defaults. Tool calls never fetch the catalog. */
|
|
44
|
-
export const routerDefaults = loadDefaults();
|