@bugabinga/pi-ext-devil 0.1.0
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 +16 -0
- package/README.md +82 -0
- package/__tests__/helpers.test.ts +207 -0
- package/assets/advisor_suite.gif +0 -0
- package/bun.lock +300 -0
- package/helpers.ts +105 -0
- package/index.ts +897 -0
- package/package.json +25 -0
- package/types.ts +14 -0
package/index.ts
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devil's Advocate Extension for Pi
|
|
3
|
+
*
|
|
4
|
+
* A structured debate tool for stress-testing ideas against a devil's advocate.
|
|
5
|
+
* Designed to reduce sycophancy by forcing critical examination.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* /devil <your idea> - Start an interactive debate with live progress
|
|
9
|
+
*
|
|
10
|
+
* Configuration (settings.json):
|
|
11
|
+
* {
|
|
12
|
+
* "devil": {
|
|
13
|
+
* "model": "zai/glm-5.1", // Optional: specific model
|
|
14
|
+
* "maxRounds": 5, // Maximum debate rounds
|
|
15
|
+
* "stoppingConditions": "consensus", // consensus | blocking_found | max_rounds
|
|
16
|
+
* "concessionThreshold": 0.7, // 0.0-1.0 for concession detection
|
|
17
|
+
* "onBlocking": "surface" // surface | escalate | ignore
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import * as fs from "node:fs";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { complete } from "@earendil-works/pi-ai";
|
|
25
|
+
import {
|
|
26
|
+
convertToLlm,
|
|
27
|
+
serializeConversation,
|
|
28
|
+
} from "@earendil-works/pi-coding-agent";
|
|
29
|
+
import type {
|
|
30
|
+
ExtensionAPI,
|
|
31
|
+
ExtensionContext,
|
|
32
|
+
Model,
|
|
33
|
+
SessionEntry,
|
|
34
|
+
} from "@earendil-works/pi-coding-agent";
|
|
35
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import {
|
|
37
|
+
matchesKey,
|
|
38
|
+
truncateToWidth,
|
|
39
|
+
visibleWidth,
|
|
40
|
+
wrapTextWithAnsi,
|
|
41
|
+
type Component,
|
|
42
|
+
type TUI,
|
|
43
|
+
} from "@earendil-works/pi-tui";
|
|
44
|
+
import type { DebateOutput } from "./types";
|
|
45
|
+
import {
|
|
46
|
+
textFromContent,
|
|
47
|
+
abortedResult,
|
|
48
|
+
checkAborted,
|
|
49
|
+
countBlockingIssues,
|
|
50
|
+
calcConcessionScore,
|
|
51
|
+
buildContextText,
|
|
52
|
+
buildRoundPrompt,
|
|
53
|
+
buildProposerPrompt,
|
|
54
|
+
cleanTranscriptText,
|
|
55
|
+
ROUND_HISTORY_TRUNCATE,
|
|
56
|
+
CONSENSUS_ROUNDS_REQUIRED,
|
|
57
|
+
} from "./helpers";
|
|
58
|
+
|
|
59
|
+
const DEVIL_SYSTEM_PROMPT = `Devil advocate. STRESS TEST ideas. Find blocking issues.
|
|
60
|
+
|
|
61
|
+
CLASSIFICATION:
|
|
62
|
+
- **blocking**: Prevents idea working. Must fix before proceeding.
|
|
63
|
+
- **addressable**: Valid concern. Has mitigations.
|
|
64
|
+
- **assumed**: Premise may not hold.
|
|
65
|
+
- **minor**: Cosmetic. No functional impact.
|
|
66
|
+
|
|
67
|
+
RULES:
|
|
68
|
+
1. Lead with blocking. Skip minor issues.
|
|
69
|
+
2. Name exact failure modes. No vague risks.
|
|
70
|
+
3. Acknowledge successful defenses.
|
|
71
|
+
4. Focus on what BREAKS the idea.
|
|
72
|
+
5. Do NOT repeat concerns already raised in previous rounds.
|
|
73
|
+
6. ESCALATE: if a previous concern was partially addressed, find the deeper issue.
|
|
74
|
+
7. If all previous concerns were resolved, concede and say "No new blocking concerns."
|
|
75
|
+
|
|
76
|
+
FORMAT:
|
|
77
|
+
### [CLASSIFICATION] Title
|
|
78
|
+
**Concern:** [problem]
|
|
79
|
+
**Fix:** [what resolves it]
|
|
80
|
+
**Status:** outstanding / resolved / retracted`;
|
|
81
|
+
|
|
82
|
+
const PROPOSER_SYSTEM_PROMPT = `Present idea. DEFEND where valid. ACKNOWLEDGE weaknesses.
|
|
83
|
+
|
|
84
|
+
RULES:
|
|
85
|
+
1. Don't capitulate easily. Require specific evidence.
|
|
86
|
+
2. Distinguish: "I don't know" vs "doesn't apply" vs "addressed that".
|
|
87
|
+
3. If concern valid → acknowledge + explain mitigation.
|
|
88
|
+
4. Never concede blocking issues without explaining fix.
|
|
89
|
+
5. Maintain consistency with your previous defenses.
|
|
90
|
+
6. If you previously conceded something, don't walk it back without new evidence.
|
|
91
|
+
|
|
92
|
+
FOR EACH CHALLENGE:
|
|
93
|
+
1. **Defense**: What defeats/challenges it
|
|
94
|
+
2. **Concession** (if valid): What remains
|
|
95
|
+
3. **Status**: outstanding / resolved
|
|
96
|
+
|
|
97
|
+
Be specific. "Addressed that" requires HOW.`;
|
|
98
|
+
|
|
99
|
+
const SYNTHESIS_SYSTEM_PROMPT = `Analyze the full debate and produce a structured verdict.
|
|
100
|
+
|
|
101
|
+
You MUST respond with ONLY a valid JSON object. No markdown, no explanation, no code fences.
|
|
102
|
+
Just the raw JSON object, nothing else.
|
|
103
|
+
|
|
104
|
+
JSON schema:
|
|
105
|
+
{
|
|
106
|
+
"recommendation": "proceed" | "proceed_with_caution" | "revise" | "abandon",
|
|
107
|
+
"summary": "2-3 sentence overview of the debate outcome",
|
|
108
|
+
"blocking": ["concern description → proposed fix"],
|
|
109
|
+
"addressable": ["concern description → mitigation"],
|
|
110
|
+
"survived": ["claims that withstood challenges"],
|
|
111
|
+
"conceded": ["acknowledged weaknesses"],
|
|
112
|
+
"next_steps": ["action item"]
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
Classification rules:
|
|
116
|
+
- blocking: Must fix before proceeding
|
|
117
|
+
- addressable: Has known mitigations
|
|
118
|
+
- If no blocking issues remain and concerns were resolved: "proceed"
|
|
119
|
+
- If blocking issues remain but have clear fixes: "revise"
|
|
120
|
+
- If fundamental premise is destroyed: "abandon"
|
|
121
|
+
- Otherwise: "proceed_with_caution"`;
|
|
122
|
+
|
|
123
|
+
// Default settings
|
|
124
|
+
const DEFAULT_MAX_ROUNDS = 5;
|
|
125
|
+
const DEFAULT_STOPPING_CONDITIONS = "consensus";
|
|
126
|
+
const DEFAULT_CONCESSION_THRESHOLD = 0.7;
|
|
127
|
+
const DEFAULT_ON_BLOCKING = "surface";
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
interface DevilSettings {
|
|
131
|
+
model?: string;
|
|
132
|
+
maxRounds?: number;
|
|
133
|
+
stoppingConditions?: "consensus" | "blocking_found" | "max_rounds";
|
|
134
|
+
concessionThreshold?: number;
|
|
135
|
+
onBlocking?: "surface" | "escalate" | "ignore";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface SynthesisResult {
|
|
139
|
+
recommendation: "proceed" | "proceed_with_caution" | "revise" | "abandon";
|
|
140
|
+
summary: string;
|
|
141
|
+
blocking: string[];
|
|
142
|
+
addressable: string[];
|
|
143
|
+
survived: string[];
|
|
144
|
+
conceded: string[];
|
|
145
|
+
next_steps: string[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface RunDebateOptions {
|
|
149
|
+
idea: string;
|
|
150
|
+
context?: Record<string, string>;
|
|
151
|
+
model: Model;
|
|
152
|
+
apiKey: string;
|
|
153
|
+
headers?: Record<string, string>;
|
|
154
|
+
maxRounds: number;
|
|
155
|
+
stoppingConditions: "consensus" | "blocking_found" | "max_rounds";
|
|
156
|
+
concessionThreshold: number;
|
|
157
|
+
signal?: AbortSignal;
|
|
158
|
+
onStatus?: (status: string, round: number, maxRounds: number, blocking: number) => void;
|
|
159
|
+
/** Fires after devil responds, before proposer responds (mid-round update) */
|
|
160
|
+
onChallenge?: (
|
|
161
|
+
round: number,
|
|
162
|
+
maxRounds: number,
|
|
163
|
+
blocking: number,
|
|
164
|
+
devilOutput: string,
|
|
165
|
+
) => void;
|
|
166
|
+
/** Fires after both devil and proposer respond (round complete) */
|
|
167
|
+
onRound?: (
|
|
168
|
+
round: number,
|
|
169
|
+
maxRounds: number,
|
|
170
|
+
blocking: number,
|
|
171
|
+
status: string,
|
|
172
|
+
devilOutput: string,
|
|
173
|
+
proposerOutput: string,
|
|
174
|
+
) => void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type DebateRole = "devil" | "proposer";
|
|
178
|
+
|
|
179
|
+
interface DebateTranscriptEntry {
|
|
180
|
+
role: DebateRole;
|
|
181
|
+
round: number;
|
|
182
|
+
text: string;
|
|
183
|
+
blocking: number;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
interface DebateLiveState {
|
|
187
|
+
idea: string;
|
|
188
|
+
maxRounds: number;
|
|
189
|
+
status: string;
|
|
190
|
+
currentRound: number;
|
|
191
|
+
blocking: number;
|
|
192
|
+
entries: DebateTranscriptEntry[];
|
|
193
|
+
startedAt: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Helpers ───────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
const MAX_FILE_CHARS = 3000; // per-file cap for context injection
|
|
199
|
+
const MAX_FILES = 10; // max files to include
|
|
200
|
+
const FILE_TOOL_NAMES = new Set(["read", "edit", "write"]);
|
|
201
|
+
|
|
202
|
+
interface ToolCallBlock {
|
|
203
|
+
type: "toolCall";
|
|
204
|
+
name: string;
|
|
205
|
+
arguments: Record<string, unknown>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createDebateState(idea: string, maxRounds: number): DebateLiveState {
|
|
209
|
+
return {
|
|
210
|
+
idea,
|
|
211
|
+
maxRounds,
|
|
212
|
+
status: "Starting debate...",
|
|
213
|
+
currentRound: 0,
|
|
214
|
+
blocking: 0,
|
|
215
|
+
entries: [],
|
|
216
|
+
startedAt: Date.now(),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function setDebateStatus(
|
|
221
|
+
state: DebateLiveState,
|
|
222
|
+
status: string,
|
|
223
|
+
round: number,
|
|
224
|
+
blocking: number,
|
|
225
|
+
) {
|
|
226
|
+
state.status = status;
|
|
227
|
+
state.currentRound = round;
|
|
228
|
+
state.blocking = blocking;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function addDebateEntry(
|
|
232
|
+
state: DebateLiveState,
|
|
233
|
+
role: DebateRole,
|
|
234
|
+
round: number,
|
|
235
|
+
blocking: number,
|
|
236
|
+
text: string,
|
|
237
|
+
) {
|
|
238
|
+
state.currentRound = round;
|
|
239
|
+
state.blocking = blocking;
|
|
240
|
+
state.entries.push({ role, round, blocking, text });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── State helpers ───────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
function padToWidth(line: string, width: number): string {
|
|
246
|
+
const target = Math.max(0, width);
|
|
247
|
+
const truncated = truncateToWidth(line, target);
|
|
248
|
+
return truncated + " ".repeat(Math.max(0, target - visibleWidth(truncated)));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function frameLine(theme: Theme, left: string, content: string, right: string, width: number): string {
|
|
252
|
+
if (width <= 0) return "";
|
|
253
|
+
if (width === 1) return truncateToWidth(theme.fg("border", left), width);
|
|
254
|
+
const innerWidth = Math.max(0, width - 2);
|
|
255
|
+
return truncateToWidth(
|
|
256
|
+
theme.fg("border", left) + padToWidth(content, innerWidth) + theme.fg("border", right),
|
|
257
|
+
width,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function renderDebateTranscript(
|
|
262
|
+
state: DebateLiveState,
|
|
263
|
+
theme: Theme,
|
|
264
|
+
width: number,
|
|
265
|
+
options: { compact: boolean; maxBodyLines: number },
|
|
266
|
+
): string[] {
|
|
267
|
+
if (width <= 0) return [""];
|
|
268
|
+
const lines: string[] = [];
|
|
269
|
+
const usableWidth = width;
|
|
270
|
+
const elapsed = Math.floor((Date.now() - state.startedAt) / 1000);
|
|
271
|
+
const header = `Review: ${state.status} · ${state.blocking} blocking · ${elapsed}s`;
|
|
272
|
+
lines.push(theme.fg("accent", truncateToWidth(header, usableWidth)));
|
|
273
|
+
|
|
274
|
+
const entries = options.compact ? state.entries.slice(-2) : state.entries;
|
|
275
|
+
if (entries.length === 0) {
|
|
276
|
+
lines.push(theme.fg("dim", truncateToWidth(" Waiting for first challenge...", usableWidth)));
|
|
277
|
+
return lines.map((line) => truncateToWidth(line, usableWidth));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (const entry of entries) {
|
|
281
|
+
const icon = entry.role === "devil" ? "⚑" : "✓";
|
|
282
|
+
const label = entry.role === "devil" ? "Devil" : "Defense";
|
|
283
|
+
lines.push(theme.fg(entry.role === "devil" ? "warning" : "success", truncateToWidth(`${icon} R${entry.round} ${label}`, usableWidth)));
|
|
284
|
+
|
|
285
|
+
const bodyWidth = Math.max(1, usableWidth - 2);
|
|
286
|
+
const body = cleanTranscriptText(entry.text, options.compact ? 700 : 20000);
|
|
287
|
+
const wrapped = wrapTextWithAnsi(theme.fg("dim", body), bodyWidth).slice(0, options.maxBodyLines);
|
|
288
|
+
for (const line of wrapped) lines.push(truncateToWidth(` ${line}`, usableWidth));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return lines.map((line) => truncateToWidth(line, usableWidth));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
export class DebateReviewComponent implements Component {
|
|
296
|
+
private scrollOffset = 0;
|
|
297
|
+
private followMode = true;
|
|
298
|
+
private finished = false;
|
|
299
|
+
private disposed = false;
|
|
300
|
+
private cachedBodyWidth?: number;
|
|
301
|
+
private cachedBodyKey?: string;
|
|
302
|
+
private cachedBody?: string[];
|
|
303
|
+
|
|
304
|
+
constructor(
|
|
305
|
+
private tui: TUI,
|
|
306
|
+
private theme: Theme,
|
|
307
|
+
private state: DebateLiveState,
|
|
308
|
+
private done: (output: DebateOutput | null) => void,
|
|
309
|
+
private controller: AbortController,
|
|
310
|
+
) {}
|
|
311
|
+
|
|
312
|
+
get signal(): AbortSignal {
|
|
313
|
+
return this.controller.signal;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
isActive(): boolean {
|
|
317
|
+
return !this.finished && !this.disposed && !this.signal.aborted;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
cancel() {
|
|
321
|
+
this.controller.abort();
|
|
322
|
+
this.finish(null);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
finish(output: DebateOutput | null) {
|
|
326
|
+
if (this.finished || this.disposed) return;
|
|
327
|
+
if (output === null) this.controller.abort();
|
|
328
|
+
this.finished = true;
|
|
329
|
+
this.done(output);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
refresh() {
|
|
333
|
+
if (!this.isActive()) return;
|
|
334
|
+
this.tui.requestRender();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
handleInput(data: string): void {
|
|
338
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") {
|
|
339
|
+
this.cancel();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (matchesKey(data, "up") || data === "k") {
|
|
343
|
+
this.followMode = false;
|
|
344
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
|
|
345
|
+
this.refresh();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (matchesKey(data, "down") || data === "j") {
|
|
349
|
+
this.scrollOffset++;
|
|
350
|
+
this.refresh();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (matchesKey(data, "pageup") || matchesKey(data, "ctrl+u")) {
|
|
354
|
+
this.followMode = false;
|
|
355
|
+
this.scrollOffset = Math.max(0, this.scrollOffset - 10);
|
|
356
|
+
this.refresh();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (matchesKey(data, "pagedown") || matchesKey(data, "ctrl+d")) {
|
|
360
|
+
this.scrollOffset += 10;
|
|
361
|
+
this.refresh();
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (data === "g") {
|
|
365
|
+
this.followMode = false;
|
|
366
|
+
this.scrollOffset = 0;
|
|
367
|
+
this.refresh();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (data === "G") {
|
|
371
|
+
this.followMode = true;
|
|
372
|
+
this.scrollOffset = 999999;
|
|
373
|
+
this.refresh();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private getBody(width: number): string[] {
|
|
378
|
+
const key = [
|
|
379
|
+
this.state.status,
|
|
380
|
+
this.state.currentRound,
|
|
381
|
+
this.state.blocking,
|
|
382
|
+
this.state.entries.length,
|
|
383
|
+
...this.state.entries.map((entry) => `${entry.role}:${entry.round}:${entry.blocking}:${entry.text.length}`),
|
|
384
|
+
].join("|");
|
|
385
|
+
if (this.cachedBody && this.cachedBodyWidth === width && this.cachedBodyKey === key) {
|
|
386
|
+
return this.cachedBody;
|
|
387
|
+
}
|
|
388
|
+
this.cachedBodyWidth = width;
|
|
389
|
+
this.cachedBodyKey = key;
|
|
390
|
+
this.cachedBody = renderDebateTranscript(this.state, this.theme, width, {
|
|
391
|
+
compact: false,
|
|
392
|
+
maxBodyLines: 1000,
|
|
393
|
+
});
|
|
394
|
+
return this.cachedBody;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
render(width: number): string[] {
|
|
398
|
+
if (width <= 0) return [""];
|
|
399
|
+
const innerWidth = Math.max(0, width - 2);
|
|
400
|
+
const body = this.getBody(innerWidth);
|
|
401
|
+
const maxVisible = Math.max(6, Math.min(24, this.tui.terminal.rows - 8));
|
|
402
|
+
const maxScroll = Math.max(0, body.length - maxVisible);
|
|
403
|
+
if (this.followMode) this.scrollOffset = maxScroll;
|
|
404
|
+
this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
|
|
405
|
+
const visible = body.slice(this.scrollOffset, this.scrollOffset + maxVisible);
|
|
406
|
+
|
|
407
|
+
const title = ` Devil Review · ${this.state.currentRound}/${this.state.maxRounds} `;
|
|
408
|
+
const top = this.theme.fg("accent", title) + this.theme.fg("border", "─".repeat(Math.max(0, innerWidth - visibleWidth(title))));
|
|
409
|
+
const lines = [frameLine(this.theme, "╭", top, "╮", width)];
|
|
410
|
+
|
|
411
|
+
for (const line of visible) {
|
|
412
|
+
lines.push(frameLine(this.theme, "│", line, "│", width));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const scrollInfo = body.length > maxVisible
|
|
416
|
+
? `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisible, body.length)}/${body.length}`
|
|
417
|
+
: `${body.length}L`;
|
|
418
|
+
const follow = this.followMode ? "follow" : "paused";
|
|
419
|
+
const footer = this.theme.fg("dim", ` ${scrollInfo} · ${follow} · j/k scroll · g/G top/end · q cancel `);
|
|
420
|
+
lines.push(frameLine(this.theme, "├", this.theme.fg("border", "─".repeat(innerWidth)), "┤", width));
|
|
421
|
+
lines.push(frameLine(this.theme, "│", footer, "│", width));
|
|
422
|
+
lines.push(frameLine(this.theme, "╰", this.theme.fg("border", "─".repeat(innerWidth)), "╯", width));
|
|
423
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
invalidate(): void {
|
|
427
|
+
this.cachedBody = undefined;
|
|
428
|
+
this.cachedBodyWidth = undefined;
|
|
429
|
+
this.cachedBodyKey = undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
dispose(): void {
|
|
433
|
+
this.disposed = true;
|
|
434
|
+
this.controller.abort();
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function gatherContext(ctx: ExtensionContext): Record<string, string> {
|
|
439
|
+
const context: Record<string, string> = {};
|
|
440
|
+
context["working_directory"] = ctx.cwd;
|
|
441
|
+
|
|
442
|
+
const branch = ctx.sessionManager.getBranch();
|
|
443
|
+
|
|
444
|
+
// ── Part 1: Extract file paths from assistant toolCall blocks, read current contents ──
|
|
445
|
+
// Tool arguments (file paths) live in assistant messages' content[type=toolCall].arguments
|
|
446
|
+
// NOT in toolResult messages (those only have output text, no input).
|
|
447
|
+
// Pattern from summarize.ts: scan assistant content for toolCall blocks.
|
|
448
|
+
const filePaths = new Set<string>();
|
|
449
|
+
|
|
450
|
+
for (const entry of branch) {
|
|
451
|
+
if (entry.type !== "message") continue;
|
|
452
|
+
const msg = entry.message;
|
|
453
|
+
if (!msg || msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
|
|
454
|
+
|
|
455
|
+
for (const block of msg.content) {
|
|
456
|
+
const tc = block as ToolCallBlock;
|
|
457
|
+
if (tc.type !== "toolCall" || !FILE_TOOL_NAMES.has(tc.name)) continue;
|
|
458
|
+
|
|
459
|
+
// read/edit/write all use "path" (edit may also have "file_path" in older sessions)
|
|
460
|
+
const filePath = (tc.arguments.path ?? tc.arguments.file_path) as string | undefined;
|
|
461
|
+
if (typeof filePath !== "string" || !filePath.trim()) continue;
|
|
462
|
+
|
|
463
|
+
const resolved = path.resolve(ctx.cwd, filePath.trim());
|
|
464
|
+
try {
|
|
465
|
+
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
466
|
+
filePaths.add(resolved);
|
|
467
|
+
}
|
|
468
|
+
} catch {
|
|
469
|
+
// skip inaccessible files
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (filePaths.size > 0) {
|
|
475
|
+
const files = [...filePaths].slice(0, MAX_FILES);
|
|
476
|
+
const fileContents = files
|
|
477
|
+
.map((fp) => {
|
|
478
|
+
try {
|
|
479
|
+
let content = fs.readFileSync(fp, "utf-8");
|
|
480
|
+
if (content.length > MAX_FILE_CHARS) {
|
|
481
|
+
content = content.slice(0, MAX_FILE_CHARS) + "\n... (truncated)";
|
|
482
|
+
}
|
|
483
|
+
const rel = path.relative(ctx.cwd, fp);
|
|
484
|
+
return `### ${rel}\n\`\`\`\n${content}\n\`\`\``;
|
|
485
|
+
} catch {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
})
|
|
489
|
+
.filter((c): c is string => c !== null);
|
|
490
|
+
if (fileContents.length > 0) {
|
|
491
|
+
context["relevant_files"] = fileContents.join("\n\n");
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── Part 2: Recent conversation (last 20 messages) ──
|
|
496
|
+
const messages = branch
|
|
497
|
+
.filter(
|
|
498
|
+
(entry): entry is SessionEntry & { type: "message" } =>
|
|
499
|
+
entry.type === "message",
|
|
500
|
+
)
|
|
501
|
+
.map((entry) => entry.message);
|
|
502
|
+
|
|
503
|
+
if (messages.length > 0) {
|
|
504
|
+
const recent = messages.slice(-20);
|
|
505
|
+
const llmMessages = convertToLlm(recent);
|
|
506
|
+
context["conversation"] = serializeConversation(llmMessages);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return context;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
function formatSynthesis(s: SynthesisResult): string {
|
|
515
|
+
const recEmoji: Record<string, string> = {
|
|
516
|
+
proceed: "✅",
|
|
517
|
+
proceed_with_caution: "⚡",
|
|
518
|
+
revise: "⚠️",
|
|
519
|
+
abandon: "🚫",
|
|
520
|
+
};
|
|
521
|
+
const emoji = recEmoji[s.recommendation] ?? "⚡";
|
|
522
|
+
|
|
523
|
+
let text = `## Verdict: ${s.recommendation.replace(/_/g, " ")} ${emoji}\n\n`;
|
|
524
|
+
text += `${s.summary}\n`;
|
|
525
|
+
|
|
526
|
+
if (s.blocking.length > 0) {
|
|
527
|
+
text += `\n### Blocking Issues\n`;
|
|
528
|
+
for (const b of s.blocking) text += `- ${b}\n`;
|
|
529
|
+
}
|
|
530
|
+
if (s.addressable.length > 0) {
|
|
531
|
+
text += `\n### Addressable Concerns\n`;
|
|
532
|
+
for (const a of s.addressable) text += `- ${a}\n`;
|
|
533
|
+
}
|
|
534
|
+
if (s.survived.length > 0) {
|
|
535
|
+
text += `\n### Survived\n`;
|
|
536
|
+
for (const c of s.survived) text += `- ${c}\n`;
|
|
537
|
+
}
|
|
538
|
+
if (s.conceded.length > 0) {
|
|
539
|
+
text += `\n### Conceded\n`;
|
|
540
|
+
for (const c of s.conceded) text += `- ${c}\n`;
|
|
541
|
+
}
|
|
542
|
+
if (s.next_steps.length > 0) {
|
|
543
|
+
text += `\n### Next Steps\n`;
|
|
544
|
+
for (const n of s.next_steps) text += `- ${n}\n`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return text;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function resolveDebateModel(
|
|
551
|
+
ctx: ExtensionContext,
|
|
552
|
+
modelSetting?: string,
|
|
553
|
+
): Model | null {
|
|
554
|
+
const current = ctx.model ?? null;
|
|
555
|
+
|
|
556
|
+
if (modelSetting) {
|
|
557
|
+
const [provider, modelId] = modelSetting.includes("/")
|
|
558
|
+
? modelSetting.split("/", 2)
|
|
559
|
+
: [current?.provider, modelSetting];
|
|
560
|
+
|
|
561
|
+
if (provider) {
|
|
562
|
+
const found = ctx.modelRegistry.find(provider, modelId);
|
|
563
|
+
if (found) return found;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return current;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function parseSynthesis(raw: string): SynthesisResult {
|
|
571
|
+
// Try JSON parse first
|
|
572
|
+
try {
|
|
573
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
574
|
+
if (jsonMatch) {
|
|
575
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
576
|
+
return {
|
|
577
|
+
recommendation:
|
|
578
|
+
["proceed", "proceed_with_caution", "revise", "abandon"].includes(
|
|
579
|
+
parsed.recommendation,
|
|
580
|
+
)
|
|
581
|
+
? parsed.recommendation
|
|
582
|
+
: "proceed_with_caution",
|
|
583
|
+
summary: String(parsed.summary ?? ""),
|
|
584
|
+
blocking: Array.isArray(parsed.blocking) ? parsed.blocking.map(String) : [],
|
|
585
|
+
addressable: Array.isArray(parsed.addressable)
|
|
586
|
+
? parsed.addressable.map(String)
|
|
587
|
+
: [],
|
|
588
|
+
survived: Array.isArray(parsed.survived) ? parsed.survived.map(String) : [],
|
|
589
|
+
conceded: Array.isArray(parsed.conceded) ? parsed.conceded.map(String) : [],
|
|
590
|
+
next_steps: Array.isArray(parsed.next_steps)
|
|
591
|
+
? parsed.next_steps.map(String)
|
|
592
|
+
: [],
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
} catch {}
|
|
596
|
+
|
|
597
|
+
// Fallback: regex parse from markdown
|
|
598
|
+
const recMatch = raw.match(/\*\*recommendation\*\*:\s*(\w+)/i);
|
|
599
|
+
const rec = recMatch?.[1]?.toLowerCase();
|
|
600
|
+
return {
|
|
601
|
+
recommendation:
|
|
602
|
+
rec && ["proceed", "proceed_with_caution", "revise", "abandon"].includes(rec)
|
|
603
|
+
? (rec as SynthesisResult["recommendation"])
|
|
604
|
+
: "proceed_with_caution",
|
|
605
|
+
summary: raw.slice(0, 200),
|
|
606
|
+
blocking: [],
|
|
607
|
+
addressable: [],
|
|
608
|
+
survived: [],
|
|
609
|
+
conceded: [],
|
|
610
|
+
next_steps: [],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Debate orchestration ────────────────────────────────────────────
|
|
615
|
+
async function runRound(
|
|
616
|
+
round: number,
|
|
617
|
+
maxRounds: number,
|
|
618
|
+
idea: string,
|
|
619
|
+
contextText: string,
|
|
620
|
+
roundHistory: string,
|
|
621
|
+
model: Model,
|
|
622
|
+
apiKey: string,
|
|
623
|
+
headers: Record<string, string> | undefined,
|
|
624
|
+
signal: AbortSignal | undefined,
|
|
625
|
+
onStatus: RunDebateOptions["onStatus"],
|
|
626
|
+
): Promise<{
|
|
627
|
+
devilOutput: string;
|
|
628
|
+
proposerOutput: string;
|
|
629
|
+
roundBlockingCount: number;
|
|
630
|
+
aborted: boolean;
|
|
631
|
+
}> {
|
|
632
|
+
onStatus?.(`Round ${round}/${maxRounds} · devil challenging`, round, maxRounds, 0);
|
|
633
|
+
|
|
634
|
+
const devilUserText = buildRoundPrompt(round, maxRounds, idea, contextText, roundHistory);
|
|
635
|
+
const devilResponse = await complete(model, { systemPrompt: DEVIL_SYSTEM_PROMPT, messages: [{ role: "user", content: [{ type: "text", text: devilUserText }], timestamp: Date.now() }] }, { apiKey, headers, signal });
|
|
636
|
+
const devilOutput = textFromContent(devilResponse.content);
|
|
637
|
+
const roundBlockingCount = countBlockingIssues(devilOutput);
|
|
638
|
+
|
|
639
|
+
onStatus?.(`Round ${round}/${maxRounds} · proposer defending`, round, maxRounds, roundBlockingCount);
|
|
640
|
+
|
|
641
|
+
const proposerUserText = buildProposerPrompt(idea, roundHistory, round, devilOutput);
|
|
642
|
+
const proposerResponse = await complete(model, { systemPrompt: PROPOSER_SYSTEM_PROMPT, messages: [{ role: "user", content: [{ type: "text", text: proposerUserText }], timestamp: Date.now() }] }, { apiKey, headers, signal });
|
|
643
|
+
const proposerOutput = textFromContent(proposerResponse.content);
|
|
644
|
+
|
|
645
|
+
return { devilOutput, proposerOutput, roundBlockingCount, aborted: !!signal?.aborted };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Synthesize the debate into a final verdict */
|
|
649
|
+
async function synthesizeDebate(
|
|
650
|
+
idea: string,
|
|
651
|
+
roundsData: string[],
|
|
652
|
+
model: Model,
|
|
653
|
+
apiKey: string,
|
|
654
|
+
headers: Record<string, string> | undefined,
|
|
655
|
+
signal: AbortSignal | undefined,
|
|
656
|
+
): Promise<SynthesisResult> {
|
|
657
|
+
const synthesisUserText = [
|
|
658
|
+
`Idea: ${idea}`,
|
|
659
|
+
`Debate (${roundsData.length} rounds):`,
|
|
660
|
+
roundsData.join("\n\n---\n\n"),
|
|
661
|
+
].join("\n\n");
|
|
662
|
+
|
|
663
|
+
const synthesisResponse = await complete(
|
|
664
|
+
model,
|
|
665
|
+
{
|
|
666
|
+
systemPrompt: SYNTHESIS_SYSTEM_PROMPT,
|
|
667
|
+
messages: [{ role: "user", content: [{ type: "text", text: synthesisUserText }], timestamp: Date.now() }],
|
|
668
|
+
},
|
|
669
|
+
{ apiKey, headers, signal },
|
|
670
|
+
);
|
|
671
|
+
|
|
672
|
+
return parseSynthesis(textFromContent(synthesisResponse.content));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Core debate logic ─────────────────────────────────────────────────
|
|
676
|
+
|
|
677
|
+
async function runDebate(opts: RunDebateOptions): Promise<DebateOutput> {
|
|
678
|
+
const { idea, context, model, apiKey, headers, maxRounds, stoppingConditions, concessionThreshold, signal, onStatus, onChallenge, onRound } = opts;
|
|
679
|
+
|
|
680
|
+
const contextText = buildContextText(context);
|
|
681
|
+
const roundsData: string[] = [];
|
|
682
|
+
let blockingIssuesFound = 0;
|
|
683
|
+
let consecutiveResolved = 0;
|
|
684
|
+
let roundHistory = "";
|
|
685
|
+
|
|
686
|
+
// Run debate rounds
|
|
687
|
+
for (let round = 1; round <= maxRounds && consecutiveResolved < CONSENSUS_ROUNDS_REQUIRED; round++) {
|
|
688
|
+
const earlyExit = checkAborted(signal, roundsData, blockingIssuesFound);
|
|
689
|
+
if (earlyExit) return earlyExit;
|
|
690
|
+
|
|
691
|
+
const { devilOutput, proposerOutput, roundBlockingCount, aborted } = await runRound(
|
|
692
|
+
round, maxRounds, idea, contextText, roundHistory, model, apiKey, headers, signal, onStatus,
|
|
693
|
+
);
|
|
694
|
+
if (aborted) return abortedResult(roundsData, blockingIssuesFound);
|
|
695
|
+
|
|
696
|
+
blockingIssuesFound += roundBlockingCount;
|
|
697
|
+
onChallenge?.(round, maxRounds, blockingIssuesFound, devilOutput);
|
|
698
|
+
|
|
699
|
+
roundsData.push(`### Round ${round}\n\n**Challenge:**\n${devilOutput}\n\n**Response:**\n${proposerOutput}`);
|
|
700
|
+
roundHistory += `### Round ${round}\n**Challenge:** ${devilOutput.slice(0, ROUND_HISTORY_TRUNCATE)}\n**Response:** ${proposerOutput.slice(0, ROUND_HISTORY_TRUNCATE)}\n\n`;
|
|
701
|
+
|
|
702
|
+
const status = `Round ${round}/${maxRounds} · ${blockingIssuesFound} blocking raised`;
|
|
703
|
+
onRound?.(round, maxRounds, blockingIssuesFound, status, devilOutput, proposerOutput);
|
|
704
|
+
|
|
705
|
+
// Check stopping conditions
|
|
706
|
+
const concessionScore = calcConcessionScore(proposerOutput);
|
|
707
|
+
if (stoppingConditions === "consensus") {
|
|
708
|
+
if (concessionScore >= concessionThreshold) {
|
|
709
|
+
consecutiveResolved++;
|
|
710
|
+
} else {
|
|
711
|
+
consecutiveResolved = 0;
|
|
712
|
+
}
|
|
713
|
+
} else if (stoppingConditions === "blocking_found" && roundBlockingCount === 0) {
|
|
714
|
+
break;
|
|
715
|
+
} else if (stoppingConditions === "max_rounds" && round >= maxRounds) {
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const finalAbort = checkAborted(signal, roundsData, blockingIssuesFound);
|
|
721
|
+
if (finalAbort) return finalAbort;
|
|
722
|
+
|
|
723
|
+
onStatus?.("Synthesizing verdict", roundsData.length, maxRounds, blockingIssuesFound);
|
|
724
|
+
const synthesis = await synthesizeDebate(idea, roundsData, model, apiKey, headers, signal);
|
|
725
|
+
|
|
726
|
+
return {
|
|
727
|
+
synthesis: formatSynthesis(synthesis),
|
|
728
|
+
roundsData,
|
|
729
|
+
recommendation: synthesis.recommendation,
|
|
730
|
+
blockingRaised: blockingIssuesFound,
|
|
731
|
+
blockingRemaining: synthesis.blocking.length,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function formatDebateOutput(
|
|
736
|
+
output: DebateOutput,
|
|
737
|
+
onBlocking: "surface" | "escalate" | "ignore",
|
|
738
|
+
): string {
|
|
739
|
+
let blockingNote = "";
|
|
740
|
+
if (output.blockingRemaining > 0 && onBlocking === "escalate") {
|
|
741
|
+
blockingNote = `\n\n⚠️ **${output.blockingRemaining} unresolved blocking issue(s).** Review before proceeding.`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const meta = output.aborted
|
|
745
|
+
? `*Debate cancelled | ${output.roundsData.length} rounds completed*`
|
|
746
|
+
: `*Debate: ${output.roundsData.length}r | ${output.blockingRaised} blocking challenges raised | ${output.blockingRemaining} unresolved in verdict*`;
|
|
747
|
+
|
|
748
|
+
return `${output.synthesis}${blockingNote}\n\n${meta}`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// ── Extension entry ───────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
export default async function (pi: ExtensionAPI) {
|
|
754
|
+
const settings = pi.settingsManager?.getSettings();
|
|
755
|
+
const devilSettings: DevilSettings = settings?.devil ?? {};
|
|
756
|
+
const activeControllers = new Set<AbortController>();
|
|
757
|
+
|
|
758
|
+
// ── Flags ─────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
pi.registerFlag("devil-max-rounds", {
|
|
761
|
+
description: "Override max debate rounds (1-10)",
|
|
762
|
+
type: "number",
|
|
763
|
+
default: devilSettings.maxRounds ?? DEFAULT_MAX_ROUNDS,
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
pi.registerFlag("devil-stop", {
|
|
767
|
+
description: "Override stopping condition (consensus | blocking_found | max_rounds)",
|
|
768
|
+
type: "string",
|
|
769
|
+
default: devilSettings.stoppingConditions ?? DEFAULT_STOPPING_CONDITIONS,
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// ── Cleanup: cancel active debates on shutdown ────────────────────────
|
|
773
|
+
pi.on("session_shutdown", async () => {
|
|
774
|
+
for (const controller of activeControllers) controller.abort();
|
|
775
|
+
activeControllers.clear();
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
// ── Command: /devil ────────────────────────────────────────────────
|
|
779
|
+
|
|
780
|
+
pi.registerCommand("devil", {
|
|
781
|
+
description: "Start an interactive devil's advocate debate",
|
|
782
|
+
handler: async (args, ctx) => {
|
|
783
|
+
const idea = args.trim();
|
|
784
|
+
if (!idea) {
|
|
785
|
+
ctx.ui?.notify("Usage: /devil <your idea>", "error");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
if (!ctx.hasUI) {
|
|
789
|
+
ctx.ui?.notify("Devil requires interactive mode", "error");
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
const model = resolveDebateModel(ctx, devilSettings.model);
|
|
793
|
+
if (!model) {
|
|
794
|
+
ctx.ui?.notify("No model selected. Use /model first.", "error");
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
799
|
+
if (!auth.ok || !auth.apiKey) {
|
|
800
|
+
ctx.ui?.notify(`No API key for ${model.provider}`, "error");
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const maxRounds = pi.getFlag("devil-max-rounds") as number ?? devilSettings.maxRounds ?? DEFAULT_MAX_ROUNDS;
|
|
805
|
+
const stoppingConditions = ((pi.getFlag("devil-stop") as string) ??
|
|
806
|
+
devilSettings.stoppingConditions ??
|
|
807
|
+
DEFAULT_STOPPING_CONDITIONS) as "consensus" | "blocking_found" | "max_rounds";
|
|
808
|
+
const concessionThreshold =
|
|
809
|
+
devilSettings.concessionThreshold ?? DEFAULT_CONCESSION_THRESHOLD;
|
|
810
|
+
const onBlocking = devilSettings.onBlocking ?? DEFAULT_ON_BLOCKING;
|
|
811
|
+
const debateContext = gatherContext(ctx);
|
|
812
|
+
|
|
813
|
+
const debateController = new AbortController();
|
|
814
|
+
activeControllers.add(debateController);
|
|
815
|
+
let result: DebateOutput | null = null;
|
|
816
|
+
let failedMessage: string | undefined;
|
|
817
|
+
try {
|
|
818
|
+
result = await ctx.ui.custom<DebateOutput | null>(
|
|
819
|
+
(tui, theme, _kb, done) => {
|
|
820
|
+
const live = createDebateState(idea, maxRounds);
|
|
821
|
+
const review = new DebateReviewComponent(tui, theme, live, done, debateController);
|
|
822
|
+
|
|
823
|
+
runDebate({
|
|
824
|
+
idea,
|
|
825
|
+
context: debateContext,
|
|
826
|
+
model,
|
|
827
|
+
apiKey: auth.apiKey!,
|
|
828
|
+
headers: auth.headers,
|
|
829
|
+
maxRounds,
|
|
830
|
+
stoppingConditions,
|
|
831
|
+
concessionThreshold,
|
|
832
|
+
signal: review.signal,
|
|
833
|
+
onStatus: (status, round, _max, blocking) => {
|
|
834
|
+
if (!review.isActive()) return;
|
|
835
|
+
setDebateStatus(live, status, round, blocking);
|
|
836
|
+
review.refresh();
|
|
837
|
+
},
|
|
838
|
+
onChallenge: (round, _max, blocking, devilOutput) => {
|
|
839
|
+
if (!review.isActive()) return;
|
|
840
|
+
addDebateEntry(live, "devil", round, blocking, devilOutput);
|
|
841
|
+
review.refresh();
|
|
842
|
+
},
|
|
843
|
+
onRound: (round, _max, blocking, _status, _devilOutput, proposerOutput) => {
|
|
844
|
+
if (!review.isActive()) return;
|
|
845
|
+
addDebateEntry(live, "proposer", round, blocking, proposerOutput);
|
|
846
|
+
review.refresh();
|
|
847
|
+
},
|
|
848
|
+
})
|
|
849
|
+
.then((output) => review.finish(output.aborted ? null : output))
|
|
850
|
+
.catch((err) => {
|
|
851
|
+
if (!review.signal.aborted && review.isActive()) {
|
|
852
|
+
failedMessage = err instanceof Error ? err.message : String(err);
|
|
853
|
+
console.error("[devil] runDebate error:", err);
|
|
854
|
+
ctx.ui.notify(`Debate failed: ${failedMessage}`, "error");
|
|
855
|
+
}
|
|
856
|
+
review.finish(null);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
return review;
|
|
860
|
+
},
|
|
861
|
+
);
|
|
862
|
+
} finally {
|
|
863
|
+
debateController.abort();
|
|
864
|
+
activeControllers.delete(debateController);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (result === null) {
|
|
868
|
+
if (!failedMessage) ctx.ui.notify("Debate cancelled", "info");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
const outputText = formatDebateOutput(result, onBlocking);
|
|
873
|
+
|
|
874
|
+
// Modal editor: Enter submits directly, Esc discards.
|
|
875
|
+
const edited = await ctx.ui.editor(
|
|
876
|
+
`🗡️ ${result.recommendation.replace(/_/g, " ")} · ${result.blockingRaised} raised · ${result.blockingRemaining} unresolved`,
|
|
877
|
+
outputText,
|
|
878
|
+
);
|
|
879
|
+
|
|
880
|
+
if (edited === undefined || !edited.trim()) {
|
|
881
|
+
ctx.ui.notify("Debate result discarded", "info");
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
pi.appendEntry("devil-result", {
|
|
886
|
+
idea,
|
|
887
|
+
verdict: edited,
|
|
888
|
+
roundsData: result.roundsData,
|
|
889
|
+
recommendation: result.recommendation,
|
|
890
|
+
blockingRaised: result.blockingRaised,
|
|
891
|
+
blockingRemaining: result.blockingRemaining,
|
|
892
|
+
timestamp: Date.now(),
|
|
893
|
+
});
|
|
894
|
+
pi.sendUserMessage(edited);
|
|
895
|
+
},
|
|
896
|
+
});
|
|
897
|
+
}
|