@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 CHANGED
@@ -1,37 +1,13 @@
1
1
  # pix-9router
2
2
 
3
- Pi extension with a 9Router model provider, speech-to-text, and text-to-speech tools.
3
+ Pi extension with a 9Router model provider.
4
4
 
5
- ## Tools
5
+ Other 9Router features live in separate packages. Each one reuses
6
+ `NINEROUTER_URL` and `NINEROUTER_KEY`:
6
7
 
7
- | Tool | Default controlled by `/9router` |
8
- |---|---|
9
- | `transcribe` | STT model |
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
- This package is standalone and opt-in. It is not bundled by `@xynogen/pix-core`.
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.6.0",
4
- "description": "Pi extension — 9Router model provider plus audio tools",
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-pretty": "^1.25.0",
48
- "typebox": "^1.1.38",
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 "../../pix-data/src/index.ts";
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 "../../pix-data/src/index.ts";
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 "../../pix-data/src/index.ts";
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 bundle
2
+ * pix-9router — Pi extension
3
3
  *
4
- * Registers:
5
- * - 9router provider (model list from self-hosted router API)
6
- * - transcribe tool (speech-to-text via audio transcription API)
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 registerRouterCommand from "./command.js";
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.js";
16
- import { fetchModelsDevIndex, lookupInIndex, routerBaseUrl, routerModels } from "./data.js";
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();