@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
|
@@ -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
|
+
}
|