@bramburn/pi-model-council 1.6.3 → 1.6.11

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/settings-ui.ts ADDED
@@ -0,0 +1,488 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { CouncilSettings, ValidationResult, OpenRouterModel } from "./types.js";
3
+ import {
4
+ loadSettings,
5
+ saveSettings,
6
+ getSettingsPath,
7
+ formatSettingsForDisplay,
8
+ createDefaultSettings,
9
+ redactedApiKey,
10
+ } from "./settings.js";
11
+ import {
12
+ pingOpenRouter as defaultPingOpenRouter,
13
+ fetchOpenRouterModels as defaultFetchOpenRouterModels,
14
+ pingOpenRouter,
15
+ fetchOpenRouterModels,
16
+ } from "./openrouterClient.js";
17
+ import { searchableSelect, type SelectableItem } from "./searchSelector.js";
18
+
19
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Shape of a Model object as returned by ctx.modelRegistry.getAvailable().
23
+ * We accept a structural subset so we don't have to import Model<Api> from
24
+ * @earendil-works/pi-ai (it's re-exported by pi-coding-agent, but we want the
25
+ * settings UI to be usable from tests that mock the registry loosely).
26
+ */
27
+ export interface RegistryModel {
28
+ id: string;
29
+ name: string;
30
+ provider: string;
31
+ api?: string;
32
+ reasoning?: boolean;
33
+ contextWindow?: number;
34
+ maxTokens?: number;
35
+ }
36
+
37
+ /**
38
+ * Pull every model the current registry considers "available" (i.e. has
39
+ * credentials configured) and filter down to those served by OpenRouter.
40
+ *
41
+ * This is the preferred path — it re-uses the OpenRouter provider that pi
42
+ * already registers when the user sets `OPENROUTER_API_KEY` (or runs
43
+ * `/login openrouter`), so the API key only has to live in one place.
44
+ */
45
+ export function getOpenRouterModelsFromRegistry(
46
+ models: ReadonlyArray<RegistryModel>,
47
+ ): OpenRouterModel[] {
48
+ return models
49
+ .filter((m) => m.provider === "openrouter")
50
+ .map((m) => ({
51
+ id: m.id,
52
+ name: m.name,
53
+ ...(m.reasoning !== undefined ? { reasoning: m.reasoning } : {}),
54
+ ...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}),
55
+ }));
56
+ }
57
+
58
+ /**
59
+ * Resolve an OpenRouter API key from three sources, in priority order:
60
+ *
61
+ * 1. Explicit key passed in (the legacy `/council-settings` UI flow)
62
+ * 2. Pi's auth storage via ctx.modelRegistry
63
+ * 3. `OPENROUTER_API_KEY` environment variable
64
+ *
65
+ * Returns `undefined` when no key can be found.
66
+ */
67
+ export async function resolveOpenRouterApiKey(
68
+ ctx: ExtensionCommandContext,
69
+ explicitKey?: string,
70
+ ): Promise<string | undefined> {
71
+ if (explicitKey && explicitKey.trim().length > 0) {
72
+ return explicitKey.trim();
73
+ }
74
+
75
+ try {
76
+ const fromRegistry = await ctx.modelRegistry.getApiKeyForProvider("openrouter");
77
+ if (fromRegistry && fromRegistry.trim().length > 0) {
78
+ return fromRegistry.trim();
79
+ }
80
+ } catch {
81
+ // Registry may not be available in all contexts; fall through.
82
+ }
83
+
84
+ const fromEnv = process.env.OPENROUTER_API_KEY;
85
+ if (fromEnv && fromEnv.trim().length > 0) {
86
+ return fromEnv.trim();
87
+ }
88
+
89
+ return undefined;
90
+ }
91
+
92
+ // ─── Validation ────────────────────────────────────────────────────────────────
93
+
94
+ export async function validateCouncilSettings(
95
+ settings: Partial<CouncilSettings>,
96
+ availableModels?: OpenRouterModel[],
97
+ pingFn?: (apiKey: string) => Promise<{ ok: boolean; error?: string; quota?: string }>,
98
+ fetchFn?: (apiKey: string) => Promise<OpenRouterModel[]>,
99
+ ): Promise<ValidationResult> {
100
+ const pingOpenRouter = pingFn ?? defaultPingOpenRouter;
101
+ const fetchOpenRouterModels = fetchFn ?? defaultFetchOpenRouterModels;
102
+ const errors: string[] = [];
103
+ const warnings: string[] = [];
104
+
105
+ if (!settings.openRouter?.apiKey) {
106
+ errors.push("OpenRouter API key is required");
107
+ return { valid: false, errors, warnings };
108
+ }
109
+
110
+ if (!settings.openRouter.apiKey.startsWith("sk-or-v1")) {
111
+ errors.push("API key must start with 'sk-or-v1'");
112
+ }
113
+
114
+ const ping = await pingOpenRouter(settings.openRouter.apiKey);
115
+ if (!ping.ok) {
116
+ errors.push(`OpenRouter validation failed: ${ping.error}`);
117
+ return { valid: false, errors, warnings };
118
+ }
119
+ if (ping.quota) {
120
+ warnings.push(`Connected. ${ping.quota}`);
121
+ }
122
+
123
+ let models: OpenRouterModel[] | undefined = availableModels;
124
+ if (!models) {
125
+ try {
126
+ models = await fetchOpenRouterModels(settings.openRouter.apiKey);
127
+ } catch {
128
+ warnings.push("Could not fetch model list — skipping model validation");
129
+ }
130
+ }
131
+
132
+ const { model1, model2, model3 } = settings.openRouter.models ?? {};
133
+ const modelsList = [model1, model2, model3];
134
+
135
+ if (modelsList.some(m => !m)) {
136
+ errors.push("All 3 council models must be selected");
137
+ }
138
+
139
+ if (models) {
140
+ const availableIds = new Set(models.map(m => m.id));
141
+ for (const model of modelsList) {
142
+ if (model && !availableIds.has(model)) {
143
+ errors.push(`Model not available on OpenRouter: ${model}`);
144
+ }
145
+ }
146
+ }
147
+
148
+ if (model1 && model2 && model3) {
149
+ if (new Set([model1, model2, model3]).size !== 3) {
150
+ errors.push("All 3 council models must be different");
151
+ }
152
+ }
153
+
154
+ return { valid: errors.length === 0, errors, warnings };
155
+ }
156
+
157
+ // ─── Show current settings (list command) ─────────────────────────────────────
158
+
159
+ export async function showCurrentSettings(ctx: ExtensionCommandContext): Promise<void> {
160
+ const settings = await loadSettings(ctx.cwd, ctx.isProjectTrusted());
161
+ const lines = formatSettingsForDisplay(settings);
162
+
163
+ const output = lines.join("\n");
164
+ ctx.ui.notify(output, "info");
165
+ }
166
+
167
+ // ─── Reset settings ────────────────────────────────────────────────────────────
168
+
169
+ export async function resetSettings(
170
+ ctx: ExtensionCommandContext,
171
+ scope: "all" | "council" | "opinion" = "all",
172
+ ): Promise<void> {
173
+ const path = getSettingsPath(ctx.cwd, ctx.isProjectTrusted());
174
+ const { unlink } = await import("node:fs/promises");
175
+
176
+ try {
177
+ await unlink(path);
178
+ } catch {
179
+ // File didn't exist — that's fine
180
+ }
181
+
182
+ const scopeLabel = scope === "all" ? "All settings" : scope === "council" ? "Council settings" : "Opinion settings";
183
+ ctx.ui.notify(`${scopeLabel} have been reset. Run /council-settings to reconfigure.`, "info");
184
+ }
185
+
186
+ // ─── Full settings UI ──────────────────────────────────────────────────────────
187
+
188
+ type SettingsState = {
189
+ apiKey: string;
190
+ model1: string;
191
+ model2: string;
192
+ model3: string;
193
+ opinionProvider: string;
194
+ opinionModelId: string;
195
+ useStructuredOutput: boolean;
196
+ availableModels: OpenRouterModel[];
197
+ /** How the model list was obtained — surfaced in the save summary. */
198
+ modelSource: "registry" | "rest" | "fallback";
199
+ };
200
+
201
+ export async function openCouncilSettingsUI(
202
+ ctx: ExtensionCommandContext,
203
+ deps?: {
204
+ pingOpenRouter?: (apiKey: string) => Promise<{ ok: boolean; error?: string; quota?: string }>;
205
+ fetchOpenRouterModels?: (apiKey: string) => Promise<OpenRouterModel[]>;
206
+ },
207
+ ): Promise<void> {
208
+ const current = await loadSettings(ctx.cwd, ctx.isProjectTrusted());
209
+ const defaults = createDefaultSettings();
210
+
211
+ const state: SettingsState = {
212
+ apiKey: current?.openRouter.apiKey ?? "",
213
+ model1: current?.openRouter.models.model1 ?? "",
214
+ model2: current?.openRouter.models.model2 ?? "",
215
+ model3: current?.openRouter.models.model3 ?? "",
216
+ opinionProvider: current?.opinion.provider ?? defaults.opinion.provider,
217
+ opinionModelId: current?.opinion.modelId ?? defaults.opinion.modelId,
218
+ useStructuredOutput: current?.options.useStructuredOutput ?? true,
219
+ availableModels: [],
220
+ modelSource: "registry",
221
+ };
222
+
223
+ // ── Step 1: discover models via the pi registry (preferred) ─────────────
224
+ // If the user has already configured OpenRouter for pi (via OPENROUTER_API_KEY
225
+ // or `/login openrouter`) the registry exposes every OpenRouter model that
226
+ // pi ships with — no extra HTTP calls, no API key prompt.
227
+ let registryModels: RegistryModel[] = [];
228
+ try {
229
+ registryModels = (await ctx.modelRegistry.getAvailable()) as RegistryModel[];
230
+ } catch {
231
+ registryModels = [];
232
+ }
233
+
234
+ const openrouterFromRegistry = getOpenRouterModelsFromRegistry(registryModels);
235
+
236
+ if (openrouterFromRegistry.length >= 3) {
237
+ state.availableModels = openrouterFromRegistry;
238
+ state.modelSource = "registry";
239
+
240
+ // Try to surface the API key from pi's auth storage so the saved settings
241
+ // remain self-contained for the runtime layer.
242
+ const registryKey = await resolveOpenRouterApiKey(ctx);
243
+ if (registryKey) {
244
+ state.apiKey = registryKey;
245
+ }
246
+ } else {
247
+ // ── Step 1 (fallback): ask the user for an API key ────────────────────
248
+ const apiKeyInput = await ctx.ui.input("OpenRouter API Key", "sk-or-v1-...");
249
+
250
+ if (!apiKeyInput) {
251
+ ctx.ui.notify("Cancelled.", "info");
252
+ return;
253
+ }
254
+
255
+ state.apiKey = apiKeyInput.trim();
256
+
257
+ // Ping to validate
258
+ const ping = await (deps?.pingOpenRouter ?? pingOpenRouter)(state.apiKey);
259
+ if (!ping.ok) {
260
+ ctx.ui.notify(`Connection failed: ${ping.error}`, "error");
261
+ return;
262
+ }
263
+
264
+ ctx.ui.notify(ping.quota ? `Connected. ${ping.quota}` : "Connected.", "info");
265
+
266
+ // Fetch models from OpenRouter
267
+ try {
268
+ state.availableModels = await (deps?.fetchOpenRouterModels ?? fetchOpenRouterModels)(state.apiKey);
269
+ state.modelSource = "rest";
270
+ } catch {
271
+ ctx.ui.notify("Connected, but could not fetch model list. Using recommended defaults.", "warning");
272
+ state.availableModels = [
273
+ { id: "qwen/qwen3.7-max", name: "Qwen 3.7 Max" },
274
+ { id: "z-ai/glm-5.2", name: "GLM-5.2" },
275
+ { id: "deepseek/deepseek-v4-pro", name: "DeepSeek V4 Pro" },
276
+ ];
277
+ state.modelSource = "fallback";
278
+ }
279
+ }
280
+
281
+ // ── Step 2: select 3 council models (forced-distinct, prompt in order) ──
282
+ // Each SelectableItem carries a searchHaystack so typing "claude" matches
283
+ // "anthropic/claude-3.5-sonnet" even though the visible label is just the
284
+ // model name. Already-picked models are filtered out for the next step.
285
+ // A reasoning-capable badge ("[reasoning]") is shown in the description when
286
+ // the model supports extended thinking — useful for picking the synthesis model.
287
+ const buildModelItems = (exclude: ReadonlyArray<string> = []): SelectableItem[] =>
288
+ state.availableModels
289
+ .filter((m) => !exclude.includes(m.id))
290
+ .map((m) => ({
291
+ value: m.id,
292
+ label: m.name,
293
+ description: m.reasoning ? `${m.id} · [reasoning]` : m.id,
294
+ searchHaystack: `${m.name} ${m.id}`,
295
+ }));
296
+
297
+ const allModelItems: SelectableItem[] = state.availableModels.map((m) => ({
298
+ value: m.id,
299
+ label: m.name,
300
+ description: m.reasoning ? `${m.id} · [reasoning]` : m.id,
301
+ searchHaystack: `${m.name} ${m.id}`,
302
+ }));
303
+
304
+ const model1Pick = await searchableSelect(ctx, {
305
+ title: "Council Model 1 of 3",
306
+ searchPlaceholder: "Type to search (e.g. \"claude\", \"gpt\", \"qwen\")",
307
+ hint: `${state.availableModels.length} models available · type to filter · ↑↓ to navigate`,
308
+ items: buildModelItems(),
309
+ });
310
+ if (!model1Pick) { ctx.ui.notify("Cancelled.", "info"); return; }
311
+ state.model1 = model1Pick.value;
312
+
313
+ const model2Pick = await searchableSelect(ctx, {
314
+ title: "Council Model 2 of 3",
315
+ searchPlaceholder: `Pick a different model — excluding "${model1Pick.label}"`,
316
+ hint: `${state.availableModels.length - 1} models remaining`,
317
+ items: buildModelItems([state.model1]),
318
+ });
319
+ if (!model2Pick) { ctx.ui.notify("Cancelled.", "info"); return; }
320
+ state.model2 = model2Pick.value;
321
+
322
+ const model3Pick = await searchableSelect(ctx, {
323
+ title: "Council Model 3 of 3",
324
+ searchPlaceholder: "Pick a third, distinct model",
325
+ hint: `${state.availableModels.length - 2} models remaining`,
326
+ items: buildModelItems([state.model1, state.model2]),
327
+ });
328
+ if (!model3Pick) { ctx.ui.notify("Cancelled.", "info"); return; }
329
+ state.model3 = model3Pick.value;
330
+
331
+ // ── Step 3: pick a 4th synthesis model ─────────────────────────────────
332
+ // The synthesis model reads the three council opinions and writes a single
333
+ // decision. We default to "Council Model 1" since the user already
334
+ // trusts it as a council member, but they can pick any OpenRouter model.
335
+ const synthesisDefaultLabel = model1Pick.label;
336
+
337
+ const synthesisPick = await searchableSelect(ctx, {
338
+ title: "Synthesis Model",
339
+ searchPlaceholder: `Reads all 3 council opinions. Default: ${synthesisDefaultLabel}`,
340
+ hint: `Look for [reasoning] badge · default: ${synthesisDefaultLabel}`,
341
+ items: allModelItems,
342
+ });
343
+ if (!synthesisPick) { ctx.ui.notify("Cancelled.", "info"); return; }
344
+ const synthesisModelId = synthesisPick.value;
345
+
346
+ // ── Step 4: pick the second-opinion model (used by /opinion) ───────────
347
+ const opinionPick = await searchableSelect(ctx, {
348
+ title: "Second Opinion Model",
349
+ searchPlaceholder: "Single-model quick check (used by /opinion)",
350
+ hint: "Recommended: a fast model for routine questions",
351
+ items: allModelItems,
352
+ });
353
+ if (!opinionPick) { ctx.ui.notify("Cancelled.", "info"); return; }
354
+ const opinionModel = state.availableModels.find((m) => m.id === opinionPick.value);
355
+ if (opinionModel) {
356
+ const parts = opinionModel.id.split("/");
357
+ state.opinionProvider = parts[0] ?? "openrouter";
358
+ state.opinionModelId = opinionModel.id;
359
+ }
360
+
361
+ // ── Step 5: structured output toggle ───────────────────────────────────
362
+ state.useStructuredOutput = await ctx.ui.confirm(
363
+ "Structured Output",
364
+ "Use structured JSON output for faster parsing? (Recommended: Yes)",
365
+ );
366
+
367
+ // ── Step 6: validate the chosen API key (if we have one) ────────────────
368
+ if (state.apiKey) {
369
+ const validation = await validateCouncilSettings({
370
+ openRouter: {
371
+ apiKey: state.apiKey,
372
+ models: { model1: state.model1, model2: state.model2, model3: state.model3 },
373
+ },
374
+ }, state.availableModels);
375
+
376
+ if (!validation.valid) {
377
+ for (const err of validation.errors) {
378
+ ctx.ui.notify(`Validation error: ${err}`, "error");
379
+ }
380
+ return;
381
+ }
382
+ }
383
+
384
+ // ── Step 7: confirm and save ────────────────────────────────────────────
385
+ const summary = [
386
+ `OpenRouter API Key: ${state.apiKey ? redactedApiKey(state.apiKey) : "(none — using pi auth)"}`,
387
+ `Council Models:`,
388
+ ` 1. ${state.model1}`,
389
+ ` 2. ${state.model2}`,
390
+ ` 3. ${state.model3}`,
391
+ `Synthesis Model: ${synthesisModelId}`,
392
+ `Second Opinion Model: ${state.opinionModelId}`,
393
+ `Structured Output: ${state.useStructuredOutput ? "enabled" : "disabled"}`,
394
+ `Model List Source: ${state.modelSource}`,
395
+ ].join("\n");
396
+
397
+ const confirmed = await ctx.ui.confirm("Save Settings?", summary);
398
+ if (!confirmed) {
399
+ ctx.ui.notify("Settings not saved.", "info");
400
+ return;
401
+ }
402
+
403
+ const settings: CouncilSettings = {
404
+ version: 1,
405
+ openRouter: {
406
+ apiKey: state.apiKey,
407
+ models: {
408
+ model1: state.model1,
409
+ model2: state.model2,
410
+ model3: state.model3,
411
+ },
412
+ },
413
+ opinion: {
414
+ provider: state.opinionProvider,
415
+ modelId: state.opinionModelId,
416
+ },
417
+ synthesis: {
418
+ modelId: synthesisModelId,
419
+ },
420
+ options: {
421
+ useStructuredOutput: state.useStructuredOutput,
422
+ modelTimeoutMs: 300000,
423
+ synthesisTimeoutMs: 360000,
424
+ retryAttempts: 3,
425
+ retryDelayMs: 3000,
426
+ },
427
+ lastUpdated: new Date().toISOString(),
428
+ };
429
+
430
+ await saveSettings(settings, ctx.cwd, ctx.isProjectTrusted());
431
+ ctx.ui.notify("Council settings saved successfully!", "info");
432
+ }
433
+
434
+ // ─── Opinion settings UI ────────────────────────────────────────────────────────
435
+
436
+ export async function openOpinionSettingsUI(
437
+ ctx: ExtensionCommandContext,
438
+ deps?: {
439
+ loadSettings?: (cwd: string, isProjectTrusted: boolean) => Promise<CouncilSettings | null>;
440
+ saveSettings?: (settings: CouncilSettings, cwd: string, isProjectTrusted: boolean) => Promise<void>;
441
+ },
442
+ ): Promise<void> {
443
+ const available = (await ctx.modelRegistry.getAvailable()) as RegistryModel[];
444
+
445
+ if (available.length === 0) {
446
+ ctx.ui.notify("No models with valid API keys available. Configure API keys in pi settings.", "error");
447
+ return;
448
+ }
449
+
450
+ // Build a flat list of all available models with provider badges so a single
451
+ // typeahead picker can choose from any provider without a separate Provider
452
+ // step. Each item's haystack includes both the provider and model id so
453
+ // typing "openrouter" or "anthropic" narrows correctly.
454
+ const items: SelectableItem[] = available.map((m) => ({
455
+ value: `${m.provider}::${m.id}`,
456
+ label: m.name ?? m.id,
457
+ description: `${m.provider} · ${m.id}`,
458
+ searchHaystack: `${m.provider} ${m.id} ${m.name ?? ""}`,
459
+ }));
460
+
461
+ const pick = await searchableSelect(ctx, {
462
+ title: "Second Opinion Model",
463
+ searchPlaceholder: "Type to search by provider or model name",
464
+ hint: `${available.length} models available across ${new Set(available.map((m) => m.provider)).size} providers`,
465
+ items,
466
+ });
467
+ if (!pick) { ctx.ui.notify("Cancelled.", "info"); return; }
468
+
469
+ const [providerChoice, modelChoice] = pick.value.split("::");
470
+
471
+ const confirmed = await ctx.ui.confirm("Save Opinion Model?", `${providerChoice}/${modelChoice}`);
472
+ if (!confirmed) {
473
+ ctx.ui.notify("Cancelled.", "info");
474
+ return;
475
+ }
476
+
477
+ const existing = await (deps?.loadSettings ?? loadSettings)(ctx.cwd, ctx.isProjectTrusted());
478
+ const settings = existing ?? createDefaultSettings();
479
+
480
+ settings.opinion = {
481
+ provider: providerChoice,
482
+ modelId: modelChoice,
483
+ };
484
+ settings.lastUpdated = new Date().toISOString();
485
+
486
+ await (deps?.saveSettings ?? saveSettings)(settings, ctx.cwd, ctx.isProjectTrusted());
487
+ ctx.ui.notify(`Opinion model set to: ${providerChoice}/${modelChoice}`, "info");
488
+ }
package/settings.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
6
+ import type { CouncilSettings } from "./types.js";
7
+
8
+ const SETTINGS_FILE = "council-settings.json";
9
+
10
+ const DEFAULT_SETTINGS: Omit<CouncilSettings, "version" | "lastUpdated" | "openRouter" | "opinion"> = {
11
+ options: {
12
+ useStructuredOutput: true,
13
+ modelTimeoutMs: 300000,
14
+ synthesisTimeoutMs: 360000,
15
+ retryAttempts: 3,
16
+ retryDelayMs: 3000,
17
+ },
18
+ };
19
+
20
+ export function getSettingsDir(cwd: string, isProjectTrusted: boolean): string {
21
+ if (isProjectTrusted) {
22
+ return join(cwd, CONFIG_DIR_NAME);
23
+ }
24
+ // Use cwd if provided, fall back to home dir
25
+ if (cwd && cwd !== "/") {
26
+ return join(cwd, ".pi", "agent");
27
+ }
28
+ return join(homedir(), ".pi", "agent");
29
+ }
30
+
31
+ export function getSettingsPath(cwd: string, isProjectTrusted: boolean): string {
32
+ return join(getSettingsDir(cwd, isProjectTrusted), SETTINGS_FILE);
33
+ }
34
+
35
+ export async function loadSettings(
36
+ cwd: string,
37
+ isProjectTrusted: boolean,
38
+ ): Promise<CouncilSettings | null> {
39
+ const path = getSettingsPath(cwd, isProjectTrusted);
40
+ if (!existsSync(path)) return null;
41
+
42
+ try {
43
+ const content = await readFile(path, "utf8");
44
+ const parsed = JSON.parse(content) as CouncilSettings;
45
+ // Basic validation
46
+ if (parsed.version !== 1) return null;
47
+ if (!parsed.openRouter?.apiKey) return null;
48
+ if (!parsed.openRouter?.models) return null;
49
+ return parsed;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ export async function saveSettings(
56
+ settings: CouncilSettings,
57
+ cwd: string,
58
+ isProjectTrusted: boolean,
59
+ ): Promise<void> {
60
+ // Determine the target directory based on trust level
61
+ const dir = isProjectTrusted
62
+ ? join(cwd, CONFIG_DIR_NAME)
63
+ : (cwd && cwd !== "/" ? join(cwd, ".pi", "agent") : join(homedir(), ".pi", "agent"));
64
+ await mkdir(dir, { recursive: true });
65
+
66
+ const path = join(dir, SETTINGS_FILE);
67
+ const toSave: CouncilSettings = {
68
+ ...DEFAULT_SETTINGS,
69
+ ...settings,
70
+ version: 1,
71
+ lastUpdated: new Date().toISOString(),
72
+ };
73
+ await writeFile(path, JSON.stringify(toSave, null, 2), "utf8");
74
+ }
75
+
76
+ export function redactedApiKey(apiKey: string): string {
77
+ if (apiKey.length <= 11) return "••••••••";
78
+ // Keep first 11 chars (e.g. "sk-or-v1-ab") + 18 bullets = 29 total
79
+ return apiKey.slice(0, 11) + "••••••••••••••••••";
80
+ }
81
+
82
+ export function formatSettingsForDisplay(settings: CouncilSettings | null): string[] {
83
+ if (!settings) {
84
+ return [
85
+ "Council Settings: Not configured",
86
+ "Run /council-settings to set up your API key and models.",
87
+ ];
88
+ }
89
+
90
+ const lines: string[] = [];
91
+ lines.push("Council Settings:");
92
+ lines.push(
93
+ settings.openRouter.apiKey
94
+ ? ` OpenRouter API Key: ${redactedApiKey(settings.openRouter.apiKey)}`
95
+ : " OpenRouter API Key: (using pi auth — no key stored locally)",
96
+ );
97
+ lines.push(` Council Model 1: ${settings.openRouter.models.model1}`);
98
+ lines.push(` Council Model 2: ${settings.openRouter.models.model2}`);
99
+ lines.push(` Council Model 3: ${settings.openRouter.models.model3}`);
100
+ lines.push(
101
+ ` Synthesis Model: ${settings.synthesis?.modelId ?? settings.openRouter.models.model1} (default: model1)`,
102
+ );
103
+ lines.push(` Second Opinion Model: ${settings.opinion.provider}/${settings.opinion.modelId}`);
104
+ lines.push(` Structured Output: ${settings.options.useStructuredOutput ? "enabled" : "disabled"}`);
105
+ lines.push(` Model Timeout: ${settings.options.modelTimeoutMs / 1000}s`);
106
+ lines.push(` Retry Attempts: ${settings.options.retryAttempts}`);
107
+ lines.push(` Last Updated: ${settings.lastUpdated}`);
108
+ return lines;
109
+ }
110
+
111
+ export function createDefaultSettings(): CouncilSettings {
112
+ return {
113
+ version: 1,
114
+ openRouter: {
115
+ apiKey: "",
116
+ models: {
117
+ model1: "",
118
+ model2: "",
119
+ model3: "",
120
+ },
121
+ },
122
+ opinion: {
123
+ provider: "openrouter",
124
+ modelId: "qwen/qwen3.7-max",
125
+ },
126
+ options: {
127
+ useStructuredOutput: true,
128
+ modelTimeoutMs: 300000,
129
+ synthesisTimeoutMs: 360000,
130
+ retryAttempts: 3,
131
+ retryDelayMs: 3000,
132
+ },
133
+ lastUpdated: new Date().toISOString(),
134
+ };
135
+ }