@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.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * Searchable, scrollable model picker — used by the council/opinion settings UIs.
3
+ *
4
+ * In TUI mode this renders a custom component with:
5
+ * - a typeahead search box that fuzzy-filters the list as you type
6
+ * - a scrollable, viewport-bounded list (max ~10 visible items)
7
+ * - ↑/↓ to navigate, Enter to select, Esc to cancel
8
+ *
9
+ * In non-TUI modes (RPC, JSON, print) it falls back to the flat `ctx.ui.select()`
10
+ * dialog so the extension still works in headless environments.
11
+ *
12
+ * This mirrors the UX of pi's built-in `/model` selector (ModelSelectorComponent).
13
+ *
14
+ * Why we don't use `SelectList.setFilter` directly: SelectList filters with
15
+ * `value.startsWith(query)` (case-insensitive), which would make typing
16
+ * "claude" fail to match `value: "anthropic/claude-3.5-sonnet"` because the
17
+ * value doesn't start with "claude". We need real fuzzy matching across
18
+ * label + value + optional haystack, so we manage the filtered list ourselves.
19
+ */
20
+
21
+ import type { ExtensionCommandContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
22
+ import type { Component } from "@earendil-works/pi-tui";
23
+ import {
24
+ fuzzyFilter,
25
+ Input,
26
+ matchesKey,
27
+ Key,
28
+ visibleWidth,
29
+ wrapTextWithAnsi,
30
+ } from "@earendil-works/pi-tui";
31
+
32
+ export interface SelectableItem {
33
+ value: string;
34
+ label: string;
35
+ /** Optional secondary line — shown under the label in muted colour. */
36
+ description?: string;
37
+ /**
38
+ * Optional fuzzy-search hint. If absent, the picker searches across
39
+ * `label` + `value`. Set this when you want a richer search target
40
+ * (e.g. include the provider name alongside the model id).
41
+ */
42
+ searchHaystack?: string;
43
+ }
44
+
45
+ export interface SearchableSelectArgs {
46
+ title: string;
47
+ /** Short footer hint shown below the list. Falls back to the default key-hint line. */
48
+ hint?: string;
49
+ items: SelectableItem[];
50
+ /** Number of visible rows. Defaults to 10 (matches /model). */
51
+ maxVisible?: number;
52
+ /** Hint shown above the search input (e.g. "Type to filter…"). */
53
+ searchPlaceholder?: string;
54
+ }
55
+
56
+ export type SearchableSelectResult = SelectableItem | undefined;
57
+
58
+ /**
59
+ * Show a searchable picker. Resolves to the selected item, or `undefined`
60
+ * if the user pressed Escape.
61
+ */
62
+ export async function searchableSelect(
63
+ ctx: ExtensionCommandContext,
64
+ args: SearchableSelectArgs,
65
+ ): Promise<SearchableSelectResult> {
66
+ // Non-TUI fallback — flat list, no search, but still works headless.
67
+ if (ctx.mode !== "tui") {
68
+ const labels = args.items.map((i) => i.label);
69
+ const choice = await ctx.ui.select(args.title, labels);
70
+ if (!choice) return undefined;
71
+ return args.items.find((i) => i.label === choice);
72
+ }
73
+
74
+ return ctx.ui.custom<SearchableSelectResult>((_tui, theme, _kb, done) => {
75
+ return buildSelectorComponent(theme, args, done);
76
+ });
77
+ }
78
+
79
+ // ─── Internal component factory ────────────────────────────────────────────────
80
+
81
+ interface InternalItem extends SelectableItem {
82
+ haystack: string;
83
+ }
84
+
85
+ function buildSelectorComponent(
86
+ theme: Theme,
87
+ args: SearchableSelectArgs,
88
+ done: (result: SearchableSelectResult) => void,
89
+ ): Component {
90
+ const maxVisible = Math.max(3, args.maxVisible ?? 10);
91
+
92
+ // Pre-compute the search haystack for each item so we don't rebuild
93
+ // it on every keystroke.
94
+ const allItems: InternalItem[] = args.items.map((item) => ({
95
+ ...item,
96
+ haystack: (item.searchHaystack ?? `${item.label} ${item.value}`).toLowerCase(),
97
+ }));
98
+
99
+ // Mutable state
100
+ let query = "";
101
+ let filtered: InternalItem[] = allItems;
102
+ let selectedIndex = 0;
103
+
104
+ // ── Search input (typing) ──
105
+ const input = new Input();
106
+
107
+ const commitSelection = (): void => {
108
+ const selected = filtered[selectedIndex];
109
+ if (!selected) return;
110
+ const original = args.items.find((i) => i.value === selected.value);
111
+ done(original);
112
+ };
113
+
114
+ const cancel = (): void => {
115
+ done(undefined);
116
+ };
117
+
118
+ const recomputeFilter = (): void => {
119
+ const q = query.trim().toLowerCase();
120
+ if (q.length === 0) {
121
+ filtered = allItems;
122
+ } else {
123
+ // Use pi-tui's fuzzyFilter (same primitive used by ModelSelectorComponent
124
+ // and the built-in /model selector). It returns a ranked subset.
125
+ filtered = fuzzyFilter(
126
+ allItems,
127
+ query,
128
+ (item) => item.haystack,
129
+ );
130
+ }
131
+ // Keep selection in bounds.
132
+ selectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, filtered.length - 1)));
133
+ };
134
+
135
+ input.onSubmit = () => commitSelection();
136
+ input.onEscape = () => cancel();
137
+
138
+ // ── Key router ──
139
+ // Input owns typing/Enter/Esc routing; we own Up/Down for navigation.
140
+ const handleInput = (data: string): void => {
141
+ // Up/Down: navigate the list directly.
142
+ if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
143
+ if (matchesKey(data, Key.up)) {
144
+ if (filtered.length === 0) return;
145
+ selectedIndex = selectedIndex === 0 ? filtered.length - 1 : selectedIndex - 1;
146
+ } else {
147
+ if (filtered.length === 0) return;
148
+ selectedIndex = selectedIndex === filtered.length - 1 ? 0 : selectedIndex + 1;
149
+ }
150
+ return;
151
+ }
152
+
153
+ // Esc: cancel. Intercept before the Input so cancel semantics are
154
+ // predictable regardless of input focus.
155
+ if (matchesKey(data, Key.escape)) {
156
+ cancel();
157
+ return;
158
+ }
159
+
160
+ // Everything else (typing, backspace, enter, etc.) goes to the Input.
161
+ input.handleInput(data);
162
+
163
+ // After any input mutation, sync the query and refilter.
164
+ query = input.getValue();
165
+ recomputeFilter();
166
+ };
167
+
168
+ // ── Render ──
169
+ let cachedLines: string[] | undefined;
170
+
171
+ function render(width: number): string[] {
172
+ if (cachedLines) return cachedLines;
173
+
174
+ const lines: string[] = [];
175
+ const renderWidth = Math.max(1, width);
176
+ const indent = " ";
177
+
178
+ function addWrapped(text: string) {
179
+ lines.push(...wrapTextWithAnsi(text, renderWidth));
180
+ }
181
+
182
+ function addWrappedWithPrefix(prefix: string, text: string) {
183
+ const prefixWidth = visibleWidth(prefix);
184
+ if (prefixWidth >= renderWidth) {
185
+ addWrapped(prefix + text);
186
+ return;
187
+ }
188
+ const wrapped = wrapTextWithAnsi(text, renderWidth - prefixWidth);
189
+ const continuationPrefix = " ".repeat(prefixWidth);
190
+ for (let i = 0; i < wrapped.length; i++) {
191
+ lines.push(`${i === 0 ? prefix : continuationPrefix}${wrapped[i]}`);
192
+ }
193
+ }
194
+
195
+ // Top border
196
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
197
+
198
+ // Title
199
+ addWrappedWithPrefix(indent, theme.fg("accent", args.title));
200
+ if (args.searchPlaceholder) {
201
+ addWrappedWithPrefix(indent, theme.fg("muted", args.searchPlaceholder));
202
+ }
203
+ lines.push("");
204
+
205
+ // Search input
206
+ for (const line of input.render(Math.max(1, renderWidth - indent.length * 2))) {
207
+ lines.push(`${indent}${line}`);
208
+ }
209
+
210
+ lines.push("");
211
+
212
+ // List (viewport-bounded, scrollable, with item count + match count)
213
+ if (filtered.length === 0) {
214
+ if (query.length > 0) {
215
+ addWrappedWithPrefix(
216
+ indent,
217
+ theme.fg("warning", `No matches for "${query}"`),
218
+ );
219
+ } else {
220
+ addWrappedWithPrefix(indent, theme.fg("muted", "No items"));
221
+ }
222
+ } else {
223
+ const startIndex = Math.max(
224
+ 0,
225
+ Math.min(
226
+ selectedIndex - Math.floor(maxVisible / 2),
227
+ filtered.length - maxVisible,
228
+ ),
229
+ );
230
+ const endIndex = Math.min(startIndex + maxVisible, filtered.length);
231
+
232
+ for (let i = startIndex; i < endIndex; i++) {
233
+ const item = filtered[i];
234
+ if (!item) continue;
235
+ const isSelected = i === selectedIndex;
236
+ const prefix = isSelected ? theme.fg("accent", "→ ") : " ";
237
+ const labelText = isSelected
238
+ ? theme.fg("accent", item.label)
239
+ : theme.fg("text", item.label);
240
+ const labelLine = `${prefix}${labelText}`;
241
+ addWrappedWithPrefix(indent, labelLine);
242
+
243
+ if (item.description) {
244
+ addWrappedWithPrefix(
245
+ indent + " ",
246
+ theme.fg("muted", item.description),
247
+ );
248
+ }
249
+ }
250
+
251
+ // Scroll / count line
252
+ if (filtered.length > maxVisible) {
253
+ addWrappedWithPrefix(
254
+ indent,
255
+ theme.fg(
256
+ "dim",
257
+ ` (${selectedIndex + 1}/${filtered.length} · ${allItems.length} total)`,
258
+ ),
259
+ );
260
+ } else if (allItems.length > 0) {
261
+ addWrappedWithPrefix(
262
+ indent,
263
+ theme.fg("dim", ` (${filtered.length}/${allItems.length})`),
264
+ );
265
+ }
266
+ }
267
+
268
+ lines.push("");
269
+
270
+ // Footer hint
271
+ addWrappedWithPrefix(
272
+ indent,
273
+ theme.fg(
274
+ "dim",
275
+ args.hint ?? "Type to search ↑↓ navigate Enter select Esc cancel",
276
+ ),
277
+ );
278
+
279
+ // Bottom border
280
+ lines.push(theme.fg("accent", "─".repeat(renderWidth)));
281
+
282
+ cachedLines = lines;
283
+ return lines;
284
+ }
285
+
286
+ // Drop the cache whenever the input or the filtered list changes.
287
+ // Input doesn't call invalidate() on every keystroke, so we drop it here.
288
+ input.invalidate = () => {
289
+ cachedLines = undefined;
290
+ };
291
+
292
+ // The TUI calls our handleInput, which mutates state. After it returns,
293
+ // the TUI calls requestRender() — we just need to make sure cachedLines
294
+ // is unset whenever state has changed.
295
+ const invalidate = (): void => {
296
+ cachedLines = undefined;
297
+ };
298
+
299
+ // Wrap handleInput so every invocation invalidates the render cache.
300
+ const wrappedHandleInput = (data: string): void => {
301
+ handleInput(data);
302
+ cachedLines = undefined;
303
+ };
304
+
305
+ return {
306
+ render,
307
+ handleInput: wrappedHandleInput,
308
+ invalidate,
309
+ };
310
+ }
311
+
312
+ // Re-export the ExtensionUIContext for callers that just want to type-hint.
313
+ export type { ExtensionUIContext };
@@ -0,0 +1,203 @@
1
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
2
+ import type { ModelOpinion, SecondOpinionInput } from "./types.js";
3
+ import { OpinionSetupError } from "./types.js";
4
+ import { buildSecondOpinionPrompt } from "./prompts.js";
5
+ import { modelOpinionJsonSchema } from "./structuredOutput.js";
6
+ import { retry, isStructuredOutputError } from "./retry.js";
7
+ import { loadSettings } from "./settings.js";
8
+ import {
9
+ callModelWithTimeout,
10
+ parseModelOpinionResponse,
11
+ resolveOpenRouterApiKey,
12
+ } from "./runnerHelpers.js";
13
+
14
+ export async function runSecondOpinion(args: {
15
+ input: SecondOpinionInput;
16
+ signal?: AbortSignal;
17
+ onStatus?: (message: string) => void;
18
+ cwd?: string;
19
+ isProjectTrusted?: boolean;
20
+ /** Optional pi extension context — when supplied we use it to resolve the
21
+ * OpenRouter API key from pi's auth storage if the settings file doesn't
22
+ * carry one. */
23
+ modelRegistry?: ModelRegistry;
24
+ }): Promise<{
25
+ opinion: ModelOpinion;
26
+ rawText: string;
27
+ markdown: string;
28
+ }> {
29
+ const cwd = args.cwd ?? process.cwd();
30
+ const isProjectTrusted = args.isProjectTrusted ?? false;
31
+
32
+ // ── Load settings ────────────────────────────────────────────────────────
33
+ const settings = await loadSettings(cwd, isProjectTrusted);
34
+
35
+ if (!settings) {
36
+ throw new OpinionSetupError(
37
+ "Second opinion model is not configured.\n\n" +
38
+ "Fix: run `/opinion-settings` (or `/council-settings`, which also\n" +
39
+ "configures the opinion model).",
40
+ );
41
+ }
42
+
43
+ // ── Validate input ───────────────────────────────────────────────────────
44
+ if (!args.input.problem || args.input.problem.trim().length === 0) {
45
+ throw new Error("Problem is required and must be non-empty");
46
+ }
47
+
48
+ // ── Resolve API key (settings → registry → env) ─────────────────────────
49
+ args.onStatus?.("Second opinion: resolving API key...");
50
+ const apiKey = await resolveOpenRouterApiKey(settings, args.modelRegistry);
51
+ if (!apiKey) {
52
+ throw new OpinionSetupError(
53
+ "Second opinion cannot run: no OpenRouter API key found.\n\n" +
54
+ "Fix: set OPENROUTER_API_KEY, run `/login openrouter` in pi, or save a\n" +
55
+ "key via `/council-settings`.",
56
+ );
57
+ }
58
+
59
+ const opinionModelId = settings.opinion.modelId;
60
+
61
+ args.onStatus?.(`Second opinion: querying ${opinionModelId}...`);
62
+
63
+ // ── Build prompt ────────────────────────────────────────────────────────
64
+ const { systemPrompt, userPrompt } = buildSecondOpinionPrompt(args.input);
65
+
66
+ const useStructuredOutput = settings.options.useStructuredOutput;
67
+ const modelTimeoutMs = settings.options.modelTimeoutMs;
68
+ const retryAttempts = settings.options.retryAttempts;
69
+ const retryDelayMs = settings.options.retryDelayMs;
70
+
71
+ // ── Call model (with structured output + retry, matching /council) ──────
72
+ let attemptWithStructuredOutput = useStructuredOutput;
73
+ let rawText: string;
74
+ const warnings: string[] = [];
75
+
76
+ try {
77
+ rawText = await callModelWithTimeout({
78
+ apiKey,
79
+ model: opinionModelId,
80
+ systemPrompt,
81
+ userPrompt,
82
+ signal: args.signal,
83
+ timeoutMs: modelTimeoutMs,
84
+ structuredOutputSchema: attemptWithStructuredOutput ? modelOpinionJsonSchema : undefined,
85
+ structuredOutputName: "model_opinion",
86
+ });
87
+ } catch (firstError) {
88
+ if (attemptWithStructuredOutput && isStructuredOutputError(firstError)) {
89
+ warnings.push(
90
+ `Model ${opinionModelId} does not support structured output, using fallback mode.`,
91
+ );
92
+ attemptWithStructuredOutput = false;
93
+ const retryResult = await retry({
94
+ attempts: retryAttempts,
95
+ delayMs: retryDelayMs,
96
+ operation: () =>
97
+ callModelWithTimeout({
98
+ apiKey,
99
+ model: opinionModelId,
100
+ systemPrompt,
101
+ userPrompt,
102
+ signal: args.signal,
103
+ timeoutMs: modelTimeoutMs,
104
+ structuredOutputSchema: undefined,
105
+ structuredOutputName: undefined,
106
+ }),
107
+ });
108
+ rawText = retryResult.value;
109
+ } else {
110
+ throw firstError;
111
+ }
112
+ }
113
+
114
+ args.onStatus?.("Second opinion: parsing response...");
115
+
116
+ // ── Parse + repair (shared with /council) ────────────────────────────────
117
+ const parsed = parseModelOpinionResponse(rawText);
118
+ warnings.push(...parsed.warnings);
119
+
120
+ args.onStatus?.("Second opinion: rendering markdown...");
121
+
122
+ const markdown = renderSecondOpinionMarkdown(parsed.opinion, args.input, warnings);
123
+
124
+ args.onStatus?.("Second opinion: complete");
125
+
126
+ // Preserve the legacy return shape (rawText exposed for callers).
127
+ return { opinion: parsed.opinion, rawText, markdown };
128
+ }
129
+
130
+ function renderSecondOpinionMarkdown(
131
+ opinion: ModelOpinion,
132
+ input: SecondOpinionInput,
133
+ warnings: string[],
134
+ ): string {
135
+ const lines: string[] = [];
136
+
137
+ lines.push("# Second Opinion");
138
+ lines.push("");
139
+ lines.push(`Generated by: ${input.mode ?? "general"} model (model-council)`);
140
+ lines.push("");
141
+
142
+ if (warnings.length > 0) {
143
+ for (const warning of warnings) {
144
+ lines.push(`> **Note:** ${warning}`);
145
+ }
146
+ lines.push("");
147
+ }
148
+
149
+ lines.push(`**Problem:** ${input.problem}`);
150
+ if (input.currentUnderstanding) {
151
+ lines.push(`**Your Understanding:** ${input.currentUnderstanding}`);
152
+ }
153
+ lines.push(`**Confidence:** ${opinion.confidence}`);
154
+ lines.push("");
155
+
156
+ if (opinion.stance) {
157
+ lines.push("## Stance");
158
+ lines.push(opinion.stance);
159
+ lines.push("");
160
+ }
161
+
162
+ lines.push("## Recommended Approach");
163
+ lines.push(opinion.recommendedApproach);
164
+ lines.push("");
165
+
166
+ if (opinion.steps.length > 0) {
167
+ lines.push("## Steps");
168
+ for (let i = 0; i < opinion.steps.length; i++) {
169
+ lines.push(`${i + 1}. ${opinion.steps[i]}`);
170
+ }
171
+ lines.push("");
172
+ }
173
+
174
+ if (opinion.filesToConsider.length > 0) {
175
+ lines.push("## Files to Consider");
176
+ for (const file of opinion.filesToConsider) {
177
+ lines.push(`- \`${file.path}\`: ${file.suggestedAction} — ${file.reason}`);
178
+ }
179
+ lines.push("");
180
+ }
181
+
182
+ if (opinion.risks.length > 0) {
183
+ lines.push("## Key Risks");
184
+ for (const risk of opinion.risks) {
185
+ lines.push(`- ${risk}`);
186
+ }
187
+ lines.push("");
188
+ }
189
+
190
+ if (opinion.verification.length > 0) {
191
+ lines.push("## Verification");
192
+ for (const verification of opinion.verification) {
193
+ lines.push(`- ${verification}`);
194
+ }
195
+ lines.push("");
196
+ }
197
+
198
+ lines.push("***");
199
+ lines.push("");
200
+ lines.push("This is a single-model second opinion. For multi-perspective analysis, use /council.");
201
+
202
+ return lines.join("\n");
203
+ }