ennodia 0.1.0-rc.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.
Files changed (53) hide show
  1. package/CONTRIBUTING.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +99 -0
  4. package/bench/README.md +32 -0
  5. package/bench/bug-recall/run.ts +331 -0
  6. package/bench/bug-recall/scorer.ts +424 -0
  7. package/bench/fixtures/bug-recall/001-missing-await/input.ts +33 -0
  8. package/bench/fixtures/bug-recall/001-missing-await/oracle.json +28 -0
  9. package/bench/fixtures/bug-recall/001-missing-await/responses/claude-code-solo.md +1 -0
  10. package/bench/fixtures/bug-recall/001-missing-await/responses/codex-solo.md +1 -0
  11. package/bench/fixtures/bug-recall/001-missing-await/responses/ennodia-parallel-compare.md +1 -0
  12. package/bench/fixtures/bug-recall/002-timeout-drain/input.ts +40 -0
  13. package/bench/fixtures/bug-recall/002-timeout-drain/oracle.json +28 -0
  14. package/bench/fixtures/bug-recall/002-timeout-drain/responses/claude-code-solo.md +1 -0
  15. package/bench/fixtures/bug-recall/002-timeout-drain/responses/codex-solo.md +1 -0
  16. package/bench/fixtures/bug-recall/002-timeout-drain/responses/ennodia-parallel-compare.md +1 -0
  17. package/bench/fixtures/bug-recall/003-schema-contract/input.ts +22 -0
  18. package/bench/fixtures/bug-recall/003-schema-contract/oracle.json +28 -0
  19. package/bench/fixtures/bug-recall/003-schema-contract/responses/claude-code-solo.md +1 -0
  20. package/bench/fixtures/bug-recall/003-schema-contract/responses/codex-solo.md +1 -0
  21. package/bench/fixtures/bug-recall/003-schema-contract/responses/ennodia-parallel-compare.md +1 -0
  22. package/bench/fixtures/bug-recall/004-cancel-propagation/input.ts +28 -0
  23. package/bench/fixtures/bug-recall/004-cancel-propagation/oracle.json +28 -0
  24. package/bench/fixtures/bug-recall/004-cancel-propagation/responses/claude-code-solo.md +1 -0
  25. package/bench/fixtures/bug-recall/004-cancel-propagation/responses/codex-solo.md +1 -0
  26. package/bench/fixtures/bug-recall/004-cancel-propagation/responses/ennodia-parallel-compare.md +1 -0
  27. package/bench/results/.gitkeep +1 -0
  28. package/docs/assets/agents/antigravity-color.svg +1 -0
  29. package/docs/assets/agents/claudecode-color.svg +1 -0
  30. package/docs/assets/agents/codex-color.svg +1 -0
  31. package/docs/assets/agents/opencode-dark.svg +1 -0
  32. package/docs/assets/agents/opencode-light.svg +1 -0
  33. package/docs/assets/icons/arrow.svg +1 -0
  34. package/docs/assets/icons/github.svg +1 -0
  35. package/docs/assets/icons/link.svg +1 -0
  36. package/docs/assets/logo-dark.svg +1 -0
  37. package/docs/assets/logo.svg +1 -0
  38. package/docs/assets/orchestration-pipeline.svg +74 -0
  39. package/docs/getting-started.md +107 -0
  40. package/docs/in-depth/architecture.md +53 -0
  41. package/docs/in-depth/releasing.md +120 -0
  42. package/docs/index.md +16 -0
  43. package/docs/reference/mcp-tools.md +104 -0
  44. package/package.json +87 -0
  45. package/src/compare.ts +777 -0
  46. package/src/diagnosis.ts +161 -0
  47. package/src/harnesses.ts +302 -0
  48. package/src/index.ts +52 -0
  49. package/src/planner.ts +106 -0
  50. package/src/runs.ts +777 -0
  51. package/src/server.ts +747 -0
  52. package/src/tasks.ts +602 -0
  53. package/src/version.ts +1 -0
package/src/compare.ts ADDED
@@ -0,0 +1,777 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import type { HarnessAdapter, HarnessDiscovery } from "./harnesses";
4
+ import type { TaskView, TaskViewOptions } from "./tasks";
5
+ import { TaskManager } from "./tasks";
6
+
7
+ export type CompareCandidateInput = {
8
+ id: string;
9
+ label?: string;
10
+ text: string;
11
+ metadata?: Record<string, unknown>;
12
+ };
13
+
14
+ export type CompareCandidate = {
15
+ id: string;
16
+ label?: string;
17
+ content: string;
18
+ taskId?: string;
19
+ harnessId?: string;
20
+ harnessName?: string;
21
+ status?: string;
22
+ elapsedMs?: number;
23
+ metadata?: Record<string, unknown>;
24
+ };
25
+
26
+ export type CompareAnalysis = z.infer<typeof CompareAnalysisSchema>;
27
+
28
+ export type CompareEvent = {
29
+ at: string;
30
+ type:
31
+ | "started"
32
+ | "judge-started"
33
+ | "judge-succeeded"
34
+ | "judge-degraded"
35
+ | "synthesizer-started"
36
+ | "synthesizer-succeeded"
37
+ | "failed"
38
+ | "cancelled";
39
+ message?: string;
40
+ };
41
+
42
+ export type CompareStatus =
43
+ | "judging"
44
+ | "synthesizing"
45
+ | "succeeded"
46
+ | "failed"
47
+ | "cancelled";
48
+
49
+ export type CompareSynthesis = {
50
+ text: string;
51
+ taskId: string;
52
+ };
53
+
54
+ export type CompareView = {
55
+ id: string;
56
+ status: CompareStatus;
57
+ promptPreview: string;
58
+ createdAt: string;
59
+ updatedAt: string;
60
+ endedAt?: string;
61
+ elapsedMs: number;
62
+ candidateCount: number;
63
+ candidates: CompareCandidate[];
64
+ judgeTaskId?: string;
65
+ synthesizerTaskId?: string;
66
+ activeTaskId?: string;
67
+ remainingMs: number | null;
68
+ etaConfidence: "timeout-budget" | "unknown" | "complete";
69
+ analysis?: CompareAnalysis;
70
+ analysisAvailable: boolean;
71
+ synthesis?: CompareSynthesis;
72
+ events: CompareEvent[];
73
+ };
74
+
75
+ export type CompareViewOptions = {
76
+ includeCandidates?: boolean;
77
+ includeEvents?: boolean;
78
+ maxCandidateChars?: number;
79
+ maxEvents?: number;
80
+ };
81
+
82
+ export type CompareStartInput = {
83
+ prompt: string;
84
+ taskIds?: string[];
85
+ responses?: CompareCandidateInput[];
86
+ judgeHarnessId?: string;
87
+ judgeModel?: string;
88
+ synthesizerHarnessId?: string;
89
+ synthesizerModel?: string;
90
+ cwd?: string;
91
+ timeoutMs?: number;
92
+ maxOutputChars?: number;
93
+ };
94
+
95
+ export type CompareManagerShutdownOptions = {
96
+ deadlineMs?: number;
97
+ };
98
+
99
+ export type ResolvedHarness = {
100
+ adapter: HarnessAdapter;
101
+ discovery: HarnessDiscovery;
102
+ };
103
+
104
+ export type ResolveHarness = (harnessId?: string) => Promise<ResolvedHarness>;
105
+
106
+ type InternalCompare = {
107
+ id: string;
108
+ status: CompareStatus;
109
+ prompt: string;
110
+ candidates: CompareCandidate[];
111
+ createdAtMs: number;
112
+ updatedAtMs: number;
113
+ endedAtMs?: number;
114
+ judgeTaskId?: string;
115
+ synthesizerTaskId?: string;
116
+ analysis?: CompareAnalysis;
117
+ synthesis?: CompareSynthesis;
118
+ events: CompareEvent[];
119
+ };
120
+
121
+ export const CompareAnalysisSchema = z.object({
122
+ consensus: z.array(z.string()).default([]),
123
+ contradictions: z
124
+ .array(
125
+ z.object({
126
+ topic: z.string(),
127
+ stances: z.array(
128
+ z.object({
129
+ source_id: z.string(),
130
+ stance: z.string(),
131
+ }),
132
+ ),
133
+ }),
134
+ )
135
+ .default([]),
136
+ partial_coverage: z
137
+ .array(
138
+ z.object({
139
+ source_ids: z.array(z.string()),
140
+ point: z.string(),
141
+ }),
142
+ )
143
+ .default([]),
144
+ unique_insights: z
145
+ .array(
146
+ z.object({
147
+ source_id: z.string(),
148
+ insight: z.string(),
149
+ }),
150
+ )
151
+ .default([]),
152
+ blind_spots: z.array(z.string()).default([]),
153
+ risks: z.array(z.string()).default([]),
154
+ confidence: z.enum(["low", "medium", "high"]).default("medium"),
155
+ });
156
+
157
+ const MAX_PROMPT_CANDIDATE_CHARS = 24_000;
158
+ const DEFAULT_MAX_OUTPUT_CHARS = 80_000;
159
+ const DEFAULT_MAX_CANDIDATE_CHARS = 6_000;
160
+ const DEFAULT_MAX_EVENTS = 100;
161
+ const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
162
+ const POLL_MS = 500;
163
+
164
+ export class CompareManager {
165
+ private readonly compares = new Map<string, InternalCompare>();
166
+ private shuttingDown = false;
167
+ private shutdownPromise?: Promise<void>;
168
+
169
+ constructor(
170
+ private readonly taskManager: TaskManager,
171
+ private readonly resolveHarness: ResolveHarness,
172
+ ) {}
173
+
174
+ async start(input: CompareStartInput): Promise<CompareView> {
175
+ if (this.shuttingDown) {
176
+ throw new Error("CompareManager is shutting down.");
177
+ }
178
+
179
+ const candidates = this.collectCandidates(input);
180
+ if (candidates.length === 0) {
181
+ throw new Error("Compare needs at least one completed task or response.");
182
+ }
183
+
184
+ const now = Date.now();
185
+ const compare: InternalCompare = {
186
+ id: randomUUID(),
187
+ status: "judging",
188
+ prompt: input.prompt,
189
+ candidates,
190
+ createdAtMs: now,
191
+ updatedAtMs: now,
192
+ events: [],
193
+ };
194
+
195
+ this.compares.set(compare.id, compare);
196
+ this.pushEvent(compare, {
197
+ type: "started",
198
+ message: `Compare started with ${candidates.length} candidate(s).`,
199
+ });
200
+
201
+ void this.run(compare, input);
202
+
203
+ return this.toView(compare);
204
+ }
205
+
206
+ listViews(options: CompareViewOptions = {}): CompareView[] {
207
+ return [...this.compares.values()]
208
+ .sort((a, b) => b.createdAtMs - a.createdAtMs)
209
+ .map((compare) => this.toView(compare, options));
210
+ }
211
+
212
+ get(id: string, options: CompareViewOptions = {}): CompareView | undefined {
213
+ const compare = this.compares.get(id);
214
+ return compare ? this.toView(compare, options) : undefined;
215
+ }
216
+
217
+ cancel(id: string): CompareView {
218
+ const compare = this.requireCompare(id);
219
+
220
+ if (isTerminalCompare(compare)) {
221
+ return this.toView(compare);
222
+ }
223
+
224
+ this.cancelCompare(compare, "Compare cancelled.");
225
+
226
+ for (const taskId of compareTaskIds(compare)) {
227
+ this.taskManager.cancel(taskId);
228
+ }
229
+
230
+ return this.toView(compare);
231
+ }
232
+
233
+ async shutdown(options: CompareManagerShutdownOptions = {}): Promise<void> {
234
+ this.shuttingDown = true;
235
+ this.shutdownPromise ??= this.performShutdown(
236
+ options.deadlineMs ?? DEFAULT_SHUTDOWN_DEADLINE_MS,
237
+ );
238
+ await this.shutdownPromise;
239
+ }
240
+
241
+ private async performShutdown(deadlineMs: number): Promise<void> {
242
+ const taskIds = new Set<string>();
243
+
244
+ for (const compare of this.compares.values()) {
245
+ if (isTerminalCompare(compare)) {
246
+ continue;
247
+ }
248
+
249
+ for (const taskId of compareTaskIds(compare)) {
250
+ taskIds.add(taskId);
251
+ }
252
+
253
+ this.cancelCompare(compare, "Compare cancelled by shutdown.");
254
+ }
255
+
256
+ for (const taskId of taskIds) {
257
+ this.taskManager.cancel(taskId);
258
+ }
259
+
260
+ await Promise.allSettled(
261
+ [...taskIds].map((taskId) =>
262
+ this.taskManager.waitForTerminal(taskId, deadlineMs)
263
+ ),
264
+ );
265
+ }
266
+
267
+ private async run(
268
+ compare: InternalCompare,
269
+ input: CompareStartInput,
270
+ ): Promise<void> {
271
+ try {
272
+ const judgeHarness = await this.resolveHarness(input.judgeHarnessId);
273
+ if (isCompareStatus(compare, "cancelled")) {
274
+ return;
275
+ }
276
+
277
+ const judgePrompt = buildJudgePrompt(input.prompt, compare.candidates);
278
+ const judgeTask = this.taskManager.start(
279
+ judgeHarness.adapter,
280
+ judgeHarness.discovery,
281
+ {
282
+ prompt: judgePrompt,
283
+ cwd: input.cwd,
284
+ model: input.judgeModel,
285
+ timeoutMs: input.timeoutMs,
286
+ },
287
+ ).task;
288
+
289
+ compare.judgeTaskId = judgeTask.id;
290
+ this.pushEvent(compare, {
291
+ type: "judge-started",
292
+ message: `Judge task started through ${judgeTask.harnessName}.`,
293
+ });
294
+
295
+ const settledJudge = await this.waitForTask(judgeTask.id, compare);
296
+ if (isCompareStatus(compare, "cancelled")) {
297
+ return;
298
+ }
299
+
300
+ if (settledJudge.status === "succeeded") {
301
+ const parsed = parseJudgeAnalysis(settledJudge.stdout);
302
+ if (parsed.ok) {
303
+ compare.analysis = parsed.analysis;
304
+ this.pushEvent(compare, {
305
+ type: "judge-succeeded",
306
+ message: "Judge returned valid structured analysis.",
307
+ });
308
+ } else {
309
+ this.pushEvent(compare, {
310
+ type: "judge-degraded",
311
+ message: parsed.error,
312
+ });
313
+ }
314
+ } else {
315
+ this.pushEvent(compare, {
316
+ type: "judge-degraded",
317
+ message: `Judge task ended with status ${settledJudge.status}.`,
318
+ });
319
+ }
320
+
321
+ compare.status = "synthesizing";
322
+ this.touch(compare);
323
+
324
+ const synthesizerHarness = await this.resolveHarness(
325
+ input.synthesizerHarnessId ?? input.judgeHarnessId,
326
+ );
327
+ if (isCompareStatus(compare, "cancelled")) {
328
+ return;
329
+ }
330
+
331
+ const synthesizerPrompt = buildSynthesizerPrompt(
332
+ input.prompt,
333
+ compare.candidates,
334
+ compare.analysis,
335
+ );
336
+ const synthesizerTask = this.taskManager.start(
337
+ synthesizerHarness.adapter,
338
+ synthesizerHarness.discovery,
339
+ {
340
+ prompt: synthesizerPrompt,
341
+ cwd: input.cwd,
342
+ model: input.synthesizerModel ?? input.judgeModel,
343
+ timeoutMs: input.timeoutMs,
344
+ },
345
+ ).task;
346
+
347
+ compare.synthesizerTaskId = synthesizerTask.id;
348
+ this.pushEvent(compare, {
349
+ type: "synthesizer-started",
350
+ message: `Synthesizer task started through ${synthesizerTask.harnessName}.`,
351
+ });
352
+
353
+ const settledSynthesizer = await this.waitForTask(
354
+ synthesizerTask.id,
355
+ compare,
356
+ );
357
+ if (isCompareStatus(compare, "cancelled")) {
358
+ return;
359
+ }
360
+
361
+ if (settledSynthesizer.status !== "succeeded") {
362
+ throw new Error(
363
+ `Synthesizer task ended with status ${settledSynthesizer.status}.`,
364
+ );
365
+ }
366
+
367
+ compare.synthesis = {
368
+ text: settledSynthesizer.stdout.trim(),
369
+ taskId: settledSynthesizer.id,
370
+ };
371
+ compare.status = "succeeded";
372
+ compare.endedAtMs = Date.now();
373
+ this.pushEvent(compare, {
374
+ type: "synthesizer-succeeded",
375
+ message: "Synthesizer returned the final answer.",
376
+ });
377
+ } catch (error) {
378
+ if (compare.status !== "cancelled") {
379
+ compare.status = "failed";
380
+ compare.endedAtMs = Date.now();
381
+ this.pushEvent(compare, {
382
+ type: "failed",
383
+ message: error instanceof Error ? error.message : String(error),
384
+ });
385
+ }
386
+ } finally {
387
+ this.touch(compare);
388
+ }
389
+ }
390
+
391
+ private collectCandidates(input: CompareStartInput): CompareCandidate[] {
392
+ const candidates: CompareCandidate[] = [];
393
+ const seen = new Set<string>();
394
+ const taskOptions: TaskViewOptions = {
395
+ includeOutput: true,
396
+ includeEvents: false,
397
+ maxOutputChars: input.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS,
398
+ };
399
+
400
+ for (const taskId of input.taskIds ?? []) {
401
+ const task = this.taskManager.get(taskId, taskOptions);
402
+ if (!task) {
403
+ throw new Error(`Unknown task: ${taskId}`);
404
+ }
405
+
406
+ if (task.status === "running") {
407
+ throw new Error(`Task is still running: ${taskId}`);
408
+ }
409
+
410
+ const id = `task:${task.id}`;
411
+ if (seen.has(id)) {
412
+ continue;
413
+ }
414
+
415
+ seen.add(id);
416
+ candidates.push(candidateFromTask(task));
417
+ }
418
+
419
+ for (const response of input.responses ?? []) {
420
+ const id = response.id.trim();
421
+ if (!id) {
422
+ throw new Error("Compare response IDs cannot be empty.");
423
+ }
424
+
425
+ if (seen.has(id)) {
426
+ throw new Error(`Duplicate compare candidate ID: ${id}`);
427
+ }
428
+
429
+ seen.add(id);
430
+ candidates.push({
431
+ id,
432
+ label: response.label,
433
+ content: response.text.trim(),
434
+ metadata: response.metadata,
435
+ });
436
+ }
437
+
438
+ return candidates.filter((candidate) => candidate.content.length > 0);
439
+ }
440
+
441
+ private async waitForTask(
442
+ taskId: string,
443
+ compare: InternalCompare,
444
+ ): Promise<TaskView> {
445
+ while (true) {
446
+ const task = this.taskManager.get(taskId, {
447
+ includeOutput: true,
448
+ includeEvents: false,
449
+ });
450
+
451
+ if (!task) {
452
+ throw new Error(`Compare child task disappeared: ${taskId}`);
453
+ }
454
+
455
+ if (compare.status === "cancelled") {
456
+ return task;
457
+ }
458
+
459
+ if (task.status !== "running") {
460
+ // TaskManager guarantees terminal status only after output drains or times out visibly.
461
+ return task;
462
+ }
463
+
464
+ await sleep(POLL_MS);
465
+ this.touch(compare);
466
+ }
467
+ }
468
+
469
+ private toView(
470
+ compare: InternalCompare,
471
+ options: CompareViewOptions = {},
472
+ ): CompareView {
473
+ const now = Date.now();
474
+ const end = compare.endedAtMs ?? now;
475
+ const activeTask = this.activeTask(compare);
476
+ const includeCandidates = options.includeCandidates ?? true;
477
+ const includeEvents = options.includeEvents ?? true;
478
+ const maxCandidateChars =
479
+ options.maxCandidateChars ?? DEFAULT_MAX_CANDIDATE_CHARS;
480
+ const maxEvents = options.maxEvents ?? DEFAULT_MAX_EVENTS;
481
+
482
+ return {
483
+ id: compare.id,
484
+ status: compare.status,
485
+ promptPreview: preview(compare.prompt),
486
+ createdAt: new Date(compare.createdAtMs).toISOString(),
487
+ updatedAt: new Date(compare.updatedAtMs).toISOString(),
488
+ endedAt: compare.endedAtMs
489
+ ? new Date(compare.endedAtMs).toISOString()
490
+ : undefined,
491
+ elapsedMs: Math.max(0, end - compare.createdAtMs),
492
+ candidateCount: compare.candidates.length,
493
+ candidates: includeCandidates
494
+ ? compare.candidates.map((candidate) =>
495
+ truncateCandidate(candidate, maxCandidateChars),
496
+ )
497
+ : [],
498
+ judgeTaskId: compare.judgeTaskId,
499
+ synthesizerTaskId: compare.synthesizerTaskId,
500
+ activeTaskId: activeTask?.id,
501
+ remainingMs: activeTask?.remainingMs ?? (compare.endedAtMs ? 0 : null),
502
+ etaConfidence: activeTask?.etaConfidence ?? (
503
+ compare.endedAtMs ? "complete" : "unknown"
504
+ ),
505
+ analysis: compare.analysis,
506
+ analysisAvailable: Boolean(compare.analysis),
507
+ synthesis: compare.synthesis,
508
+ events: includeEvents ? tailItems(compare.events, maxEvents) : [],
509
+ };
510
+ }
511
+
512
+ private activeTask(compare: InternalCompare): TaskView | undefined {
513
+ const activeTaskId =
514
+ compare.status === "synthesizing"
515
+ ? compare.synthesizerTaskId
516
+ : compare.status === "judging"
517
+ ? compare.judgeTaskId
518
+ : undefined;
519
+
520
+ return activeTaskId
521
+ ? this.taskManager.get(activeTaskId, {
522
+ includeOutput: false,
523
+ includeEvents: false,
524
+ })
525
+ : undefined;
526
+ }
527
+
528
+ private requireCompare(id: string): InternalCompare {
529
+ const compare = this.compares.get(id);
530
+ if (!compare) {
531
+ throw new Error(`Unknown compare: ${id}`);
532
+ }
533
+
534
+ return compare;
535
+ }
536
+
537
+ private cancelCompare(compare: InternalCompare, message: string): void {
538
+ compare.status = "cancelled";
539
+ compare.endedAtMs = Date.now();
540
+ this.pushEvent(compare, { type: "cancelled", message });
541
+ }
542
+
543
+ private pushEvent(
544
+ compare: InternalCompare,
545
+ event: Omit<CompareEvent, "at">,
546
+ ): void {
547
+ compare.events.push({
548
+ at: new Date().toISOString(),
549
+ ...event,
550
+ });
551
+ this.touch(compare);
552
+ }
553
+
554
+ private touch(compare: InternalCompare): void {
555
+ compare.updatedAtMs = Date.now();
556
+ }
557
+ }
558
+
559
+ export function buildJudgePrompt(
560
+ originalPrompt: string,
561
+ candidates: CompareCandidate[],
562
+ ): string {
563
+ return [
564
+ "ENNODIA_COMPARE_JUDGE",
565
+ "",
566
+ "You are the judge in an AI orchestration pipeline.",
567
+ "Compare the candidate responses. Do not write the final answer.",
568
+ "Return only valid JSON matching this schema:",
569
+ JSON.stringify(schemaExample(), null, 2),
570
+ "",
571
+ "Rules:",
572
+ "- Treat agreement across independent candidates as stronger signal, but not as proof.",
573
+ "- Surface contradictions and partial coverage clearly.",
574
+ "- Preserve unique insights with source IDs.",
575
+ "- Flag blind spots none of the candidates covered.",
576
+ "- Do not invent sources or claims.",
577
+ "",
578
+ "Original prompt:",
579
+ originalPrompt,
580
+ "",
581
+ "Candidate responses:",
582
+ renderCandidates(candidates),
583
+ ].join("\n");
584
+ }
585
+
586
+ export function buildSynthesizerPrompt(
587
+ originalPrompt: string,
588
+ candidates: CompareCandidate[],
589
+ analysis?: CompareAnalysis,
590
+ ): string {
591
+ return [
592
+ "ENNODIA_COMPARE_SYNTHESIZER",
593
+ "",
594
+ "You are the synthesizer in an AI orchestration pipeline.",
595
+ "Write the final answer for the user using the judge analysis and the candidate responses.",
596
+ "If judge analysis is missing, degrade gracefully and use the raw candidate responses.",
597
+ "Be explicit about uncertainty, contradictions, and missing coverage.",
598
+ "When a non-obvious claim depends on one candidate, mention its source ID inline.",
599
+ "",
600
+ "Original prompt:",
601
+ originalPrompt,
602
+ "",
603
+ "Judge analysis JSON:",
604
+ analysis ? JSON.stringify(analysis, null, 2) : "null",
605
+ "",
606
+ "Candidate responses:",
607
+ renderCandidates(candidates),
608
+ ].join("\n");
609
+ }
610
+
611
+ export function parseJudgeAnalysis(
612
+ text: string,
613
+ ): { ok: true; analysis: CompareAnalysis } | { ok: false; error: string } {
614
+ const jsonText = extractJsonObject(text);
615
+ if (!jsonText) {
616
+ return { ok: false, error: "Judge output did not contain a JSON object." };
617
+ }
618
+
619
+ try {
620
+ const parsed = JSON.parse(jsonText) as unknown;
621
+ return { ok: true, analysis: CompareAnalysisSchema.parse(parsed) };
622
+ } catch (error) {
623
+ return {
624
+ ok: false,
625
+ error: error instanceof Error ? error.message : String(error),
626
+ };
627
+ }
628
+ }
629
+
630
+ function candidateFromTask(task: TaskView): CompareCandidate {
631
+ const content = `${task.stdout.trim()}\n${task.stderr.trim()}`.trim();
632
+
633
+ return {
634
+ id: `task:${task.id}`,
635
+ label: task.harnessName,
636
+ content,
637
+ taskId: task.id,
638
+ harnessId: task.harnessId,
639
+ harnessName: task.harnessName,
640
+ status: task.status,
641
+ elapsedMs: task.elapsedMs,
642
+ metadata: {
643
+ exitCode: task.exitCode,
644
+ timedOut: task.timedOut,
645
+ drainTimedOut: task.drainTimedOut,
646
+ },
647
+ };
648
+ }
649
+
650
+ function renderCandidates(candidates: CompareCandidate[]): string {
651
+ return candidates
652
+ .map((candidate) =>
653
+ [
654
+ `<candidate id="${escapeAttribute(candidate.id)}" label="${escapeAttribute(
655
+ candidate.label ?? candidate.id,
656
+ )}">`,
657
+ truncate(candidate.content, MAX_PROMPT_CANDIDATE_CHARS),
658
+ "</candidate>",
659
+ ].join("\n"),
660
+ )
661
+ .join("\n\n");
662
+ }
663
+
664
+ function schemaExample() {
665
+ return {
666
+ consensus: ["Points all or most candidates agree on."],
667
+ contradictions: [
668
+ {
669
+ topic: "What the disagreement is about.",
670
+ stances: [
671
+ {
672
+ source_id: "candidate-id",
673
+ stance: "The candidate's position.",
674
+ },
675
+ ],
676
+ },
677
+ ],
678
+ partial_coverage: [
679
+ {
680
+ source_ids: ["candidate-id"],
681
+ point: "Important point only some candidates covered.",
682
+ },
683
+ ],
684
+ unique_insights: [
685
+ {
686
+ source_id: "candidate-id",
687
+ insight: "Useful insight only this candidate raised.",
688
+ },
689
+ ],
690
+ blind_spots: ["Important topics no candidate addressed."],
691
+ risks: ["Risks, caveats, or weak evidence in the candidate set."],
692
+ confidence: "low | medium | high",
693
+ };
694
+ }
695
+
696
+ function extractJsonObject(text: string): string | undefined {
697
+ const withoutFence = text
698
+ .replace(/```json\s*/gi, "")
699
+ .replace(/```\s*/g, "")
700
+ .trim();
701
+ const start = withoutFence.indexOf("{");
702
+ const end = withoutFence.lastIndexOf("}");
703
+
704
+ if (start === -1 || end === -1 || end <= start) {
705
+ return undefined;
706
+ }
707
+
708
+ return withoutFence.slice(start, end + 1);
709
+ }
710
+
711
+ function truncateCandidate(
712
+ candidate: CompareCandidate,
713
+ maxChars: number,
714
+ ): CompareCandidate {
715
+ return {
716
+ ...candidate,
717
+ content: truncate(candidate.content, maxChars),
718
+ };
719
+ }
720
+
721
+ function truncate(text: string, maxChars: number): string {
722
+ if (maxChars <= 0) {
723
+ return "";
724
+ }
725
+
726
+ if (text.length <= maxChars) {
727
+ return text;
728
+ }
729
+
730
+ if (maxChars <= 3) {
731
+ return ".".repeat(maxChars);
732
+ }
733
+
734
+ return `${text.slice(0, maxChars - 3)}...`;
735
+ }
736
+
737
+ function tailItems<T>(items: T[], maxItems: number): T[] {
738
+ if (maxItems <= 0) {
739
+ return [];
740
+ }
741
+
742
+ return items.slice(-maxItems);
743
+ }
744
+
745
+ function preview(text: string): string {
746
+ const clean = text.replace(/\s+/g, " ").trim();
747
+ return clean.length <= 160 ? clean : `${clean.slice(0, 157)}...`;
748
+ }
749
+
750
+ function escapeAttribute(text: string): string {
751
+ return text.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
752
+ }
753
+
754
+ function sleep(ms: number): Promise<void> {
755
+ return new Promise((resolve) => setTimeout(resolve, ms));
756
+ }
757
+
758
+ function isCompareStatus(
759
+ compare: InternalCompare,
760
+ status: CompareStatus,
761
+ ): boolean {
762
+ return compare.status === status;
763
+ }
764
+
765
+ function isTerminalCompare(compare: InternalCompare): boolean {
766
+ return (
767
+ compare.status === "succeeded" ||
768
+ compare.status === "failed" ||
769
+ compare.status === "cancelled"
770
+ );
771
+ }
772
+
773
+ function compareTaskIds(compare: InternalCompare): string[] {
774
+ return [compare.judgeTaskId, compare.synthesizerTaskId].filter(
775
+ (taskId): taskId is string => typeof taskId === "string",
776
+ );
777
+ }