@bramburn/pi-model-council 1.6.2 → 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/CHANGELOG.md +342 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +128 -0
- package/DISCLAIMER.md +62 -0
- package/LICENSE +21 -0
- package/README.md +460 -0
- package/SECURITY.md +80 -0
- package/SUPPORT.md +72 -0
- package/commandParser.ts +333 -0
- package/councilRunner.ts +499 -0
- package/index.ts +304 -0
- package/markdown.ts +113 -0
- package/openrouterClient.ts +244 -0
- package/package.json +76 -1
- package/prompts.ts +299 -0
- package/retry.ts +92 -0
- package/runnerHelpers.ts +130 -0
- package/schemas.ts +44 -0
- package/searchSelector.ts +313 -0
- package/secondOpinionRunner.ts +203 -0
- package/settings-ui.ts +488 -0
- package/settings.ts +135 -0
- package/structuredOutput.ts +500 -0
- package/types.ts +169 -0
- package/index.js +0 -1
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
|
+
}
|