@dmsdc-ai/aigentry-deliberation 0.0.26 → 0.0.27

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 (2) hide show
  1. package/decision-engine.js +1006 -0
  2. package/package.json +2 -1
@@ -0,0 +1,1006 @@
1
+ /**
2
+ * Decision Engine — Stage-based state machine for structured multi-AI decision making.
3
+ *
4
+ * Implements a pipeline: intake → parallel_opinions → conflict_map → user_probe → synthesis → action_export → done
5
+ *
6
+ * Each stage enforces strict transitions. Multiple LLMs give independent parallel opinions,
7
+ * conflicts are mapped via MCDA score divergence, and the user resolves them before synthesis.
8
+ *
9
+ * Pure ESM, no external dependencies (Node.js built-in only: fs, path, crypto).
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto";
13
+ import { readFileSync } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ // ── Constants ────────────────────────────────────────────────────
18
+
19
+ /**
20
+ * Ordered stages of a decision session.
21
+ * @type {readonly string[]}
22
+ */
23
+ export const DECISION_STAGES = [
24
+ "intake", // Problem definition, options, criteria collection
25
+ "parallel_opinions", // All LLMs give independent opinions (no cross-visibility)
26
+ "conflict_map", // Extract disagreements between opinions
27
+ "user_probe", // Present conflicts to user, pause for input
28
+ "synthesis", // Combine user input + opinions into final decision
29
+ "action_export", // Convert decision to actionable output
30
+ "done",
31
+ ];
32
+
33
+ /**
34
+ * Valid stage transitions. Each key maps to the single allowed next stage.
35
+ * @type {Record<string, string>}
36
+ */
37
+ export const STAGE_TRANSITIONS = {
38
+ intake: "parallel_opinions",
39
+ parallel_opinions: "conflict_map",
40
+ conflict_map: "user_probe",
41
+ user_probe: "synthesis",
42
+ synthesis: "action_export",
43
+ action_export: "done",
44
+ };
45
+
46
+ /** Score divergence threshold to flag a criterion as a conflict (1-10 scale). */
47
+ const CONFLICT_DIVERGENCE_THRESHOLD = 3;
48
+
49
+ /** Maximum number of conflicts surfaced to the user to avoid overwhelm. */
50
+ const MAX_CONFLICTS = 5;
51
+
52
+ // ── Helpers ──────────────────────────────────────────────────────
53
+
54
+ const __dirname = dirname(fileURLToPath(import.meta.url));
55
+
56
+ /**
57
+ * Generate a slug from text: lowercase, strip non-alphanumeric, collapse dashes, truncate.
58
+ * @param {string} text
59
+ * @param {number} [maxLen=48]
60
+ * @returns {string}
61
+ */
62
+ function slugify(text, maxLen = 48) {
63
+ return (text || "decision")
64
+ .toLowerCase()
65
+ .replace(/[^a-z0-9]+/g, "-")
66
+ .replace(/^-+|-+$/g, "")
67
+ .slice(0, maxLen) || "decision";
68
+ }
69
+
70
+ /**
71
+ * ISO timestamp string.
72
+ * @returns {string}
73
+ */
74
+ function now() {
75
+ return new Date().toISOString();
76
+ }
77
+
78
+ // ── Core API ─────────────────────────────────────────────────────
79
+
80
+ /**
81
+ * @typedef {Object} ModelOpinion
82
+ * @property {string} speaker - LLM identifier
83
+ * @property {string} summary - 1-line conclusion
84
+ * @property {string} reasoning - Full reasoning text
85
+ * @property {Record<string, number>} scores - MCDA scores per criterion (1-10)
86
+ * @property {string} recommendation - Which option the model recommends
87
+ * @property {number} confidence - 0-1 confidence value
88
+ * @property {string} timestamp - ISO timestamp
89
+ */
90
+
91
+ /**
92
+ * @typedef {Object} ConflictItem
93
+ * @property {string} id - conflict-{index}
94
+ * @property {string} criterion - Which criterion the conflict is about
95
+ * @property {Record<string, string>} positions - speaker -> their position text
96
+ * @property {Record<string, number>} scores - speaker -> their score
97
+ * @property {number} divergence - Score spread (max - min)
98
+ * @property {string} question - Auto-generated clarifying question for user
99
+ */
100
+
101
+ /**
102
+ * @typedef {Object} ActionPlan
103
+ * @property {string} decision - The final decision
104
+ * @property {string} rationale - Why this decision was made
105
+ * @property {Array<{id: string, title: string, description: string, priority: string}>} actionItems
106
+ * @property {Array<{description: string, mitigation: string, probability: string}>} risks
107
+ * @property {{checklist: string, githubIssue: string}} exportFormats
108
+ */
109
+
110
+ /**
111
+ * @typedef {Object} DecisionSession
112
+ * @property {string} id
113
+ * @property {"decision"} type
114
+ * @property {string} stage
115
+ * @property {"active"|"completed"|"cancelled"} status
116
+ * @property {string} problem
117
+ * @property {string[]} options
118
+ * @property {string[]} criteria
119
+ * @property {string|null} template
120
+ * @property {string[]} speakers
121
+ * @property {Array} participant_profiles
122
+ * @property {Record<string, ModelOpinion>} opinions
123
+ * @property {ConflictItem[]} conflicts
124
+ * @property {Array} userProbeResponses
125
+ * @property {string|null} synthesis
126
+ * @property {ActionPlan|null} actionPlan
127
+ * @property {Array} log
128
+ * @property {{created: string, updated: string, participants: number, template: string|null}} metadata
129
+ */
130
+
131
+ /**
132
+ * Create a new decision session.
133
+ *
134
+ * @param {Object} params
135
+ * @param {string} params.problem - The decision question
136
+ * @param {string[]} [params.options] - Available choices (can be empty)
137
+ * @param {string[]} [params.criteria] - Evaluation criteria
138
+ * @param {string[]} params.speakers - Participating LLMs
139
+ * @param {string|null} [params.template] - Template name if used
140
+ * @param {Array} [params.participant_profiles] - Same format as deliberation
141
+ * @returns {DecisionSession}
142
+ */
143
+ export function createDecisionSession({
144
+ problem,
145
+ options = [],
146
+ criteria = [],
147
+ speakers = [],
148
+ template = null,
149
+ participant_profiles = [],
150
+ }) {
151
+ if (!problem || typeof problem !== "string") {
152
+ throw new Error("problem is required and must be a non-empty string");
153
+ }
154
+ if (!Array.isArray(speakers) || speakers.length === 0) {
155
+ throw new Error("speakers must be a non-empty array");
156
+ }
157
+
158
+ const id = `decision-${slugify(problem)}-${randomUUID().slice(0, 8)}`;
159
+ const timestamp = now();
160
+
161
+ return {
162
+ id,
163
+ type: "decision",
164
+ stage: "intake",
165
+ status: "active",
166
+ problem,
167
+ options: Array.isArray(options) ? [...options] : [],
168
+ criteria: Array.isArray(criteria) ? [...criteria] : [],
169
+ template: template || null,
170
+ speakers: [...speakers],
171
+ participant_profiles: Array.isArray(participant_profiles) ? [...participant_profiles] : [],
172
+ opinions: {},
173
+ conflicts: [],
174
+ userProbeResponses: [],
175
+ synthesis: null,
176
+ actionPlan: null,
177
+ log: [],
178
+ metadata: {
179
+ created: timestamp,
180
+ updated: timestamp,
181
+ participants: speakers.length,
182
+ template: template || null,
183
+ },
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Advance a decision session to the next stage.
189
+ *
190
+ * Validates the current stage has a valid transition, updates the session in place,
191
+ * appends a log entry, and returns the session.
192
+ *
193
+ * @param {DecisionSession} session
194
+ * @returns {DecisionSession} The updated session
195
+ * @throws {Error} If current stage has no valid transition or session is not active
196
+ */
197
+ export function advanceStage(session) {
198
+ if (!session || typeof session !== "object") {
199
+ throw new Error("session is required");
200
+ }
201
+ if (session.status !== "active") {
202
+ throw new Error(`Cannot advance: session status is "${session.status}", expected "active"`);
203
+ }
204
+
205
+ const nextStage = STAGE_TRANSITIONS[session.stage];
206
+ if (!nextStage) {
207
+ throw new Error(`No valid transition from stage "${session.stage}"`);
208
+ }
209
+
210
+ const prevStage = session.stage;
211
+ session.stage = nextStage;
212
+ session.metadata.updated = now();
213
+
214
+ if (nextStage === "done") {
215
+ session.status = "completed";
216
+ }
217
+
218
+ session.log.push({
219
+ event: "stage_transition",
220
+ from: prevStage,
221
+ to: nextStage,
222
+ timestamp: now(),
223
+ });
224
+
225
+ return session;
226
+ }
227
+
228
+ /**
229
+ * Build a conflict map from model opinions and evaluation criteria.
230
+ *
231
+ * For each criterion, collects scores from all opinions, calculates divergence,
232
+ * and flags criteria with divergence >= CONFLICT_DIVERGENCE_THRESHOLD as conflicts.
233
+ * Returns top MAX_CONFLICTS conflicts sorted by divergence descending.
234
+ *
235
+ * @param {Record<string, ModelOpinion>} opinions - speaker -> opinion
236
+ * @param {string[]} criteria - List of evaluation criteria
237
+ * @returns {ConflictItem[]}
238
+ */
239
+ export function buildConflictMap(opinions, criteria) {
240
+ if (!opinions || typeof opinions !== "object") return [];
241
+ if (!Array.isArray(criteria) || criteria.length === 0) return [];
242
+
243
+ const speakerNames = Object.keys(opinions);
244
+ if (speakerNames.length < 2) return [];
245
+
246
+ const conflicts = [];
247
+
248
+ for (const criterion of criteria) {
249
+ const scores = {};
250
+ const positions = {};
251
+ let hasScores = false;
252
+
253
+ for (const speaker of speakerNames) {
254
+ const opinion = opinions[speaker];
255
+ if (!opinion) continue;
256
+
257
+ // Collect score for this criterion
258
+ const score = opinion.scores?.[criterion];
259
+ if (typeof score === "number" && score >= 1 && score <= 10) {
260
+ scores[speaker] = score;
261
+ hasScores = true;
262
+ }
263
+
264
+ // Extract position text: use reasoning or summary as fallback
265
+ positions[speaker] = extractPositionForCriterion(opinion, criterion);
266
+ }
267
+
268
+ if (!hasScores) continue;
269
+
270
+ const scoreValues = Object.values(scores);
271
+ if (scoreValues.length < 2) continue;
272
+
273
+ const maxScore = Math.max(...scoreValues);
274
+ const minScore = Math.min(...scoreValues);
275
+ const divergence = maxScore - minScore;
276
+
277
+ if (divergence >= CONFLICT_DIVERGENCE_THRESHOLD) {
278
+ // Find the speakers at max and min for the question
279
+ const maxSpeaker = speakerNames.find(s => scores[s] === maxScore) || speakerNames[0];
280
+ const minSpeaker = speakerNames.find(s => scores[s] === minScore) || speakerNames[1];
281
+
282
+ const question =
283
+ `Models disagree on "${criterion}": ` +
284
+ `${maxSpeaker} says "${truncate(positions[maxSpeaker], 80)}" (score: ${scores[maxSpeaker]}), ` +
285
+ `${minSpeaker} says "${truncate(positions[minSpeaker], 80)}" (score: ${scores[minSpeaker]}). ` +
286
+ `Which perspective aligns more with your priorities?`;
287
+
288
+ conflicts.push({
289
+ id: `conflict-${conflicts.length}`,
290
+ criterion,
291
+ positions,
292
+ scores,
293
+ divergence,
294
+ question,
295
+ });
296
+ }
297
+ }
298
+
299
+ // Sort by divergence descending, cap at MAX_CONFLICTS
300
+ conflicts.sort((a, b) => b.divergence - a.divergence);
301
+ const topConflicts = conflicts.slice(0, MAX_CONFLICTS);
302
+
303
+ // Re-index IDs after sorting/slicing
304
+ for (let i = 0; i < topConflicts.length; i++) {
305
+ topConflicts[i].id = `conflict-${i}`;
306
+ }
307
+
308
+ return topConflicts;
309
+ }
310
+
311
+ /**
312
+ * Extract a speaker's position text for a given criterion from their opinion.
313
+ * Searches reasoning for a sentence mentioning the criterion, falls back to summary.
314
+ *
315
+ * @param {ModelOpinion} opinion
316
+ * @param {string} criterion
317
+ * @returns {string}
318
+ */
319
+ function extractPositionForCriterion(opinion, criterion) {
320
+ if (!opinion) return "(no opinion)";
321
+
322
+ const reasoning = opinion.reasoning || "";
323
+ const criterionLower = criterion.toLowerCase();
324
+
325
+ // Try to find a sentence in reasoning that mentions the criterion
326
+ const sentences = reasoning.split(/[.!?]\s+/);
327
+ for (const sentence of sentences) {
328
+ if (sentence.toLowerCase().includes(criterionLower)) {
329
+ return sentence.trim();
330
+ }
331
+ }
332
+
333
+ // Fall back to summary
334
+ return opinion.summary || opinion.recommendation || "(no position stated)";
335
+ }
336
+
337
+ /**
338
+ * Truncate text to maxLen, appending "..." if truncated.
339
+ * @param {string} text
340
+ * @param {number} maxLen
341
+ * @returns {string}
342
+ */
343
+ function truncate(text, maxLen) {
344
+ if (!text) return "";
345
+ if (text.length <= maxLen) return text;
346
+ return text.slice(0, maxLen - 3) + "...";
347
+ }
348
+
349
+ /**
350
+ * Parse a raw LLM response into a structured ModelOpinion.
351
+ *
352
+ * Handles both structured (markdown headers + score lines) and unstructured responses.
353
+ * Looks for "## Summary", "## Recommendation", score patterns "Criterion: N/10", confidence "Confidence: X%".
354
+ *
355
+ * @param {string} speaker - Speaker identifier
356
+ * @param {string} content - Raw LLM response text
357
+ * @param {string[]} criteria - Expected criteria list
358
+ * @returns {ModelOpinion}
359
+ */
360
+ export function parseOpinionFromResponse(speaker, content, criteria) {
361
+ if (!content || typeof content !== "string") {
362
+ return {
363
+ speaker,
364
+ summary: "(empty response)",
365
+ reasoning: "",
366
+ scores: {},
367
+ recommendation: "",
368
+ confidence: 0.5,
369
+ timestamp: now(),
370
+ };
371
+ }
372
+
373
+ const lines = content.split("\n");
374
+
375
+ // Extract sections by markdown headers
376
+ const sections = {};
377
+ let currentSection = "__preamble";
378
+ const sectionLines = { __preamble: [] };
379
+
380
+ for (const line of lines) {
381
+ const headerMatch = line.match(/^#{1,3}\s+(.+)/);
382
+ if (headerMatch) {
383
+ currentSection = headerMatch[1].trim().toLowerCase();
384
+ sectionLines[currentSection] = [];
385
+ } else {
386
+ if (!sectionLines[currentSection]) sectionLines[currentSection] = [];
387
+ sectionLines[currentSection].push(line);
388
+ }
389
+ }
390
+
391
+ for (const [key, value] of Object.entries(sectionLines)) {
392
+ sections[key] = value.join("\n").trim();
393
+ }
394
+
395
+ // Extract summary
396
+ const summary =
397
+ sections["summary"] ||
398
+ sections["recommendation"] ||
399
+ sections["conclusion"] ||
400
+ extractFirstNonEmpty(lines, 120);
401
+
402
+ // Extract recommendation
403
+ const recommendation =
404
+ sections["recommendation"] ||
405
+ sections["decision"] ||
406
+ sections["conclusion"] ||
407
+ "";
408
+
409
+ // Extract reasoning
410
+ const reasoning =
411
+ sections["reasoning"] ||
412
+ sections["analysis"] ||
413
+ sections["rationale"] ||
414
+ content;
415
+
416
+ // Extract scores per criterion
417
+ const scores = {};
418
+ if (Array.isArray(criteria)) {
419
+ for (const criterion of criteria) {
420
+ const score = extractScoreForCriterion(content, criterion);
421
+ if (score !== null) {
422
+ scores[criterion] = score;
423
+ }
424
+ }
425
+ }
426
+
427
+ // Extract confidence
428
+ const confidence = extractConfidence(content);
429
+
430
+ return {
431
+ speaker,
432
+ summary: truncate(summary, 200),
433
+ reasoning,
434
+ scores,
435
+ recommendation: truncate(recommendation, 200),
436
+ confidence,
437
+ timestamp: now(),
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Extract a numeric score (1-10) for a given criterion from text.
443
+ * Handles patterns: "Criterion: N/10", "Criterion: N", "- Criterion — N/10", etc.
444
+ *
445
+ * @param {string} text
446
+ * @param {string} criterion
447
+ * @returns {number|null}
448
+ */
449
+ function extractScoreForCriterion(text, criterion) {
450
+ if (!text || !criterion) return null;
451
+
452
+ // Escape special regex chars in criterion name
453
+ const escaped = criterion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
454
+
455
+ // Pattern variants:
456
+ // Criterion: 8/10
457
+ // Criterion: 8
458
+ // - Criterion — 8/10
459
+ // **Criterion**: 8
460
+ // | Criterion | 8 |
461
+ const patterns = [
462
+ new RegExp(`(?:^|[\\-*|])\\s*(?:\\*\\*)?${escaped}(?:\\*\\*)?\\s*[:—|]\\s*(\\d{1,2})(?:\\/10)?`, "im"),
463
+ new RegExp(`${escaped}[^\\d]{0,20}(\\d{1,2})(?:\\/10|\\s*(?:out of|of)\\s*10)?`, "im"),
464
+ ];
465
+
466
+ for (const pattern of patterns) {
467
+ const match = text.match(pattern);
468
+ if (match) {
469
+ const num = parseInt(match[1], 10);
470
+ if (num >= 1 && num <= 10) return num;
471
+ }
472
+ }
473
+
474
+ return null;
475
+ }
476
+
477
+ /**
478
+ * Extract confidence value (0-1) from text.
479
+ * Handles: "Confidence: 85%", "Confidence: 0.85", "confidence level: high"
480
+ *
481
+ * @param {string} text
482
+ * @returns {number}
483
+ */
484
+ function extractConfidence(text) {
485
+ if (!text) return 0.5;
486
+
487
+ // Percentage pattern: "Confidence: 85%" or "confidence level: 85%"
488
+ const pctMatch = text.match(/confidence[^:]*:\s*(\d{1,3})\s*%/i);
489
+ if (pctMatch) {
490
+ const pct = parseInt(pctMatch[1], 10);
491
+ if (pct >= 0 && pct <= 100) return pct / 100;
492
+ }
493
+
494
+ // Decimal pattern: "Confidence: 0.85"
495
+ const decMatch = text.match(/confidence[^:]*:\s*(0?\.\d+|1\.0?)/i);
496
+ if (decMatch) {
497
+ const val = parseFloat(decMatch[1]);
498
+ if (val >= 0 && val <= 1) return val;
499
+ }
500
+
501
+ // Keyword pattern
502
+ const lower = text.toLowerCase();
503
+ if (/confidence[^.]*\b(very\s+high|extremely\s+high)\b/i.test(lower)) return 0.95;
504
+ if (/confidence[^.]*\bhigh\b/i.test(lower)) return 0.85;
505
+ if (/confidence[^.]*\bmedium\b/i.test(lower)) return 0.65;
506
+ if (/confidence[^.]*\blow\b/i.test(lower)) return 0.35;
507
+ if (/confidence[^.]*\bvery\s+low\b/i.test(lower)) return 0.15;
508
+
509
+ return 0.5; // default mid-confidence
510
+ }
511
+
512
+ /**
513
+ * Return the first non-empty, non-header line from an array, truncated.
514
+ * @param {string[]} lines
515
+ * @param {number} maxLen
516
+ * @returns {string}
517
+ */
518
+ function extractFirstNonEmpty(lines, maxLen) {
519
+ for (const line of lines) {
520
+ const trimmed = line.trim();
521
+ if (trimmed && !trimmed.startsWith("#")) {
522
+ return truncate(trimmed, maxLen);
523
+ }
524
+ }
525
+ return "";
526
+ }
527
+
528
+ /**
529
+ * Build the prompt sent to each LLM for independent opinion extraction.
530
+ *
531
+ * The prompt clearly states the problem, lists options and criteria,
532
+ * and requests a structured response. Does NOT include any other model's opinion.
533
+ *
534
+ * @param {string} problem - The decision question
535
+ * @param {string[]} options - Available choices
536
+ * @param {string[]} criteria - Evaluation criteria
537
+ * @param {string|null} [template] - Template name (for context)
538
+ * @returns {string}
539
+ */
540
+ export function buildOpinionPrompt(problem, options, criteria, template = null) {
541
+ const parts = [];
542
+
543
+ parts.push("You are participating in a structured decision-making process.");
544
+ parts.push("Give your INDEPENDENT opinion. Do NOT reference other models or prior opinions.\n");
545
+
546
+ parts.push(`## Decision Problem\n${problem}\n`);
547
+
548
+ if (Array.isArray(options) && options.length > 0) {
549
+ parts.push("## Available Options");
550
+ for (let i = 0; i < options.length; i++) {
551
+ parts.push(`${i + 1}. ${options[i]}`);
552
+ }
553
+ parts.push("");
554
+ }
555
+
556
+ if (Array.isArray(criteria) && criteria.length > 0) {
557
+ parts.push("## Evaluation Criteria");
558
+ parts.push("Score each criterion from 1 (worst) to 10 (best) for your recommended option.\n");
559
+ for (const c of criteria) {
560
+ parts.push(`- ${c}`);
561
+ }
562
+ parts.push("");
563
+ }
564
+
565
+ if (template) {
566
+ parts.push(`*Using decision template: ${template}*\n`);
567
+ }
568
+
569
+ parts.push("## Required Response Format\n");
570
+ parts.push("Please structure your response with these sections:\n");
571
+ parts.push("### Summary");
572
+ parts.push("(1-2 sentence conclusion)\n");
573
+ parts.push("### Recommendation");
574
+ parts.push("(Which option you recommend and why)\n");
575
+
576
+ if (Array.isArray(criteria) && criteria.length > 0) {
577
+ parts.push("### Scores");
578
+ for (const c of criteria) {
579
+ parts.push(`- ${c}: ?/10`);
580
+ }
581
+ parts.push("");
582
+ }
583
+
584
+ parts.push("### Reasoning");
585
+ parts.push("(Detailed analysis supporting your recommendation)\n");
586
+ parts.push("### Confidence");
587
+ parts.push("(Your confidence level as a percentage, e.g., Confidence: 80%)");
588
+
589
+ return parts.join("\n");
590
+ }
591
+
592
+ /**
593
+ * Generate formatted markdown presenting conflicts to the user with numbered questions.
594
+ *
595
+ * @param {ConflictItem[]} conflicts
596
+ * @returns {string}
597
+ */
598
+ export function generateConflictQuestions(conflicts) {
599
+ if (!Array.isArray(conflicts) || conflicts.length === 0) {
600
+ return "No significant conflicts detected between model opinions. Proceeding to synthesis.";
601
+ }
602
+
603
+ const parts = [];
604
+ parts.push("# Decision Conflicts Requiring Your Input\n");
605
+ parts.push(`The participating models disagree on **${conflicts.length}** key area${conflicts.length > 1 ? "s" : ""}.\n`);
606
+ parts.push("Please review each conflict and share your perspective:\n");
607
+ parts.push("---\n");
608
+
609
+ for (let i = 0; i < conflicts.length; i++) {
610
+ const c = conflicts[i];
611
+ parts.push(`## Conflict ${i + 1}: ${c.criterion}`);
612
+ parts.push(`**Divergence score:** ${c.divergence}/10\n`);
613
+
614
+ // Show each speaker's position and score
615
+ const speakers = Object.keys(c.scores);
616
+ for (const speaker of speakers) {
617
+ const score = c.scores[speaker];
618
+ const position = c.positions?.[speaker] || "(no position)";
619
+ parts.push(`- **${speaker}** (score: ${score}/10): ${truncate(position, 150)}`);
620
+ }
621
+
622
+ parts.push("");
623
+ parts.push(`**Question ${i + 1}:** ${c.question}\n`);
624
+ parts.push("---\n");
625
+ }
626
+
627
+ parts.push("Please respond with your preference for each numbered question.");
628
+
629
+ return parts.join("\n");
630
+ }
631
+
632
+ /**
633
+ * Build a synthesis report from a complete decision session.
634
+ *
635
+ * Combines opinions, conflicts, and user probe responses into
636
+ * an executive summary, criteria breakdown, and conflict resolution summary.
637
+ *
638
+ * @param {DecisionSession} session
639
+ * @returns {string} Markdown synthesis report
640
+ */
641
+ export function buildSynthesis(session) {
642
+ if (!session || typeof session !== "object") {
643
+ return "Error: invalid session";
644
+ }
645
+
646
+ const parts = [];
647
+ const opinions = session.opinions || {};
648
+ const conflicts = session.conflicts || [];
649
+ const userResponses = session.userProbeResponses || [];
650
+ const speakerNames = Object.keys(opinions);
651
+
652
+ // ── Executive Summary ──
653
+ parts.push("# Decision Synthesis Report\n");
654
+ parts.push(`**Problem:** ${session.problem}\n`);
655
+
656
+ // Determine consensus recommendation
657
+ const recommendationCounts = {};
658
+ for (const opinion of Object.values(opinions)) {
659
+ const rec = opinion.recommendation || "(none)";
660
+ recommendationCounts[rec] = (recommendationCounts[rec] || 0) + 1;
661
+ }
662
+ const sortedRecs = Object.entries(recommendationCounts).sort((a, b) => b[1] - a[1]);
663
+ const topRec = sortedRecs[0];
664
+
665
+ if (topRec) {
666
+ const unanimity = topRec[1] === speakerNames.length ? "unanimous" : `${topRec[1]}/${speakerNames.length}`;
667
+ parts.push(`**Consensus recommendation:** ${topRec[0]} (${unanimity} agreement)\n`);
668
+ }
669
+
670
+ // Average confidence
671
+ const confidences = Object.values(opinions).map(o => o.confidence).filter(c => typeof c === "number");
672
+ if (confidences.length > 0) {
673
+ const avgConf = confidences.reduce((a, b) => a + b, 0) / confidences.length;
674
+ parts.push(`**Average confidence:** ${Math.round(avgConf * 100)}%\n`);
675
+ }
676
+
677
+ // ── Individual Summaries ──
678
+ parts.push("## Model Opinions\n");
679
+ for (const speaker of speakerNames) {
680
+ const op = opinions[speaker];
681
+ parts.push(`### ${speaker}`);
682
+ parts.push(`- **Recommendation:** ${op.recommendation || "(none)"}`);
683
+ parts.push(`- **Summary:** ${op.summary || "(none)"}`);
684
+ parts.push(`- **Confidence:** ${Math.round((op.confidence || 0.5) * 100)}%`);
685
+ parts.push("");
686
+ }
687
+
688
+ // ── Criteria Breakdown Table ──
689
+ if (Array.isArray(session.criteria) && session.criteria.length > 0 && speakerNames.length > 0) {
690
+ parts.push("## Criteria Scores\n");
691
+
692
+ // Table header
693
+ const header = `| Criterion | ${speakerNames.join(" | ")} | Avg |`;
694
+ const separator = `|${"-".repeat(11)}|${speakerNames.map(() => "-".repeat(7)).join("|")}|${"-".repeat(6)}|`;
695
+ parts.push(header);
696
+ parts.push(separator);
697
+
698
+ for (const criterion of session.criteria) {
699
+ const scores = speakerNames.map(s => opinions[s]?.scores?.[criterion]);
700
+ const validScores = scores.filter(s => typeof s === "number");
701
+ const avg = validScores.length > 0
702
+ ? (validScores.reduce((a, b) => a + b, 0) / validScores.length).toFixed(1)
703
+ : "—";
704
+ const row = `| ${truncate(criterion, 30)} | ${scores.map(s => (typeof s === "number" ? String(s) : "—")).join(" | ")} | ${avg} |`;
705
+ parts.push(row);
706
+ }
707
+ parts.push("");
708
+ }
709
+
710
+ // ── Conflict Resolution ──
711
+ if (conflicts.length > 0) {
712
+ parts.push("## Conflict Resolution\n");
713
+ for (let i = 0; i < conflicts.length; i++) {
714
+ const c = conflicts[i];
715
+ const userResponse = userResponses[i] || "(no response)";
716
+ parts.push(`### ${c.criterion} (divergence: ${c.divergence})`);
717
+ parts.push(`- **User's resolution:** ${userResponse}`);
718
+ parts.push("");
719
+ }
720
+ } else {
721
+ parts.push("## Conflicts\n");
722
+ parts.push("No significant conflicts were detected.\n");
723
+ }
724
+
725
+ return parts.join("\n");
726
+ }
727
+
728
+ /**
729
+ * Convert a decision session's synthesis into an actionable plan.
730
+ *
731
+ * @param {DecisionSession} session
732
+ * @returns {ActionPlan}
733
+ */
734
+ export function buildActionPlan(session) {
735
+ if (!session || typeof session !== "object") {
736
+ return {
737
+ decision: "(no session)",
738
+ rationale: "",
739
+ actionItems: [],
740
+ risks: [],
741
+ exportFormats: { checklist: "", githubIssue: "" },
742
+ };
743
+ }
744
+
745
+ const opinions = session.opinions || {};
746
+ const speakerNames = Object.keys(opinions);
747
+
748
+ // Determine the winning recommendation
749
+ const recommendationCounts = {};
750
+ for (const opinion of Object.values(opinions)) {
751
+ const rec = opinion.recommendation || "";
752
+ if (rec) recommendationCounts[rec] = (recommendationCounts[rec] || 0) + 1;
753
+ }
754
+ const sortedRecs = Object.entries(recommendationCounts).sort((a, b) => b[1] - a[1]);
755
+ const decision = sortedRecs[0]?.[0] || session.problem;
756
+
757
+ // Build rationale from consensus + user input
758
+ const rationale = buildRationale(session, decision);
759
+
760
+ // Generate action items from the decision and criteria
761
+ const actionItems = generateActionItems(session, decision);
762
+
763
+ // Generate risks from low-scored criteria and conflicts
764
+ const risks = generateRisks(session);
765
+
766
+ // Export formats
767
+ const checklist = buildChecklist(decision, actionItems);
768
+ const githubIssue = buildGithubIssue(session, decision, rationale, actionItems, risks);
769
+
770
+ return {
771
+ decision,
772
+ rationale,
773
+ actionItems,
774
+ risks,
775
+ exportFormats: {
776
+ checklist,
777
+ githubIssue,
778
+ },
779
+ };
780
+ }
781
+
782
+ /**
783
+ * Build rationale text from session data.
784
+ * @param {DecisionSession} session
785
+ * @param {string} decision
786
+ * @returns {string}
787
+ */
788
+ function buildRationale(session, decision) {
789
+ const parts = [];
790
+ const opinions = session.opinions || {};
791
+ const speakerNames = Object.keys(opinions);
792
+
793
+ // Count agreement
794
+ const agreeing = speakerNames.filter(s => {
795
+ const rec = opinions[s]?.recommendation || "";
796
+ return rec.toLowerCase().includes(decision.toLowerCase().slice(0, 20));
797
+ });
798
+
799
+ if (agreeing.length > 0) {
800
+ parts.push(`${agreeing.length}/${speakerNames.length} models recommended this approach.`);
801
+ }
802
+
803
+ // User resolutions
804
+ if (session.userProbeResponses && session.userProbeResponses.length > 0) {
805
+ parts.push("User input was incorporated for conflict resolution.");
806
+ }
807
+
808
+ // Average confidence
809
+ const confidences = Object.values(opinions).map(o => o.confidence).filter(c => typeof c === "number");
810
+ if (confidences.length > 0) {
811
+ const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length;
812
+ parts.push(`Average model confidence: ${Math.round(avg * 100)}%.`);
813
+ }
814
+
815
+ return parts.join(" ") || "Decision based on multi-model deliberation.";
816
+ }
817
+
818
+ /**
819
+ * Generate action items from session data.
820
+ * @param {DecisionSession} session
821
+ * @param {string} decision
822
+ * @returns {Array<{id: string, title: string, description: string, priority: string}>}
823
+ */
824
+ function generateActionItems(session, decision) {
825
+ const items = [];
826
+
827
+ // Primary action: implement the decision
828
+ items.push({
829
+ id: "action-0",
830
+ title: `Implement: ${truncate(decision, 60)}`,
831
+ description: `Execute the decided approach: ${decision}`,
832
+ priority: "high",
833
+ });
834
+
835
+ // For each criterion, create a monitoring/validation action
836
+ const criteria = session.criteria || [];
837
+ for (let i = 0; i < criteria.length && i < 5; i++) {
838
+ items.push({
839
+ id: `action-${i + 1}`,
840
+ title: `Validate: ${truncate(criteria[i], 60)}`,
841
+ description: `Ensure the implementation satisfies the "${criteria[i]}" criterion`,
842
+ priority: i < 2 ? "medium" : "low",
843
+ });
844
+ }
845
+
846
+ return items;
847
+ }
848
+
849
+ /**
850
+ * Generate risk items from low scores and conflicts.
851
+ * @param {DecisionSession} session
852
+ * @returns {Array<{description: string, mitigation: string, probability: string}>}
853
+ */
854
+ function generateRisks(session) {
855
+ const risks = [];
856
+ const opinions = session.opinions || {};
857
+ const conflicts = session.conflicts || [];
858
+ const criteria = session.criteria || [];
859
+
860
+ // Risks from high-divergence conflicts
861
+ for (const conflict of conflicts) {
862
+ risks.push({
863
+ description: `Disagreement on "${conflict.criterion}" (divergence: ${conflict.divergence}/10)`,
864
+ mitigation: `Monitor and re-evaluate if assumptions about "${conflict.criterion}" change`,
865
+ probability: conflict.divergence >= 6 ? "high" : "medium",
866
+ });
867
+ }
868
+
869
+ // Risks from criteria with low average scores
870
+ for (const criterion of criteria) {
871
+ const scores = Object.values(opinions)
872
+ .map(o => o.scores?.[criterion])
873
+ .filter(s => typeof s === "number");
874
+ if (scores.length === 0) continue;
875
+
876
+ const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
877
+ if (avg <= 4) {
878
+ risks.push({
879
+ description: `Low confidence in "${criterion}" (avg score: ${avg.toFixed(1)}/10)`,
880
+ mitigation: `Investigate alternatives or mitigations for "${criterion}"`,
881
+ probability: avg <= 2 ? "high" : "medium",
882
+ });
883
+ }
884
+ }
885
+
886
+ return risks.slice(0, 8); // Cap at 8 risks
887
+ }
888
+
889
+ /**
890
+ * Build a markdown checklist from action items.
891
+ * @param {string} decision
892
+ * @param {Array<{id: string, title: string, description: string, priority: string}>} actionItems
893
+ * @returns {string}
894
+ */
895
+ function buildChecklist(decision, actionItems) {
896
+ const parts = [];
897
+ parts.push(`# Decision Checklist: ${truncate(decision, 60)}\n`);
898
+ for (const item of actionItems) {
899
+ const priorityTag = item.priority === "high" ? " [HIGH]" : item.priority === "medium" ? " [MED]" : "";
900
+ parts.push(`- [ ]${priorityTag} ${item.title}`);
901
+ if (item.description) {
902
+ parts.push(` - ${item.description}`);
903
+ }
904
+ }
905
+ return parts.join("\n");
906
+ }
907
+
908
+ /**
909
+ * Build a GitHub issue body from session data.
910
+ * @param {DecisionSession} session
911
+ * @param {string} decision
912
+ * @param {string} rationale
913
+ * @param {Array} actionItems
914
+ * @param {Array} risks
915
+ * @returns {string}
916
+ */
917
+ function buildGithubIssue(session, decision, rationale, actionItems, risks) {
918
+ const parts = [];
919
+
920
+ parts.push(`## Decision: ${decision}\n`);
921
+ parts.push(`**Problem:** ${session.problem}\n`);
922
+ parts.push(`**Rationale:** ${rationale}\n`);
923
+
924
+ if (actionItems.length > 0) {
925
+ parts.push("### Action Items\n");
926
+ for (const item of actionItems) {
927
+ parts.push(`- [ ] **[${item.priority.toUpperCase()}]** ${item.title}: ${item.description}`);
928
+ }
929
+ parts.push("");
930
+ }
931
+
932
+ if (risks.length > 0) {
933
+ parts.push("### Risks\n");
934
+ for (const risk of risks) {
935
+ parts.push(`- **[${risk.probability.toUpperCase()}]** ${risk.description}`);
936
+ parts.push(` - Mitigation: ${risk.mitigation}`);
937
+ }
938
+ parts.push("");
939
+ }
940
+
941
+ parts.push("---");
942
+ parts.push(`*Generated by aigentry-deliberation decision engine*`);
943
+
944
+ return parts.join("\n");
945
+ }
946
+
947
+ /**
948
+ * Load decision templates from the selectors directory.
949
+ * Returns an empty array if the file does not exist or is invalid.
950
+ *
951
+ * @returns {Array<{name: string, description: string, criteria: string[], options?: string[], keywords?: string[]}>}
952
+ */
953
+ export function loadTemplates() {
954
+ const templatePath = join(__dirname, "selectors", "decision-templates.json");
955
+ try {
956
+ const raw = readFileSync(templatePath, "utf-8");
957
+ const parsed = JSON.parse(raw);
958
+ return Array.isArray(parsed) ? parsed : [];
959
+ } catch {
960
+ return [];
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Match a template to a problem description using keyword matching.
966
+ *
967
+ * Scores each template by counting keyword hits in the problem text.
968
+ * Returns the best-matching template, or null if no template matches.
969
+ *
970
+ * @param {string} problemText - The decision problem description
971
+ * @param {Array<{name: string, keywords?: string[], description?: string}>} templates
972
+ * @returns {{name: string, description: string, criteria: string[], options?: string[], keywords?: string[]}|null}
973
+ */
974
+ export function matchTemplate(problemText, templates) {
975
+ if (!problemText || !Array.isArray(templates) || templates.length === 0) {
976
+ return null;
977
+ }
978
+
979
+ const lower = problemText.toLowerCase();
980
+ let bestMatch = null;
981
+ let bestScore = 0;
982
+
983
+ for (const template of templates) {
984
+ let score = 0;
985
+ const keywords = template.keywords || [];
986
+
987
+ for (const keyword of keywords) {
988
+ if (lower.includes(keyword.toLowerCase())) {
989
+ score++;
990
+ }
991
+ }
992
+
993
+ // Also match against template name and description
994
+ if (template.name && lower.includes(template.name.toLowerCase())) {
995
+ score += 2;
996
+ }
997
+
998
+ if (score > bestScore) {
999
+ bestScore = score;
1000
+ bestMatch = template;
1001
+ }
1002
+ }
1003
+
1004
+ // Require at least 1 keyword match
1005
+ return bestScore >= 1 ? bestMatch : null;
1006
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dmsdc-ai/aigentry-deliberation",
3
- "version": "0.0.26",
3
+ "version": "0.0.27",
4
4
  "description": "MCP Deliberation Server — Multi-session AI deliberation with smart speaker ordering and persona roles",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,7 @@
29
29
  },
30
30
  "files": [
31
31
  "index.js",
32
+ "decision-engine.js",
32
33
  "model-router.js",
33
34
  "install.js",
34
35
  "doctor.js",