@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/package.json
CHANGED
|
@@ -1 +1,76 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@bramburn/pi-model-council",
|
|
3
|
+
"version": "1.6.11",
|
|
4
|
+
"description": "Pi extension: multi-model coding decisions via OpenRouter",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"*.ts",
|
|
9
|
+
"!*.test.ts",
|
|
10
|
+
"!tsconfig.json",
|
|
11
|
+
"!vitest.config.ts",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"CHANGELOG.md",
|
|
15
|
+
"SECURITY.md",
|
|
16
|
+
"DISCLAIMER.md",
|
|
17
|
+
"CONTRIBUTING.md",
|
|
18
|
+
"CODE_OF_CONDUCT.md",
|
|
19
|
+
"SUPPORT.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi-extension",
|
|
24
|
+
"openrouter",
|
|
25
|
+
"ai-council"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/bramburn/pi-model-council"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/bramburn/pi-model-council/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/bramburn/pi-model-council#readme",
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.14.0",
|
|
37
|
+
"npm": ">=11.5.1"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"registry": "https://registry.npmjs.org/",
|
|
42
|
+
"provenance": true
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"lint": "eslint . --max-warnings 0",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest",
|
|
49
|
+
"test:coverage": "vitest run --coverage",
|
|
50
|
+
"audit": "npm audit --audit-level=high",
|
|
51
|
+
"prepublishOnly": "npm run lint && npm run typecheck && npm test",
|
|
52
|
+
"pack:dry": "npm pack --dry-run"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@sinclair/typebox": "^0.34.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
59
|
+
"@earendil-works/pi-tui": "*",
|
|
60
|
+
"@eslint/js": "^9.0.0",
|
|
61
|
+
"@types/node": "^22.0.0",
|
|
62
|
+
"eslint": "^9.0.0",
|
|
63
|
+
"typescript": "^5.7.0",
|
|
64
|
+
"typescript-eslint": "^8.0.0",
|
|
65
|
+
"vitest": "^3.2.6"
|
|
66
|
+
},
|
|
67
|
+
"peerDependencies": {
|
|
68
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
69
|
+
"@earendil-works/pi-tui": "*"
|
|
70
|
+
},
|
|
71
|
+
"pi": {
|
|
72
|
+
"extensions": [
|
|
73
|
+
"./"
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
}
|
package/prompts.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CouncilInput,
|
|
3
|
+
CouncilModelResult,
|
|
4
|
+
ModelOpinion,
|
|
5
|
+
SecondOpinionInput,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
export function buildSecondOpinionPrompt(input: SecondOpinionInput): {
|
|
9
|
+
systemPrompt: string;
|
|
10
|
+
userPrompt: string;
|
|
11
|
+
} {
|
|
12
|
+
const mode = input.mode ?? "general";
|
|
13
|
+
|
|
14
|
+
const systemPrompt = `You are a technical coding advisor providing a second opinion.
|
|
15
|
+
Give a practical, implementation-oriented response.
|
|
16
|
+
Be direct and concise. When uncertain, say so.
|
|
17
|
+
Focus on actionable recommendations.
|
|
18
|
+
Return valid JSON in the requested shape.`;
|
|
19
|
+
|
|
20
|
+
const userPromptParts: string[] = [];
|
|
21
|
+
|
|
22
|
+
userPromptParts.push(`# Second Opinion Request`);
|
|
23
|
+
userPromptParts.push(`**Mode:** ${mode.toUpperCase()}`);
|
|
24
|
+
userPromptParts.push("");
|
|
25
|
+
userPromptParts.push("## Problem");
|
|
26
|
+
userPromptParts.push(input.problem);
|
|
27
|
+
|
|
28
|
+
if (input.currentUnderstanding) {
|
|
29
|
+
userPromptParts.push("");
|
|
30
|
+
userPromptParts.push("## Current Understanding");
|
|
31
|
+
userPromptParts.push(input.currentUnderstanding);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (input.relevantFiles && input.relevantFiles.length > 0) {
|
|
35
|
+
userPromptParts.push("");
|
|
36
|
+
userPromptParts.push("## Relevant Files");
|
|
37
|
+
for (const file of input.relevantFiles) {
|
|
38
|
+
userPromptParts.push(`- **${file.path}**: ${file.summary}`);
|
|
39
|
+
if (file.importantSnippets) {
|
|
40
|
+
userPromptParts.push(` \`\`\`\n${file.importantSnippets}\n \`\`\``);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (input.constraints && input.constraints.length > 0) {
|
|
46
|
+
userPromptParts.push("");
|
|
47
|
+
userPromptParts.push("## Constraints");
|
|
48
|
+
for (const constraint of input.constraints) {
|
|
49
|
+
userPromptParts.push(`- ${constraint}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (input.questions && input.questions.length > 0) {
|
|
54
|
+
userPromptParts.push("");
|
|
55
|
+
userPromptParts.push("## Specific Questions");
|
|
56
|
+
for (const question of input.questions) {
|
|
57
|
+
userPromptParts.push(`- ${question}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
userPromptParts.push("");
|
|
62
|
+
userPromptParts.push("Return JSON in this exact shape:");
|
|
63
|
+
userPromptParts.push(`{
|
|
64
|
+
"stance": "short position on the approach",
|
|
65
|
+
"recommendedApproach": "short description of recommended approach",
|
|
66
|
+
"steps": ["step 1", "step 2"],
|
|
67
|
+
"filesToConsider": [
|
|
68
|
+
{
|
|
69
|
+
"path": "file path",
|
|
70
|
+
"reason": "why this file is relevant",
|
|
71
|
+
"suggestedAction": "modify|add|read-only|no-change"
|
|
72
|
+
}
|
|
73
|
+
],
|
|
74
|
+
"risks": ["risk 1", "risk 2"],
|
|
75
|
+
"verification": ["test or check to verify"],
|
|
76
|
+
"confidence": "low|medium|high"
|
|
77
|
+
}`);
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
systemPrompt,
|
|
81
|
+
userPrompt: userPromptParts.join("\n"),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildProposalPrompts(input: CouncilInput): {
|
|
86
|
+
systemPrompt: string;
|
|
87
|
+
userPrompt: string;
|
|
88
|
+
} {
|
|
89
|
+
const systemPrompt = `You are one member of a three-model coding council.
|
|
90
|
+
Your job is to give an independent technical opinion.
|
|
91
|
+
You are not editing code.
|
|
92
|
+
You are advising the main Pi coding agent.
|
|
93
|
+
Be practical, conservative, and implementation-oriented.
|
|
94
|
+
Prefer minimal, testable changes for fixes.
|
|
95
|
+
For architecture questions, prefer clear boundaries, maintainability, and reversible decisions.
|
|
96
|
+
Return ONLY valid JSON matching the requested shape.
|
|
97
|
+
Do not wrap JSON in Markdown.`;
|
|
98
|
+
|
|
99
|
+
const userPromptParts: string[] = [];
|
|
100
|
+
|
|
101
|
+
userPromptParts.push(`# Council Mode: ${input.mode.toUpperCase()}`);
|
|
102
|
+
userPromptParts.push("");
|
|
103
|
+
userPromptParts.push("## Problem");
|
|
104
|
+
userPromptParts.push(input.problem);
|
|
105
|
+
|
|
106
|
+
if (input.currentUnderstanding) {
|
|
107
|
+
userPromptParts.push("");
|
|
108
|
+
userPromptParts.push("## Current Understanding");
|
|
109
|
+
userPromptParts.push(input.currentUnderstanding);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (input.relevantFiles && input.relevantFiles.length > 0) {
|
|
113
|
+
userPromptParts.push("");
|
|
114
|
+
userPromptParts.push("## Relevant Files");
|
|
115
|
+
for (const file of input.relevantFiles) {
|
|
116
|
+
userPromptParts.push(`- **${file.path}**: ${file.summary}`);
|
|
117
|
+
if (file.importantSnippets) {
|
|
118
|
+
userPromptParts.push(` \`\`\`\n${file.importantSnippets}\n \`\`\``);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (input.constraints && input.constraints.length > 0) {
|
|
124
|
+
userPromptParts.push("");
|
|
125
|
+
userPromptParts.push("## Constraints");
|
|
126
|
+
for (const constraint of input.constraints) {
|
|
127
|
+
userPromptParts.push(`- ${constraint}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (input.questionsToCouncil && input.questionsToCouncil.length > 0) {
|
|
132
|
+
userPromptParts.push("");
|
|
133
|
+
userPromptParts.push("## Questions to Council");
|
|
134
|
+
for (const question of input.questionsToCouncil) {
|
|
135
|
+
userPromptParts.push(`- ${question}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
userPromptParts.push("");
|
|
140
|
+
userPromptParts.push(`Return JSON in this exact shape:`);
|
|
141
|
+
userPromptParts.push(`{
|
|
142
|
+
"stance": "short position on the approach",
|
|
143
|
+
"recommendedApproach": "short description of recommended approach",
|
|
144
|
+
"steps": ["step 1", "step 2"],
|
|
145
|
+
"filesToConsider": [
|
|
146
|
+
{
|
|
147
|
+
"path": "file path",
|
|
148
|
+
"reason": "why this file is relevant",
|
|
149
|
+
"suggestedAction": "modify|add|read-only|no-change"
|
|
150
|
+
}
|
|
151
|
+
],
|
|
152
|
+
"risks": ["risk 1", "risk 2"],
|
|
153
|
+
"verification": ["test or check to verify"],
|
|
154
|
+
"confidence": "low|medium|high"
|
|
155
|
+
}`);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
systemPrompt,
|
|
159
|
+
userPrompt: userPromptParts.join("\n"),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function buildSynthesisPrompts(input: CouncilInput, results: CouncilModelResult[]): {
|
|
164
|
+
systemPrompt: string;
|
|
165
|
+
userPrompt: string;
|
|
166
|
+
/**
|
|
167
|
+
* Map from blind label (e.g. "Opinion A") to the underlying model id.
|
|
168
|
+
* Surfaced after the synthesis output so the chairman's reasoning can be
|
|
169
|
+
* cross-referenced without biasing the synthesis itself.
|
|
170
|
+
*/
|
|
171
|
+
labelMap: Array<{ label: string; model: string }>;
|
|
172
|
+
} {
|
|
173
|
+
const systemPrompt = `You are the chair of a coding model council.
|
|
174
|
+
You will receive the original problem and the opinions of three models.
|
|
175
|
+
Synthesize one practical decision for the main Pi coding agent.
|
|
176
|
+
|
|
177
|
+
Decision rules (apply in order):
|
|
178
|
+
1. Compare options on evidence, not on which model said them. Do NOT
|
|
179
|
+
over-weight an opinion because of the model name behind it.
|
|
180
|
+
2. Surface disagreements explicitly. Do NOT blend incompatible views
|
|
181
|
+
into a mushy compromise — pick one side with a reason, or escalate
|
|
182
|
+
the disagreement as a "to be validated" item.
|
|
183
|
+
3. Do NOT copy any single opinion verbatim. The plan must reflect the
|
|
184
|
+
council as a whole, even if one model clearly led.
|
|
185
|
+
4. Prefer approaches that are minimal, testable, reversible, and
|
|
186
|
+
consistent with the supplied constraints.
|
|
187
|
+
5. When confidence is mixed, lower the overall confidence and call it
|
|
188
|
+
out in the unknowns list.
|
|
189
|
+
|
|
190
|
+
Return ONLY valid JSON matching the requested CouncilDecision shape.
|
|
191
|
+
Do not wrap JSON in Markdown.`;
|
|
192
|
+
|
|
193
|
+
const userPromptParts: string[] = [];
|
|
194
|
+
|
|
195
|
+
userPromptParts.push("# Original Problem");
|
|
196
|
+
userPromptParts.push(`**Mode:** ${input.mode}`);
|
|
197
|
+
userPromptParts.push(`**Problem:** ${input.problem}`);
|
|
198
|
+
|
|
199
|
+
if (input.currentUnderstanding) {
|
|
200
|
+
userPromptParts.push(`**Current Understanding:** ${input.currentUnderstanding}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (input.constraints && input.constraints.length > 0) {
|
|
204
|
+
userPromptParts.push("**Constraints:**");
|
|
205
|
+
for (const constraint of input.constraints) {
|
|
206
|
+
userPromptParts.push(`- ${constraint}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
userPromptParts.push("");
|
|
211
|
+
userPromptParts.push("# Model Opinions (presented anonymously to avoid bias)");
|
|
212
|
+
userPromptParts.push("");
|
|
213
|
+
|
|
214
|
+
// Build blind labels (Opinion A / B / C) for the chairman. The mapping
|
|
215
|
+
// from blind label to model id is returned separately so the agent
|
|
216
|
+
// that displays the report can correlate notes without leaking it into
|
|
217
|
+
// the chairman's reasoning context.
|
|
218
|
+
const labels = ["A", "B", "C", "D", "E", "F"];
|
|
219
|
+
const labelMap: Array<{ label: string; model: string }> = [];
|
|
220
|
+
let labelIdx = 0;
|
|
221
|
+
|
|
222
|
+
for (const result of results) {
|
|
223
|
+
const blindLabel = `Opinion ${labels[labelIdx++] ?? `S${labelIdx}`}`;
|
|
224
|
+
labelMap.push({ label: blindLabel, model: result.model });
|
|
225
|
+
|
|
226
|
+
userPromptParts.push(`## ${blindLabel}`);
|
|
227
|
+
if (result.ok && result.parsed) {
|
|
228
|
+
const parsed = result.parsed as ModelOpinion;
|
|
229
|
+
userPromptParts.push(`**Stance:** ${parsed.stance}`);
|
|
230
|
+
userPromptParts.push(`**Recommended Approach:** ${parsed.recommendedApproach}`);
|
|
231
|
+
userPromptParts.push(`**Steps:**`);
|
|
232
|
+
for (const step of parsed.steps) {
|
|
233
|
+
userPromptParts.push(` ${parsed.steps.indexOf(step) + 1}. ${step}`);
|
|
234
|
+
}
|
|
235
|
+
if (parsed.filesToConsider.length > 0) {
|
|
236
|
+
userPromptParts.push(`**Files to Consider:**`);
|
|
237
|
+
for (const file of parsed.filesToConsider) {
|
|
238
|
+
userPromptParts.push(` - ${file.path}: ${file.suggestedAction} — ${file.reason}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
userPromptParts.push(`**Risks:** ${parsed.risks.join(", ") || "none"}`);
|
|
242
|
+
userPromptParts.push(`**Verification:** ${parsed.verification.join(", ") || "none"}`);
|
|
243
|
+
userPromptParts.push(`**Confidence:** ${parsed.confidence}`);
|
|
244
|
+
} else if (result.ok && result.rawText) {
|
|
245
|
+
userPromptParts.push("**Response (unstructured):**");
|
|
246
|
+
userPromptParts.push(result.rawText.substring(0, 500) + (result.rawText.length > 500 ? "..." : ""));
|
|
247
|
+
} else if (!result.ok && result.error) {
|
|
248
|
+
userPromptParts.push(`**Error:** ${result.error}`);
|
|
249
|
+
}
|
|
250
|
+
userPromptParts.push("");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
userPromptParts.push("");
|
|
254
|
+
userPromptParts.push("# Synthesize Council Decision");
|
|
255
|
+
userPromptParts.push("");
|
|
256
|
+
userPromptParts.push(`Generate a decisionId using a timestamp-like format (e.g., "council-${Date.now()}").`);
|
|
257
|
+
userPromptParts.push(`In the \`modelNotes\` array, refer to each opinion by its blind label ("${labelMap[0]?.label ?? "Opinion A"}", etc.) — do NOT include model ids.`);
|
|
258
|
+
userPromptParts.push("");
|
|
259
|
+
userPromptParts.push(`Return JSON in this exact shape:`);
|
|
260
|
+
userPromptParts.push(`{
|
|
261
|
+
"decisionId": "council-<timestamp>",
|
|
262
|
+
"mode": "${input.mode}",
|
|
263
|
+
"confidence": "low|medium|high",
|
|
264
|
+
"consensus": {
|
|
265
|
+
"agreements": ["agreed point 1", "agreed point 2"],
|
|
266
|
+
"disagreements": ["disagreement 1", "disagreement 2"],
|
|
267
|
+
"unknowns": ["unknown 1", "unknown 2"]
|
|
268
|
+
},
|
|
269
|
+
"recommendedPlan": {
|
|
270
|
+
"summary": "one-sentence summary of the recommended plan",
|
|
271
|
+
"steps": ["step 1", "step 2", "step 3"]
|
|
272
|
+
},
|
|
273
|
+
"implementationGuidance": {
|
|
274
|
+
"filesToEdit": [
|
|
275
|
+
{
|
|
276
|
+
"path": "file path",
|
|
277
|
+
"reason": "why to edit this file",
|
|
278
|
+
"action": "specific action to take"
|
|
279
|
+
}
|
|
280
|
+
],
|
|
281
|
+
"testsToRun": ["test command or check"],
|
|
282
|
+
"guardrails": ["constraint or warning to follow"]
|
|
283
|
+
},
|
|
284
|
+
"modelNotes": [
|
|
285
|
+
{
|
|
286
|
+
"model": "Opinion A",
|
|
287
|
+
"stance": "one-line stance summary",
|
|
288
|
+
"keyRisks": ["risk 1", "risk 2"]
|
|
289
|
+
}
|
|
290
|
+
],
|
|
291
|
+
"handoffPrompt": "Concise instruction for the main Pi coding agent: what to do next, what to avoid, and what success looks like."
|
|
292
|
+
}`);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
systemPrompt,
|
|
296
|
+
userPrompt: userPromptParts.join("\n"),
|
|
297
|
+
labelMap,
|
|
298
|
+
};
|
|
299
|
+
}
|
package/retry.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeout and retry helpers for council model calls.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Execute a promise with a timeout.
|
|
7
|
+
*/
|
|
8
|
+
export async function withTimeout<T>(
|
|
9
|
+
promiseFactory: (signal: AbortSignal) => Promise<T>,
|
|
10
|
+
timeoutMs: number,
|
|
11
|
+
parentSignal?: AbortSignal
|
|
12
|
+
): Promise<T> {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
|
|
15
|
+
// If parent signal aborts, abort child
|
|
16
|
+
const parentAbortHandler = () => {
|
|
17
|
+
controller.abort();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (parentSignal) {
|
|
21
|
+
parentSignal.addEventListener("abort", parentAbortHandler);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Set timeout
|
|
25
|
+
const timeoutId = setTimeout(() => {
|
|
26
|
+
controller.abort();
|
|
27
|
+
}, timeoutMs);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = await promiseFactory(controller.signal);
|
|
31
|
+
return result;
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timeoutId);
|
|
34
|
+
if (parentSignal) {
|
|
35
|
+
parentSignal.removeEventListener("abort", parentAbortHandler);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Retry an operation with a delay between attempts.
|
|
42
|
+
*/
|
|
43
|
+
export async function retry<T>(args: {
|
|
44
|
+
attempts: number;
|
|
45
|
+
delayMs: number;
|
|
46
|
+
operation: (attempt: number) => Promise<T>;
|
|
47
|
+
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
|
48
|
+
}): Promise<{ value: T; attemptCount: number }> {
|
|
49
|
+
const { attempts, delayMs, operation, shouldRetry } = args;
|
|
50
|
+
|
|
51
|
+
let lastError: unknown;
|
|
52
|
+
|
|
53
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
54
|
+
try {
|
|
55
|
+
const value = await operation(attempt);
|
|
56
|
+
return { value, attemptCount: attempt };
|
|
57
|
+
} catch (error) {
|
|
58
|
+
lastError = error;
|
|
59
|
+
|
|
60
|
+
// Check if we should retry
|
|
61
|
+
const shouldRetryThis = shouldRetry?.(error, attempt) ?? true;
|
|
62
|
+
|
|
63
|
+
if (shouldRetryThis && attempt < attempts) {
|
|
64
|
+
// Delay before next attempt
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// All attempts failed
|
|
71
|
+
throw lastError;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Check if an error is likely due to structured output not being supported.
|
|
76
|
+
*/
|
|
77
|
+
export function isStructuredOutputError(error: unknown): boolean {
|
|
78
|
+
if (!(error instanceof Error)) return false;
|
|
79
|
+
|
|
80
|
+
const message = error.message.toLowerCase();
|
|
81
|
+
const indicators = [
|
|
82
|
+
"response_format",
|
|
83
|
+
"json_schema",
|
|
84
|
+
"structured",
|
|
85
|
+
"unsupported",
|
|
86
|
+
"does not support",
|
|
87
|
+
"400",
|
|
88
|
+
"invalid request",
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
return indicators.some((indicator) => message.includes(indicator));
|
|
92
|
+
}
|
package/runnerHelpers.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers used by both the council runner (multi-model fan-out) and
|
|
3
|
+
* the second-opinion runner (single-model call). Extracted in v1.6.0 so
|
|
4
|
+
* the two runners share the same auth resolution, parse-then-repair
|
|
5
|
+
* pipeline, and call-with-timeout wrapping.
|
|
6
|
+
*
|
|
7
|
+
* Per Sandi Metz: these are "the same idea" (cross-cutting concerns:
|
|
8
|
+
* auth, parsing, timeout) so they belong in one place. The orchestration
|
|
9
|
+
* differences (fan-out vs single-call, synthesis step, fallback decision)
|
|
10
|
+
* remain in the respective runner files where they belong.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { CouncilSettings, ModelOpinion } from "./types.js";
|
|
15
|
+
import { callOpenRouterChat } from "./openrouterClient.js";
|
|
16
|
+
import { extractJsonObject } from "./openrouterClient.js";
|
|
17
|
+
import { repairModelOpinion, validateModelOpinion } from "./structuredOutput.js";
|
|
18
|
+
import { withTimeout } from "./retry.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the OpenRouter API key from three sources, in priority order:
|
|
22
|
+
*
|
|
23
|
+
* 1. Settings file (`council-settings.json`)
|
|
24
|
+
* 2. Pi's auth storage (`ctx.modelRegistry.getApiKeyForProvider("openrouter")`)
|
|
25
|
+
* 3. `process.env.OPENROUTER_API_KEY`
|
|
26
|
+
*
|
|
27
|
+
* Returns the trimmed key, or `undefined` if no source yields a key.
|
|
28
|
+
*/
|
|
29
|
+
export async function resolveOpenRouterApiKey(
|
|
30
|
+
settings: Pick<CouncilSettings, "openRouter">,
|
|
31
|
+
modelRegistry?: ModelRegistry,
|
|
32
|
+
): Promise<string | undefined> {
|
|
33
|
+
const fromSettings = settings.openRouter.apiKey?.trim();
|
|
34
|
+
if (fromSettings) return fromSettings;
|
|
35
|
+
|
|
36
|
+
if (modelRegistry) {
|
|
37
|
+
try {
|
|
38
|
+
const fromRegistry = await modelRegistry.getApiKeyForProvider("openrouter");
|
|
39
|
+
const trimmed = fromRegistry?.trim();
|
|
40
|
+
if (trimmed) return trimmed;
|
|
41
|
+
} catch {
|
|
42
|
+
// Registry may not be available in all contexts; fall through.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const fromEnv = process.env.OPENROUTER_API_KEY?.trim();
|
|
47
|
+
if (fromEnv) return fromEnv;
|
|
48
|
+
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Parse a model's raw text response into a `ModelOpinion`, running through
|
|
54
|
+
* validate → repair → fallback. This consolidates the parse pipeline that
|
|
55
|
+
* both runners used to inline.
|
|
56
|
+
*
|
|
57
|
+
* Returns the parsed opinion and any non-fatal warnings about how it was
|
|
58
|
+
* recovered. The fallback shape (when even repair fails) preserves the
|
|
59
|
+
* raw text snippet so the user can still see what the model said.
|
|
60
|
+
*/
|
|
61
|
+
export function parseModelOpinionResponse(rawText: string): {
|
|
62
|
+
opinion: ModelOpinion;
|
|
63
|
+
warnings: string[];
|
|
64
|
+
} {
|
|
65
|
+
try {
|
|
66
|
+
const jsonObj = extractJsonObject(rawText);
|
|
67
|
+
const validation = validateModelOpinion(jsonObj);
|
|
68
|
+
|
|
69
|
+
if (validation.ok && validation.value) {
|
|
70
|
+
return { opinion: validation.value, warnings: validation.warnings ?? [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const repaired = repairModelOpinion(jsonObj, rawText);
|
|
74
|
+
return { opinion: repaired.value, warnings: repaired.warnings ?? [] };
|
|
75
|
+
} catch {
|
|
76
|
+
// Last-ditch fallback: surface the raw text so the user can still see
|
|
77
|
+
// what the model said, but flag it clearly as unstructured.
|
|
78
|
+
return {
|
|
79
|
+
opinion: {
|
|
80
|
+
stance: "Direct response",
|
|
81
|
+
recommendedApproach: rawText.substring(0, 500),
|
|
82
|
+
steps: [],
|
|
83
|
+
filesToConsider: [],
|
|
84
|
+
risks: [],
|
|
85
|
+
verification: [],
|
|
86
|
+
confidence: "medium",
|
|
87
|
+
},
|
|
88
|
+
warnings: ["Response was not structured JSON, showing raw response."],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Call OpenRouter with a hard timeout that combines the parent abort
|
|
95
|
+
* signal (from Pi's extension context) with a per-call deadline.
|
|
96
|
+
*
|
|
97
|
+
* Throws with a clear message identifying which model failed, so the
|
|
98
|
+
* user can distinguish "slow model" from "bad API key" from "network
|
|
99
|
+
* error" in the runner logs.
|
|
100
|
+
*/
|
|
101
|
+
export async function callModelWithTimeout(args: {
|
|
102
|
+
apiKey: string;
|
|
103
|
+
model: string;
|
|
104
|
+
systemPrompt: string;
|
|
105
|
+
userPrompt: string;
|
|
106
|
+
signal?: AbortSignal;
|
|
107
|
+
timeoutMs: number;
|
|
108
|
+
structuredOutputSchema?: unknown;
|
|
109
|
+
structuredOutputName?: string;
|
|
110
|
+
}): Promise<string> {
|
|
111
|
+
try {
|
|
112
|
+
return await withTimeout(
|
|
113
|
+
(childSignal) =>
|
|
114
|
+
callOpenRouterChat({
|
|
115
|
+
apiKey: args.apiKey,
|
|
116
|
+
model: args.model,
|
|
117
|
+
systemPrompt: args.systemPrompt,
|
|
118
|
+
userPrompt: args.userPrompt,
|
|
119
|
+
signal: childSignal,
|
|
120
|
+
structuredOutputSchema: args.structuredOutputSchema,
|
|
121
|
+
structuredOutputName: args.structuredOutputName,
|
|
122
|
+
}),
|
|
123
|
+
args.timeoutMs,
|
|
124
|
+
args.signal,
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
128
|
+
throw new Error(`Model ${args.model} failed: ${message}`, { cause: error });
|
|
129
|
+
}
|
|
130
|
+
}
|
package/schemas.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
|
|
3
|
+
export const secondOpinionInputSchema = Type.Object({
|
|
4
|
+
problem: Type.String({
|
|
5
|
+
description: "The problem, question, or technical decision to get a second opinion on.",
|
|
6
|
+
}),
|
|
7
|
+
mode: Type.Optional(Type.Union([
|
|
8
|
+
Type.Literal("fix"),
|
|
9
|
+
Type.Literal("ask"),
|
|
10
|
+
Type.Literal("architecture"),
|
|
11
|
+
Type.Literal("general"),
|
|
12
|
+
])),
|
|
13
|
+
currentUnderstanding: Type.Optional(Type.String({
|
|
14
|
+
description: "Your current understanding or proposed approach.",
|
|
15
|
+
})),
|
|
16
|
+
relevantFiles: Type.Optional(Type.Array(Type.Object({
|
|
17
|
+
path: Type.String(),
|
|
18
|
+
summary: Type.String(),
|
|
19
|
+
importantSnippets: Type.Optional(Type.String()),
|
|
20
|
+
}))),
|
|
21
|
+
constraints: Type.Optional(Type.Array(Type.String())),
|
|
22
|
+
questions: Type.Optional(Type.Array(Type.String())),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const councilInputSchema = Type.Object({
|
|
26
|
+
mode: Type.Union([
|
|
27
|
+
Type.Literal("fix"),
|
|
28
|
+
Type.Literal("ask"),
|
|
29
|
+
Type.Literal("architecture"),
|
|
30
|
+
]),
|
|
31
|
+
problem: Type.String({
|
|
32
|
+
description: "The problem, question, or architecture decision to ask the council about.",
|
|
33
|
+
}),
|
|
34
|
+
currentUnderstanding: Type.Optional(Type.String({
|
|
35
|
+
description: "The main Pi model's current understanding or proposed approach.",
|
|
36
|
+
})),
|
|
37
|
+
relevantFiles: Type.Optional(Type.Array(Type.Object({
|
|
38
|
+
path: Type.String(),
|
|
39
|
+
summary: Type.String(),
|
|
40
|
+
importantSnippets: Type.Optional(Type.String()),
|
|
41
|
+
}))),
|
|
42
|
+
constraints: Type.Optional(Type.Array(Type.String())),
|
|
43
|
+
questionsToCouncil: Type.Optional(Type.Array(Type.String())),
|
|
44
|
+
});
|