clay-server 2.23.0 → 2.23.1-beta.2

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.
@@ -0,0 +1,1604 @@
1
+ var fs = require("fs");
2
+ var path = require("path");
3
+ var crypto = require("crypto");
4
+ var matesModule = require("./mates");
5
+
6
+ /**
7
+ * Attach debate engine to a project context.
8
+ *
9
+ * ctx fields:
10
+ * cwd, slug, send, sendTo, sendToSession, sm, sdk,
11
+ * getMateProfile, loadMateClaudeMd, loadMateDigests,
12
+ * hydrateImageRefs, onProcessingChanged, getLinuxUserForSession, getSessionForWs,
13
+ * updateMemorySummary, initMemorySummary, enqueueDigest
14
+ */
15
+ function attachDebate(ctx) {
16
+
17
+ // --- Helpers shared with other modules ---
18
+
19
+ function escapeRegex(str) {
20
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+ }
22
+
23
+ function buildDebateNameMap(panelists, mateCtx) {
24
+ var nameMap = {};
25
+ for (var i = 0; i < panelists.length; i++) {
26
+ var mate = matesModule.getMate(mateCtx, panelists[i].mateId);
27
+ if (!mate) continue;
28
+ var name = (mate.profile && mate.profile.displayName) || mate.name || "";
29
+ if (name) {
30
+ nameMap[name] = panelists[i].mateId;
31
+ }
32
+ }
33
+ return nameMap;
34
+ }
35
+
36
+ function detectMentions(text, nameMap) {
37
+ var names = Object.keys(nameMap);
38
+ // Sort by length descending to match longest name first
39
+ names.sort(function (a, b) { return b.length - a.length; });
40
+ var mentioned = [];
41
+ // Strip markdown inline formatting so **@Name**, ~~@Name~~, `@Name`, [@Name](url) etc. still match
42
+ var cleaned = text
43
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) -> text
44
+ .replace(/`([^`]*)`/g, "$1") // `code` -> code
45
+ .replace(/(\*{1,3}|_{1,3}|~{2})/g, ""); // bold, italic, strikethrough markers
46
+ console.log("[debate-mention] nameMap keys:", JSON.stringify(names));
47
+ console.log("[debate-mention] text snippet:", cleaned.slice(0, 200));
48
+ for (var i = 0; i < names.length; i++) {
49
+ // Match @Name followed by any non-name character (not alphanumeric, not Korean, not dash/underscore)
50
+ var pattern = new RegExp("@" + escapeRegex(names[i]) + "(?![\\p{L}\\p{N}_-])", "iu");
51
+ var matched = pattern.test(cleaned);
52
+ console.log("[debate-mention] testing @" + names[i] + " pattern=" + pattern.toString() + " matched=" + matched);
53
+ if (matched) {
54
+ var mateId = nameMap[names[i]];
55
+ if (mentioned.indexOf(mateId) === -1) {
56
+ mentioned.push(mateId);
57
+ }
58
+ }
59
+ }
60
+ return mentioned;
61
+ }
62
+
63
+ // --- Context builders ---
64
+
65
+ function buildModeratorContext(debate) {
66
+ var lines = [
67
+ "You are moderating a structured debate among your AI teammates.",
68
+ "",
69
+ "Topic: " + debate.topic,
70
+ "Format: " + debate.format,
71
+ "Context: " + debate.context,
72
+ ];
73
+ if (debate.specialRequests) {
74
+ lines.push("Special requests: " + debate.specialRequests);
75
+ }
76
+ lines.push("");
77
+ lines.push("Panelists:");
78
+ for (var i = 0; i < debate.panelists.length; i++) {
79
+ var p = debate.panelists[i];
80
+ var profile = ctx.getMateProfile(debate.mateCtx, p.mateId);
81
+ lines.push("- @" + profile.name + " (" + p.role + "): " + p.brief);
82
+ }
83
+ lines.push("");
84
+ lines.push("RULES:");
85
+ lines.push("1. To call on a panelist, mention them with @TheirName in your response.");
86
+ lines.push("2. Only mention ONE panelist per response. Wait for their answer before calling the next.");
87
+ lines.push("3. When you mention a panelist, clearly state what you want them to address.");
88
+ lines.push("4. After hearing from all panelists, you may start additional rounds.");
89
+ lines.push("5. When you believe the debate has reached a natural conclusion, provide a summary WITHOUT mentioning any panelist. A response with no @mention signals the end of the debate.");
90
+ lines.push("6. If the user interjects with a comment, acknowledge it and weave it into the discussion.");
91
+ lines.push("");
92
+ lines.push("Begin by introducing the topic and calling on the first panelist.");
93
+ return lines.join("\n");
94
+ }
95
+
96
+ function buildPanelistContext(debate, panelistInfo) {
97
+ var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId);
98
+ var lines = [
99
+ "You are participating in a structured debate as a panelist.",
100
+ "",
101
+ "Topic: " + debate.topic,
102
+ "Your role: " + panelistInfo.role,
103
+ "Your brief: " + panelistInfo.brief,
104
+ "",
105
+ "Other panelists:",
106
+ ];
107
+ for (var i = 0; i < debate.panelists.length; i++) {
108
+ var p = debate.panelists[i];
109
+ if (p.mateId === panelistInfo.mateId) continue;
110
+ var profile = ctx.getMateProfile(debate.mateCtx, p.mateId);
111
+ lines.push("- @" + profile.name + " (" + p.role + "): " + p.brief);
112
+ }
113
+ lines.push("");
114
+ lines.push("The moderator is @" + moderatorProfile.name + ". They will call on you when it is your turn.");
115
+ lines.push("");
116
+ lines.push("RULES:");
117
+ lines.push("1. Stay in your assigned role and perspective.");
118
+ lines.push("2. Respond to the specific question or prompt from the moderator.");
119
+ lines.push("3. You may reference what other panelists have said.");
120
+ lines.push("4. Keep responses focused and substantive. Do not ramble.");
121
+ lines.push("5. You have read-only access to project files if needed to support your arguments.");
122
+ return lines.join("\n");
123
+ }
124
+
125
+ function buildDebateToolHandler(session) {
126
+ return function (toolName, input, toolOpts) {
127
+ var autoAllow = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
128
+ if (autoAllow[toolName]) {
129
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
130
+ }
131
+ return Promise.resolve({
132
+ behavior: "deny",
133
+ message: "Read-only access during debate. You cannot make changes.",
134
+ });
135
+ };
136
+ }
137
+
138
+ // --- State persistence ---
139
+
140
+ function persistDebateState(session) {
141
+ if (!session._debate) return;
142
+ var d = session._debate;
143
+ session.debateState = {
144
+ phase: d.phase,
145
+ topic: d.topic,
146
+ format: d.format,
147
+ context: d.context || "",
148
+ specialRequests: d.specialRequests || null,
149
+ moderatorId: d.moderatorId,
150
+ panelists: d.panelists.map(function (p) {
151
+ return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" };
152
+ }),
153
+ briefPath: d.briefPath || null,
154
+ debateId: d.debateId || null,
155
+ setupSessionId: d.setupSessionId || null,
156
+ setupStartedAt: d.setupStartedAt || null,
157
+ round: d.round || 1,
158
+ awaitingConcludeConfirm: !!d.awaitingConcludeConfirm,
159
+ };
160
+ ctx.sm.saveSessionFile(session);
161
+ }
162
+
163
+ function restoreDebateFromState(session) {
164
+ var ds = session.debateState;
165
+ if (!ds) return null;
166
+ var userId = null; // Will be set when WS connects
167
+ var mateCtx = matesModule.buildMateCtx(userId);
168
+ var debate = {
169
+ phase: ds.phase,
170
+ topic: ds.topic,
171
+ format: ds.format,
172
+ context: ds.context || "",
173
+ specialRequests: ds.specialRequests || null,
174
+ moderatorId: ds.moderatorId,
175
+ panelists: ds.panelists || [],
176
+ mateCtx: mateCtx,
177
+ moderatorSession: null,
178
+ panelistSessions: {},
179
+ nameMap: buildDebateNameMap(ds.panelists || [], mateCtx),
180
+ turnInProgress: false,
181
+ pendingComment: null,
182
+ round: ds.round || 1,
183
+ history: [],
184
+ setupSessionId: ds.setupSessionId || null,
185
+ debateId: ds.debateId || null,
186
+ setupStartedAt: ds.setupStartedAt || null,
187
+ briefPath: ds.briefPath || null,
188
+ awaitingConcludeConfirm: !!ds.awaitingConcludeConfirm,
189
+ };
190
+
191
+ // Fallback: if awaitingConcludeConfirm was not persisted, detect from history
192
+ if (!debate.awaitingConcludeConfirm && ds.phase === "live") {
193
+ var hasEnded = false;
194
+ var hasConclude = false;
195
+ var lastModText = null;
196
+ for (var i = 0; i < session.history.length; i++) {
197
+ var h = session.history[i];
198
+ if (h.type === "debate_ended") hasEnded = true;
199
+ if (h.type === "debate_conclude_confirm") hasConclude = true;
200
+ if (h.type === "debate_turn_done" && h.role === "moderator") lastModText = h.text || "";
201
+ }
202
+ // conclude_confirm in history without a subsequent ended = still awaiting user decision
203
+ if (hasConclude && !hasEnded) {
204
+ debate.awaitingConcludeConfirm = true;
205
+ } else if (!hasEnded && !hasConclude && lastModText !== null) {
206
+ // No explicit entry yet; infer from last moderator text having no @mentions
207
+ var mentions = detectMentions(lastModText, debate.nameMap);
208
+ if (mentions.length === 0) {
209
+ debate.awaitingConcludeConfirm = true;
210
+ }
211
+ }
212
+ }
213
+
214
+ session._debate = debate;
215
+ return debate;
216
+ }
217
+
218
+ // --- Brief watcher ---
219
+
220
+ function startDebateBriefWatcher(session, debate, briefPath) {
221
+ if (!briefPath) {
222
+ console.error("[debate] No briefPath provided to watcher");
223
+ return;
224
+ }
225
+ // Persist briefPath on debate so restoration can reuse it
226
+ debate.briefPath = briefPath;
227
+ var watchDir = path.dirname(briefPath);
228
+ var briefFilename = path.basename(briefPath);
229
+
230
+ // Clean up any existing watcher
231
+ if (debate._briefWatcher) {
232
+ try { debate._briefWatcher.close(); } catch (e) {}
233
+ debate._briefWatcher = null;
234
+ }
235
+ if (debate._briefDebounce) {
236
+ clearTimeout(debate._briefDebounce);
237
+ debate._briefDebounce = null;
238
+ }
239
+
240
+ function checkDebateBrief() {
241
+ try {
242
+ var raw = fs.readFileSync(briefPath, "utf8");
243
+ var brief = JSON.parse(raw);
244
+
245
+ // Stop watching
246
+ if (debate._briefWatcher) { debate._briefWatcher.close(); debate._briefWatcher = null; }
247
+ if (debate._briefDebounce) { clearTimeout(debate._briefDebounce); debate._briefDebounce = null; }
248
+
249
+ // Clean up the brief file
250
+ try { fs.unlinkSync(briefPath); } catch (e) {}
251
+
252
+ // Apply brief to debate state
253
+ debate.topic = brief.topic || debate.topic;
254
+ debate.format = brief.format || debate.format;
255
+ debate.context = brief.context || "";
256
+ debate.specialRequests = brief.specialRequests || null;
257
+
258
+ // Update panelists with roles from the brief
259
+ if (brief.panelists && brief.panelists.length) {
260
+ for (var i = 0; i < brief.panelists.length; i++) {
261
+ var bp = brief.panelists[i];
262
+ for (var j = 0; j < debate.panelists.length; j++) {
263
+ if (debate.panelists[j].mateId === bp.mateId) {
264
+ debate.panelists[j].role = bp.role || "";
265
+ debate.panelists[j].brief = bp.brief || "";
266
+ }
267
+ }
268
+ }
269
+ }
270
+
271
+ // Rebuild name map with updated roles
272
+ var mateCtx = debate.mateCtx || matesModule.buildMateCtx(null);
273
+ debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx);
274
+
275
+ // If debate was started from DM (no setupSessionId), go to reviewing phase
276
+ if (!debate.setupSessionId) {
277
+ console.log("[debate] Brief picked up from DM, entering review phase. Topic:", debate.topic);
278
+ debate.phase = "reviewing";
279
+ persistDebateState(session);
280
+
281
+ var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId);
282
+ var briefReadyMsg = {
283
+ type: "debate_brief_ready",
284
+ debateId: debate.debateId,
285
+ topic: debate.topic,
286
+ format: debate.format || "free_discussion",
287
+ context: debate.context || "",
288
+ specialRequests: debate.specialRequests || null,
289
+ moderatorId: debate.moderatorId,
290
+ moderatorName: moderatorProfile.name,
291
+ panelists: debate.panelists.map(function (p) {
292
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
293
+ return { mateId: p.mateId, name: prof.name, role: p.role || "", brief: p.brief || "" };
294
+ }),
295
+ };
296
+ ctx.sendToSession(session.localId, briefReadyMsg);
297
+ } else {
298
+ console.log("[debate] Brief picked up, transitioning to live. Topic:", debate.topic);
299
+ // Transition to live (standard flow via modal/skill)
300
+ startDebateLive(session);
301
+ }
302
+ } catch (e) {
303
+ // File not ready yet or invalid JSON, keep watching
304
+ }
305
+ }
306
+
307
+ try {
308
+ try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
309
+ debate._briefWatcher = fs.watch(watchDir, function (eventType, filename) {
310
+ if (filename === briefFilename) {
311
+ if (debate._briefDebounce) clearTimeout(debate._briefDebounce);
312
+ debate._briefDebounce = setTimeout(checkDebateBrief, 300);
313
+ }
314
+ });
315
+ debate._briefWatcher.on("error", function () {});
316
+ console.log("[debate] Watching for " + briefFilename + " at " + watchDir);
317
+ } catch (e) {
318
+ console.error("[debate] Failed to watch " + watchDir + ":", e.message);
319
+ }
320
+
321
+ // Check immediately in case the file already exists (server restart scenario)
322
+ checkDebateBrief();
323
+ }
324
+
325
+ // --- Restore debate on reconnect ---
326
+
327
+ function restoreDebateState(ws) {
328
+ var userId = ws._clayUser ? ws._clayUser.id : null;
329
+ var mateCtx = matesModule.buildMateCtx(userId);
330
+
331
+ ctx.sm.sessions.forEach(function (session) {
332
+ // Already restored
333
+ if (session._debate) return;
334
+
335
+ // Has persisted debate state?
336
+ if (!session.debateState) return;
337
+
338
+ var phase = session.debateState.phase;
339
+ if (phase !== "preparing" && phase !== "reviewing" && phase !== "live") return;
340
+
341
+ // Restore _debate from persisted state
342
+ var debate = restoreDebateFromState(session);
343
+ if (!debate) return;
344
+
345
+ // Update mateCtx with the connected user's context
346
+ debate.mateCtx = mateCtx;
347
+ debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx);
348
+
349
+ var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId);
350
+
351
+ if (phase === "preparing") {
352
+ var briefPath = debate.briefPath;
353
+ if (!briefPath && debate.debateId) {
354
+ briefPath = path.join(ctx.cwd, ".clay", "debates", debate.debateId, "brief.json");
355
+ }
356
+ if (!briefPath) return;
357
+
358
+ console.log("[debate] Restoring debate (preparing). topic:", debate.topic, "briefPath:", briefPath);
359
+ startDebateBriefWatcher(session, debate, briefPath);
360
+
361
+ // Only show preparing indicator for quick start (standard setup shows skill in real-time)
362
+ if (debate.quickStart) {
363
+ ctx.sendTo(ws, {
364
+ type: "debate_preparing",
365
+ topic: debate.topic,
366
+ moderatorId: debate.moderatorId,
367
+ moderatorName: moderatorProfile.name,
368
+ setupSessionId: debate.setupSessionId,
369
+ panelists: debate.panelists.map(function (p) {
370
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
371
+ return { mateId: p.mateId, name: prof.name };
372
+ }),
373
+ });
374
+ }
375
+ } else if (phase === "reviewing") {
376
+ console.log("[debate] Restoring debate (reviewing). topic:", debate.topic);
377
+ ctx.sendTo(ws, {
378
+ type: "debate_brief_ready",
379
+ debateId: debate.debateId,
380
+ topic: debate.topic,
381
+ format: debate.format || "free_discussion",
382
+ context: debate.context || "",
383
+ specialRequests: debate.specialRequests || null,
384
+ moderatorId: debate.moderatorId,
385
+ moderatorName: moderatorProfile.name,
386
+ panelists: debate.panelists.map(function (p) {
387
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
388
+ return { mateId: p.mateId, name: prof.name, role: p.role || "", brief: p.brief || "" };
389
+ }),
390
+ });
391
+ } else if (phase === "live") {
392
+ console.log("[debate] Restoring debate (live). topic:", debate.topic, "awaitingConclude:", debate.awaitingConcludeConfirm);
393
+ // Debate was live when server restarted. It can't resume AI turns,
394
+ // but we can show the sticky and let user see history.
395
+ ctx.sendTo(ws, {
396
+ type: "debate_started",
397
+ topic: debate.topic,
398
+ format: debate.format,
399
+ round: debate.round,
400
+ moderatorId: debate.moderatorId,
401
+ moderatorName: moderatorProfile.name,
402
+ panelists: debate.panelists.map(function (p) {
403
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
404
+ return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed };
405
+ }),
406
+ });
407
+ // If moderator had concluded, re-send conclude confirm so client shows End/Continue UI
408
+ if (debate.awaitingConcludeConfirm) {
409
+ ctx.sendTo(ws, { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round });
410
+ }
411
+ }
412
+ });
413
+ }
414
+
415
+ // --- Check for DM debate brief ---
416
+
417
+ function checkForDmDebateBrief(session, mateId, mateCtx) {
418
+ // Skip if there's already an active debate on this session
419
+ if (session._debate && (session._debate.phase === "preparing" || session._debate.phase === "reviewing" || session._debate.phase === "live")) return;
420
+
421
+ var debatesDir = path.join(ctx.cwd, ".clay", "debates");
422
+ var dirs;
423
+ try {
424
+ dirs = fs.readdirSync(debatesDir);
425
+ } catch (e) {
426
+ return; // No debates directory
427
+ }
428
+
429
+ for (var i = 0; i < dirs.length; i++) {
430
+ var briefPath = path.join(debatesDir, dirs[i], "brief.json");
431
+ var raw;
432
+ try {
433
+ raw = fs.readFileSync(briefPath, "utf8");
434
+ } catch (e) {
435
+ continue; // No brief.json in this dir
436
+ }
437
+
438
+ var brief;
439
+ try {
440
+ brief = JSON.parse(raw);
441
+ } catch (e) {
442
+ continue; // Invalid JSON
443
+ }
444
+
445
+ // Found a valid brief - create debate state
446
+ var debateId = dirs[i];
447
+ console.log("[debate] Found DM debate brief from mate " + mateId + ", debateId:", debateId);
448
+
449
+ // Clean up the brief file
450
+ try { fs.unlinkSync(briefPath); } catch (e) {}
451
+
452
+ var debate = {
453
+ phase: "reviewing",
454
+ topic: brief.topic || "Untitled debate",
455
+ format: brief.format || "free_discussion",
456
+ context: brief.context || "",
457
+ specialRequests: brief.specialRequests || null,
458
+ moderatorId: mateId,
459
+ panelists: (brief.panelists || []).map(function (p) {
460
+ return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" };
461
+ }),
462
+ mateCtx: mateCtx,
463
+ moderatorSession: null,
464
+ panelistSessions: {},
465
+ nameMap: null,
466
+ turnInProgress: false,
467
+ pendingComment: null,
468
+ round: 1,
469
+ history: [],
470
+ setupSessionId: null,
471
+ debateId: debateId,
472
+ briefPath: briefPath,
473
+ };
474
+ debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx);
475
+ session._debate = debate;
476
+ persistDebateState(session);
477
+
478
+ var moderatorProfile = ctx.getMateProfile(mateCtx, mateId);
479
+ ctx.sendToSession(session.localId, {
480
+ type: "debate_brief_ready",
481
+ debateId: debateId,
482
+ topic: debate.topic,
483
+ format: debate.format,
484
+ context: debate.context,
485
+ specialRequests: debate.specialRequests,
486
+ moderatorId: mateId,
487
+ moderatorName: moderatorProfile.name,
488
+ panelists: debate.panelists.map(function (p) {
489
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
490
+ return { mateId: p.mateId, name: prof.name, role: p.role || "", brief: p.brief || "" };
491
+ }),
492
+ });
493
+ return; // Only process first brief found
494
+ }
495
+ }
496
+
497
+ // --- Main debate handlers ---
498
+
499
+ function handleDebateStart(ws, msg) {
500
+ var session = ctx.getSessionForWs(ws);
501
+ if (!session) return;
502
+
503
+ if (!msg.moderatorId || !msg.topic || !msg.panelists || !msg.panelists.length) {
504
+ ctx.sendTo(ws, { type: "debate_error", error: "Missing required fields: moderatorId, topic, panelists." });
505
+ return;
506
+ }
507
+
508
+ if (session._debate && (session._debate.phase === "live" || session._debate.phase === "preparing")) {
509
+ ctx.sendTo(ws, { type: "debate_error", error: "A debate is already in progress." });
510
+ return;
511
+ }
512
+
513
+ // Block mentions during debate
514
+ if (session._mentionInProgress) {
515
+ ctx.sendTo(ws, { type: "debate_error", error: "A mention is in progress. Wait for it to finish." });
516
+ return;
517
+ }
518
+
519
+ var userId = ws._clayUser ? ws._clayUser.id : null;
520
+ var mateCtx = matesModule.buildMateCtx(userId);
521
+ var moderatorProfile = ctx.getMateProfile(mateCtx, msg.moderatorId);
522
+
523
+ // --- Phase 1: Preparing (clay-debate-setup skill) ---
524
+ var debate = {
525
+ phase: "preparing",
526
+ topic: msg.topic,
527
+ format: "free_discussion",
528
+ context: "",
529
+ specialRequests: null,
530
+ moderatorId: msg.moderatorId,
531
+ panelists: msg.panelists,
532
+ mateCtx: mateCtx,
533
+ moderatorSession: null,
534
+ panelistSessions: {},
535
+ nameMap: buildDebateNameMap(msg.panelists, mateCtx),
536
+ turnInProgress: false,
537
+ pendingComment: null,
538
+ round: 1,
539
+ history: [],
540
+ setupSessionId: null,
541
+ };
542
+ session._debate = debate;
543
+
544
+ var debateId = "debate_" + Date.now();
545
+ var debateDir = path.join(ctx.cwd, ".clay", "debates", debateId);
546
+ try { fs.mkdirSync(debateDir, { recursive: true }); } catch (e) {}
547
+ var briefPath = path.join(debateDir, "brief.json");
548
+ console.log("[debate] cwd=" + ctx.cwd + " debateDir=" + debateDir + " briefPath=" + briefPath);
549
+
550
+ debate.debateId = debateId;
551
+ debate.briefPath = briefPath;
552
+
553
+ if (msg.quickStart) {
554
+ // --- Quick Start: moderator mate generates brief from DM context ---
555
+ handleDebateQuickStart(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath);
556
+ } else {
557
+ // --- Standard: clay-debate-setup skill ---
558
+ handleDebateSkillSetup(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath);
559
+ }
560
+ }
561
+
562
+ // Quick start: moderator mate uses DM conversation context to generate the debate brief directly
563
+ function handleDebateQuickStart(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath) {
564
+ debate.quickStart = true;
565
+ var debateId = debate.debateId;
566
+
567
+ // Create setup session (still needed for session grouping)
568
+ var setupSession = ctx.sm.createSession();
569
+ setupSession.title = "Debate Setup: " + (msg.topic || "Quick").slice(0, 40);
570
+ setupSession.debateSetupMode = true;
571
+ setupSession.loop = { active: true, iteration: 0, role: "crafting", loopId: debateId, name: (msg.topic || "Quick").slice(0, 40), source: "debate", startedAt: Date.now() };
572
+ ctx.sm.saveSessionFile(setupSession);
573
+ ctx.sm.switchSession(setupSession.localId, null, ctx.hydrateImageRefs);
574
+ debate.setupSessionId = setupSession.localId;
575
+ debate.setupStartedAt = setupSession.loop.startedAt;
576
+
577
+ // Build DM conversation context for the moderator
578
+ var dmContext = msg.dmContext || "";
579
+
580
+ // Build panelist info
581
+ var panelistInfo = msg.panelists.map(function (p) {
582
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
583
+ return "- " + (prof.name || p.mateId) + " (ID: " + p.mateId + ", bio: " + (prof.bio || "none") + ")";
584
+ }).join("\n");
585
+
586
+ var quickBriefPrompt = [
587
+ "You are " + (moderatorProfile.name || "the moderator") + ". You were just having a DM conversation with the user, and they want to turn this into a structured debate.",
588
+ "",
589
+ "## Recent DM Conversation",
590
+ dmContext,
591
+ "",
592
+ "## Topic Suggestion",
593
+ msg.topic || "(Derive from conversation above)",
594
+ "",
595
+ "## Available Panelists",
596
+ panelistInfo,
597
+ "",
598
+ "## Your Task",
599
+ "Based on the conversation context, create a debate brief. You know the topic well because you were just discussing it.",
600
+ "Assign each panelist a role and perspective that will create the most productive debate.",
601
+ "",
602
+ "Output ONLY a valid JSON object (no markdown fences, no extra text):",
603
+ "{",
604
+ ' "topic": "refined debate topic",',
605
+ ' "format": "free_discussion",',
606
+ ' "context": "key context from DM conversation that panelists should know",',
607
+ ' "specialRequests": "any special instructions (null if none)",',
608
+ ' "panelists": [',
609
+ ' { "mateId": "...", "role": "perspective/stance", "brief": "what this panelist should argue for" }',
610
+ " ]",
611
+ "}",
612
+ ].join("\n");
613
+
614
+ // Persist and start watcher
615
+ persistDebateState(session);
616
+ startDebateBriefWatcher(session, debate, briefPath);
617
+
618
+ // Notify clients
619
+ var preparingMsg = {
620
+ type: "debate_preparing",
621
+ topic: debate.topic || "(Setting up...)",
622
+ moderatorId: debate.moderatorId,
623
+ moderatorName: moderatorProfile.name,
624
+ setupSessionId: setupSession.localId,
625
+ panelists: debate.panelists.map(function (p) {
626
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
627
+ return { mateId: p.mateId, name: prof.name };
628
+ }),
629
+ };
630
+ ctx.sendTo(ws, preparingMsg);
631
+ ctx.sendToSession(session.localId, preparingMsg);
632
+ ctx.sendToSession(setupSession.localId, preparingMsg);
633
+
634
+ // Use moderator's own Claude identity to generate the brief via mention session
635
+ var claudeMd = ctx.loadMateClaudeMd(mateCtx, debate.moderatorId);
636
+ var digests = ctx.loadMateDigests(mateCtx, debate.moderatorId, debate.topic);
637
+
638
+ var briefText = "";
639
+ ctx.sdk.createMentionSession({
640
+ claudeMd: claudeMd,
641
+ initialContext: digests,
642
+ initialMessage: quickBriefPrompt,
643
+ onActivity: function () {},
644
+ onDelta: function (delta) { briefText += delta; },
645
+ onDone: function () {
646
+ try {
647
+ var cleaned = briefText.trim();
648
+ if (cleaned.indexOf("```") === 0) {
649
+ cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim();
650
+ }
651
+ // Validate it is parseable JSON
652
+ JSON.parse(cleaned);
653
+ // Write brief.json for the watcher to pick up
654
+ fs.writeFileSync(briefPath, cleaned, "utf8");
655
+ console.log("[debate-quick] Moderator generated brief, wrote to " + briefPath);
656
+ } catch (e) {
657
+ console.error("[debate-quick] Failed to generate brief:", e.message);
658
+ console.error("[debate-quick] Raw output:", briefText.substring(0, 500));
659
+ // Fall back: write a minimal brief
660
+ var fallbackBrief = {
661
+ topic: debate.topic || "Discussion",
662
+ format: "free_discussion",
663
+ context: "",
664
+ specialRequests: null,
665
+ panelists: debate.panelists.map(function (p) {
666
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
667
+ return { mateId: p.mateId, role: "participant", brief: "Share your perspective on the topic." };
668
+ }),
669
+ };
670
+ try {
671
+ fs.writeFileSync(briefPath, JSON.stringify(fallbackBrief), "utf8");
672
+ console.log("[debate-quick] Wrote fallback brief");
673
+ } catch (fe) {
674
+ console.error("[debate-quick] Failed to write fallback brief:", fe.message);
675
+ endDebate(session, "error");
676
+ }
677
+ }
678
+ },
679
+ onError: function (err) {
680
+ console.error("[debate-quick] Moderator brief generation failed:", err);
681
+ endDebate(session, "error");
682
+ },
683
+ });
684
+ }
685
+
686
+ // Standard debate setup via clay-debate-setup skill
687
+ function handleDebateSkillSetup(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath) {
688
+ var debateId = debate.debateId;
689
+
690
+ // Create a new session for the setup skill (like Ralph crafting)
691
+ var setupSession = ctx.sm.createSession();
692
+ setupSession.title = "Debate Setup: " + msg.topic.slice(0, 40);
693
+ setupSession.debateSetupMode = true;
694
+ setupSession.loop = { active: true, iteration: 0, role: "crafting", loopId: debateId, name: msg.topic.slice(0, 40), source: "debate", startedAt: Date.now() };
695
+ ctx.sm.saveSessionFile(setupSession);
696
+ ctx.sm.switchSession(setupSession.localId, null, ctx.hydrateImageRefs);
697
+ debate.setupSessionId = setupSession.localId;
698
+ debate.setupStartedAt = setupSession.loop.startedAt;
699
+
700
+ // Build panelist info for the skill prompt
701
+ var panelistNames = msg.panelists.map(function (p) {
702
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
703
+ return prof.name || p.mateId;
704
+ }).join(", ");
705
+
706
+ var craftingPrompt = "Use the /clay-debate-setup skill to prepare a structured debate. " +
707
+ "You MUST invoke the clay-debate-setup skill. Do NOT start the debate yourself.\n\n" +
708
+ "## Initial Topic\n" + msg.topic + "\n\n" +
709
+ "## Moderator\n" + (moderatorProfile.name || msg.moderatorId) + "\n\n" +
710
+ "## Selected Panelists\n" + msg.panelists.map(function (p) {
711
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
712
+ return "- " + (prof.name || p.mateId) + " (ID: " + p.mateId + ")";
713
+ }).join("\n") + "\n\n" +
714
+ "## Debate Brief Output Path\n" +
715
+ "When the setup is complete, write the debate brief JSON to this EXACT absolute path:\n" +
716
+ "`" + briefPath + "`\n" +
717
+ "This is where the debate engine watches for the file. Do NOT write it anywhere else.\n\n" +
718
+ "## Spoken Language\nKorean (unless user switches)";
719
+
720
+ // Persist debate state before starting watcher
721
+ persistDebateState(session);
722
+
723
+ // Watch for brief.json in the debate-specific directory
724
+ startDebateBriefWatcher(session, debate, briefPath);
725
+
726
+ // Standard setup: no preparing indicator needed because the user
727
+ // sees the skill working in real-time in the setup session.
728
+
729
+ // Start the setup skill session
730
+ setupSession.history.push({ type: "user_message", text: craftingPrompt });
731
+ ctx.sm.appendToSessionFile(setupSession, { type: "user_message", text: craftingPrompt });
732
+ ctx.sendToSession(setupSession.localId, { type: "user_message", text: craftingPrompt });
733
+ setupSession.isProcessing = true;
734
+ ctx.onProcessingChanged();
735
+ setupSession.sentToolResults = {};
736
+ ctx.sendToSession(setupSession.localId, { type: "status", status: "processing" });
737
+ ctx.sdk.startQuery(setupSession, craftingPrompt, undefined, ctx.getLinuxUserForSession(setupSession));
738
+ }
739
+
740
+ // --- Live debate ---
741
+
742
+ function startDebateLive(session) {
743
+ var debate = session._debate;
744
+ if (!debate || debate.phase === "live") return;
745
+
746
+ debate.phase = "live";
747
+ debate.turnInProgress = true;
748
+ debate.round = 1;
749
+
750
+ var mateCtx = debate.mateCtx;
751
+ var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId);
752
+
753
+ // Create a dedicated debate session, grouped with the setup session
754
+ var debateSession = ctx.sm.createSession();
755
+ debateSession.title = debate.topic.slice(0, 50);
756
+ debateSession.loop = { active: true, iteration: 1, role: "debate", loopId: debate.debateId, name: debate.topic.slice(0, 40), source: "debate", startedAt: debate.setupStartedAt || Date.now() };
757
+ // Assign cliSessionId manually so saveSessionFile works (no SDK query for debate sessions)
758
+ if (!debateSession.cliSessionId) {
759
+ debateSession.cliSessionId = crypto.randomUUID();
760
+ }
761
+ ctx.sm.saveSessionFile(debateSession);
762
+ ctx.sm.switchSession(debateSession.localId, null, ctx.hydrateImageRefs);
763
+ debate.liveSessionId = debateSession.localId;
764
+
765
+ // Move _debate to the new session so all debate logic uses it
766
+ debateSession._debate = debate;
767
+ delete session._debate;
768
+ // Clear persisted state from setup session, persist on live session
769
+ session.debateState = null;
770
+ ctx.sm.saveSessionFile(session);
771
+ persistDebateState(debateSession);
772
+
773
+ // Save to session history
774
+ var debateStartEntry = {
775
+ type: "debate_started",
776
+ topic: debate.topic,
777
+ format: debate.format,
778
+ moderatorId: debate.moderatorId,
779
+ moderatorName: moderatorProfile.name,
780
+ panelists: debate.panelists.map(function (p) {
781
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
782
+ return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed };
783
+ }),
784
+ };
785
+ debateSession.history.push(debateStartEntry);
786
+ ctx.sm.appendToSessionFile(debateSession, debateStartEntry);
787
+
788
+ // Notify clients (same data as history entry)
789
+ ctx.sendToSession(debateSession.localId, debateStartEntry);
790
+
791
+ // Signal moderator's first turn
792
+ ctx.sendToSession(debateSession.localId, {
793
+ type: "debate_turn",
794
+ mateId: debate.moderatorId,
795
+ mateName: moderatorProfile.name,
796
+ role: "moderator",
797
+ round: debate.round,
798
+ avatarColor: moderatorProfile.avatarColor,
799
+ avatarStyle: moderatorProfile.avatarStyle,
800
+ avatarSeed: moderatorProfile.avatarSeed,
801
+ });
802
+
803
+ // Create moderator mention session
804
+ var claudeMd = ctx.loadMateClaudeMd(mateCtx, debate.moderatorId);
805
+ var digests = ctx.loadMateDigests(mateCtx, debate.moderatorId, debate.topic);
806
+ var moderatorContext = buildModeratorContext(debate) + digests;
807
+
808
+ ctx.sdk.createMentionSession({
809
+ claudeMd: claudeMd,
810
+ initialContext: moderatorContext,
811
+ initialMessage: "Begin the debate on: " + debate.topic,
812
+ onActivity: function (activity) {
813
+ if (debateSession._debate && debateSession._debate.phase !== "ended") {
814
+ ctx.sendToSession(debateSession.localId, { type: "debate_activity", mateId: debate.moderatorId, activity: activity });
815
+ }
816
+ },
817
+ onDelta: function (delta) {
818
+ if (debateSession._debate && debateSession._debate.phase !== "ended") {
819
+ ctx.sendToSession(debateSession.localId, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta });
820
+ }
821
+ },
822
+ onDone: function (fullText) {
823
+ handleModeratorTurnDone(debateSession, fullText);
824
+ },
825
+ onError: function (errMsg) {
826
+ console.error("[debate] Moderator error:", errMsg);
827
+ endDebate(debateSession, "error");
828
+ },
829
+ canUseTool: buildDebateToolHandler(debateSession),
830
+ }).then(function (mentionSession) {
831
+ if (mentionSession) {
832
+ debate.moderatorSession = mentionSession;
833
+ }
834
+ }).catch(function (err) {
835
+ console.error("[debate] Failed to create moderator session:", err.message || err);
836
+ endDebate(debateSession, "error");
837
+ });
838
+ }
839
+
840
+ // --- Turn management ---
841
+
842
+ function handleModeratorTurnDone(session, fullText) {
843
+ var debate = session._debate;
844
+ if (!debate || debate.phase === "ended") return;
845
+
846
+ debate.turnInProgress = false;
847
+
848
+ // Record in debate history
849
+ var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId);
850
+ debate.history.push({ speaker: "moderator", mateId: debate.moderatorId, mateName: moderatorProfile.name, text: fullText });
851
+
852
+ // Save to session history
853
+ var turnEntry = { type: "debate_turn_done", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, text: fullText, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, avatarColor: moderatorProfile.avatarColor };
854
+ session.history.push(turnEntry);
855
+ ctx.sm.appendToSessionFile(session, turnEntry);
856
+ ctx.sendToSession(session.localId, turnEntry);
857
+
858
+ // Check if user stopped the debate during this turn
859
+ if (debate.phase === "ending") {
860
+ endDebate(session, "user_stopped");
861
+ return;
862
+ }
863
+
864
+ // Detect @mentions
865
+ console.log("[debate] nameMap keys:", JSON.stringify(Object.keys(debate.nameMap)));
866
+ console.log("[debate] moderator text (last 200):", fullText.slice(-200));
867
+ var mentionedIds = detectMentions(fullText, debate.nameMap);
868
+ console.log("[debate] detected mentions:", JSON.stringify(mentionedIds));
869
+
870
+ if (mentionedIds.length === 0) {
871
+ // No mentions = moderator wants to conclude. Ask user to confirm.
872
+ console.log("[debate] No mentions detected, requesting user confirmation to end.");
873
+ debate.turnInProgress = false;
874
+ debate.awaitingConcludeConfirm = true;
875
+ persistDebateState(session);
876
+ var concludeEntry = { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round };
877
+ session.history.push(concludeEntry);
878
+ ctx.sm.appendToSessionFile(session, concludeEntry);
879
+ ctx.sendToSession(session.localId, concludeEntry);
880
+ return;
881
+ }
882
+
883
+ // Check for pending user comment before triggering panelist
884
+ if (debate.pendingComment) {
885
+ injectUserComment(session);
886
+ return;
887
+ }
888
+
889
+ // Trigger the first mentioned panelist
890
+ triggerPanelist(session, mentionedIds[0], fullText);
891
+ }
892
+
893
+ function triggerPanelist(session, mateId, moderatorText) {
894
+ var debate = session._debate;
895
+ if (!debate || debate.phase === "ended") return;
896
+
897
+ debate.turnInProgress = true;
898
+ debate._currentTurnMateId = mateId;
899
+ debate._currentTurnText = "";
900
+
901
+ var profile = ctx.getMateProfile(debate.mateCtx, mateId);
902
+ var panelistInfo = null;
903
+ for (var i = 0; i < debate.panelists.length; i++) {
904
+ if (debate.panelists[i].mateId === mateId) {
905
+ panelistInfo = debate.panelists[i];
906
+ break;
907
+ }
908
+ }
909
+ if (!panelistInfo) {
910
+ console.error("[debate] Panelist not found:", mateId);
911
+ debate._currentTurnMateId = null;
912
+ // Feed error back to moderator
913
+ feedBackToModerator(session, mateId, "[This panelist is not part of the debate panel.]");
914
+ return;
915
+ }
916
+
917
+ // Notify clients of new turn
918
+ ctx.sendToSession(session.localId, {
919
+ type: "debate_turn",
920
+ mateId: mateId,
921
+ mateName: profile.name,
922
+ role: panelistInfo.role,
923
+ round: debate.round,
924
+ avatarColor: profile.avatarColor,
925
+ avatarStyle: profile.avatarStyle,
926
+ avatarSeed: profile.avatarSeed,
927
+ });
928
+
929
+ var panelistCallbacks = {
930
+ onActivity: function (activity) {
931
+ if (session._debate && session._debate.phase !== "ended") {
932
+ ctx.sendToSession(session.localId, { type: "debate_activity", mateId: mateId, activity: activity });
933
+ }
934
+ },
935
+ onDelta: function (delta) {
936
+ if (session._debate && session._debate.phase !== "ended") {
937
+ debate._currentTurnText += delta;
938
+ ctx.sendToSession(session.localId, { type: "debate_stream", mateId: mateId, mateName: profile.name, delta: delta });
939
+ }
940
+ },
941
+ onDone: function (fullText) {
942
+ handlePanelistTurnDone(session, mateId, fullText);
943
+ },
944
+ onError: function (errMsg) {
945
+ console.error("[debate] Panelist error for " + mateId + ":", errMsg);
946
+ debate.turnInProgress = false;
947
+ // Feed error back to moderator so the debate can continue
948
+ feedBackToModerator(session, mateId, "[" + profile.name + " encountered an error and could not respond. Please continue with other panelists or wrap up.]");
949
+ },
950
+ };
951
+
952
+ // Check for existing session
953
+ var existing = debate.panelistSessions[mateId];
954
+ if (existing && existing.isAlive()) {
955
+ // Build recent debate context for continuation
956
+ var recentHistory = "";
957
+ var lastPanelistIdx = -1;
958
+ for (var hi = debate.history.length - 1; hi >= 0; hi--) {
959
+ if (debate.history[hi].mateId === mateId) {
960
+ lastPanelistIdx = hi;
961
+ break;
962
+ }
963
+ }
964
+ if (lastPanelistIdx >= 0 && lastPanelistIdx < debate.history.length - 1) {
965
+ recentHistory = "\n\n[Debate turns since your last response:]\n---\n";
966
+ for (var hj = lastPanelistIdx + 1; hj < debate.history.length; hj++) {
967
+ var h = debate.history[hj];
968
+ recentHistory += h.mateName + " (" + (h.speaker === "moderator" ? "moderator" : h.role || h.speaker) + "): " + h.text.substring(0, 500) + "\n\n";
969
+ }
970
+ recentHistory += "---";
971
+ }
972
+ var continuationMsg = recentHistory + "\n\n[The moderator is now addressing you. Please respond.]\n\nModerator said:\n" + moderatorText;
973
+ existing.pushMessage(continuationMsg, panelistCallbacks);
974
+ } else {
975
+ // Create new panelist session
976
+ var claudeMd = ctx.loadMateClaudeMd(debate.mateCtx, mateId);
977
+ var digests = ctx.loadMateDigests(debate.mateCtx, mateId, debate.topic);
978
+ var panelistContext = buildPanelistContext(debate, panelistInfo) + digests;
979
+
980
+ // Include debate history so far for context
981
+ var historyContext = "";
982
+ if (debate.history.length > 0) {
983
+ historyContext = "\n\n[Debate so far:]\n---\n";
984
+ for (var hk = 0; hk < debate.history.length; hk++) {
985
+ var he = debate.history[hk];
986
+ historyContext += he.mateName + " (" + (he.speaker === "moderator" ? "moderator" : he.role || he.speaker) + "): " + he.text.substring(0, 500) + "\n\n";
987
+ }
988
+ historyContext += "---";
989
+ }
990
+
991
+ ctx.sdk.createMentionSession({
992
+ claudeMd: claudeMd,
993
+ initialContext: panelistContext + historyContext,
994
+ initialMessage: "The moderator addresses you:\n\n" + moderatorText,
995
+ onActivity: panelistCallbacks.onActivity,
996
+ onDelta: panelistCallbacks.onDelta,
997
+ onDone: panelistCallbacks.onDone,
998
+ onError: panelistCallbacks.onError,
999
+ canUseTool: buildDebateToolHandler(session),
1000
+ }).then(function (mentionSession) {
1001
+ if (mentionSession) {
1002
+ debate.panelistSessions[mateId] = mentionSession;
1003
+ }
1004
+ }).catch(function (err) {
1005
+ console.error("[debate] Failed to create panelist session for " + mateId + ":", err.message || err);
1006
+ debate.turnInProgress = false;
1007
+ feedBackToModerator(session, mateId, "[" + profile.name + " is unavailable. Please continue with other panelists or wrap up.]");
1008
+ });
1009
+ }
1010
+ }
1011
+
1012
+ function handlePanelistTurnDone(session, mateId, fullText) {
1013
+ var debate = session._debate;
1014
+ if (!debate || debate.phase === "ended") return;
1015
+
1016
+ debate.turnInProgress = false;
1017
+ debate._currentTurnMateId = null;
1018
+ debate._currentTurnText = "";
1019
+
1020
+ var profile = ctx.getMateProfile(debate.mateCtx, mateId);
1021
+ var panelistInfo = null;
1022
+ for (var i = 0; i < debate.panelists.length; i++) {
1023
+ if (debate.panelists[i].mateId === mateId) {
1024
+ panelistInfo = debate.panelists[i];
1025
+ break;
1026
+ }
1027
+ }
1028
+
1029
+ // Record in debate history
1030
+ debate.history.push({ speaker: "panelist", mateId: mateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", text: fullText });
1031
+
1032
+ // Save to session history
1033
+ var turnEntry = { type: "debate_turn_done", mateId: mateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", round: debate.round, text: fullText, avatarStyle: profile.avatarStyle, avatarSeed: profile.avatarSeed, avatarColor: profile.avatarColor };
1034
+ session.history.push(turnEntry);
1035
+ ctx.sm.appendToSessionFile(session, turnEntry);
1036
+ ctx.sendToSession(session.localId, turnEntry);
1037
+
1038
+ // Check if user stopped the debate
1039
+ if (debate.phase === "ending") {
1040
+ endDebate(session, "user_stopped");
1041
+ return;
1042
+ }
1043
+
1044
+ // Check for pending user comment
1045
+ if (debate.pendingComment) {
1046
+ injectUserComment(session);
1047
+ return;
1048
+ }
1049
+
1050
+ // Feed panelist response back to moderator
1051
+ feedBackToModerator(session, mateId, fullText);
1052
+ }
1053
+
1054
+ function feedBackToModerator(session, panelistMateId, panelistText) {
1055
+ var debate = session._debate;
1056
+ if (!debate || !debate.moderatorSession || debate.phase === "ended") return;
1057
+
1058
+ debate.round++;
1059
+ debate.turnInProgress = true;
1060
+
1061
+ var panelistProfile = ctx.getMateProfile(debate.mateCtx, panelistMateId);
1062
+ var panelistInfo = null;
1063
+ for (var i = 0; i < debate.panelists.length; i++) {
1064
+ if (debate.panelists[i].mateId === panelistMateId) {
1065
+ panelistInfo = debate.panelists[i];
1066
+ break;
1067
+ }
1068
+ }
1069
+
1070
+ var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId);
1071
+
1072
+ // Notify clients of moderator turn
1073
+ ctx.sendToSession(session.localId, {
1074
+ type: "debate_turn",
1075
+ mateId: debate.moderatorId,
1076
+ mateName: moderatorProfile.name,
1077
+ role: "moderator",
1078
+ round: debate.round,
1079
+ avatarColor: moderatorProfile.avatarColor,
1080
+ avatarStyle: moderatorProfile.avatarStyle,
1081
+ avatarSeed: moderatorProfile.avatarSeed,
1082
+ });
1083
+
1084
+ var feedText = "[Panelist Response]\n\n" +
1085
+ "@" + panelistProfile.name + " (" + (panelistInfo ? panelistInfo.role : "panelist") + ") responded:\n" +
1086
+ panelistText + "\n\n" +
1087
+ "Continue the debate. Call on the next panelist with @TheirName, or provide a closing summary (without any @mentions) to end the debate.";
1088
+
1089
+ debate.moderatorSession.pushMessage(feedText, buildModeratorCallbacks(session));
1090
+ }
1091
+
1092
+ function buildModeratorCallbacks(session) {
1093
+ var debate = session._debate;
1094
+ var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId);
1095
+ return {
1096
+ onActivity: function (activity) {
1097
+ if (session._debate && session._debate.phase !== "ended") {
1098
+ ctx.sendToSession(session.localId, { type: "debate_activity", mateId: debate.moderatorId, activity: activity });
1099
+ }
1100
+ },
1101
+ onDelta: function (delta) {
1102
+ if (session._debate && session._debate.phase !== "ended") {
1103
+ ctx.sendToSession(session.localId, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta });
1104
+ }
1105
+ },
1106
+ onDone: function (fullText) {
1107
+ handleModeratorTurnDone(session, fullText);
1108
+ },
1109
+ onError: function (errMsg) {
1110
+ console.error("[debate] Moderator error:", errMsg);
1111
+ endDebate(session, "error");
1112
+ },
1113
+ };
1114
+ }
1115
+
1116
+ // --- User interaction during debate ---
1117
+
1118
+ function handleDebateComment(ws, msg) {
1119
+ var session = ctx.getSessionForWs(ws);
1120
+ if (!session) return;
1121
+
1122
+ var debate = session._debate;
1123
+ if (!debate || debate.phase !== "live") {
1124
+ ctx.sendTo(ws, { type: "debate_error", error: "No active debate." });
1125
+ return;
1126
+ }
1127
+
1128
+ // If awaiting conclude confirmation, re-send the confirm prompt instead
1129
+ if (debate.awaitingConcludeConfirm) {
1130
+ ctx.sendTo(ws, { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round });
1131
+ return;
1132
+ }
1133
+
1134
+ if (!msg.text) return;
1135
+
1136
+ debate.pendingComment = { text: msg.text };
1137
+ ctx.sendToSession(session.localId, { type: "debate_comment_queued", text: msg.text });
1138
+
1139
+ // If a panelist turn is in progress, abort it and go straight to moderator
1140
+ if (debate.turnInProgress && debate._currentTurnMateId && debate._currentTurnMateId !== debate.moderatorId) {
1141
+ var abortMateId = debate._currentTurnMateId;
1142
+ console.log("[debate] User raised hand during panelist turn, aborting " + abortMateId);
1143
+
1144
+ // Close the panelist's mention session to stop generation
1145
+ if (debate.panelistSessions[abortMateId]) {
1146
+ try { debate.panelistSessions[abortMateId].close(); } catch (e) {}
1147
+ delete debate.panelistSessions[abortMateId];
1148
+ }
1149
+
1150
+ // Save partial text as interrupted turn
1151
+ var partialText = debate._currentTurnText || "(interrupted by audience)";
1152
+ var profile = ctx.getMateProfile(debate.mateCtx, abortMateId);
1153
+ var panelistInfo = null;
1154
+ for (var pi = 0; pi < debate.panelists.length; pi++) {
1155
+ if (debate.panelists[pi].mateId === abortMateId) { panelistInfo = debate.panelists[pi]; break; }
1156
+ }
1157
+
1158
+ ctx.sendToSession(session.localId, {
1159
+ type: "debate_turn_done",
1160
+ mateId: abortMateId,
1161
+ mateName: profile.name,
1162
+ role: panelistInfo ? panelistInfo.role : "",
1163
+ text: partialText,
1164
+ interrupted: true,
1165
+ avatarStyle: profile.avatarStyle,
1166
+ avatarSeed: profile.avatarSeed,
1167
+ avatarColor: profile.avatarColor,
1168
+ });
1169
+
1170
+ var turnEntry = { type: "debate_turn_done", mateId: abortMateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", round: debate.round, text: partialText, avatarStyle: profile.avatarStyle, avatarSeed: profile.avatarSeed, avatarColor: profile.avatarColor, interrupted: true };
1171
+ session.history.push(turnEntry);
1172
+ ctx.sm.appendToSessionFile(session, turnEntry);
1173
+ debate.history.push({ speaker: "panelist", mateId: abortMateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", text: partialText });
1174
+
1175
+ debate.turnInProgress = false;
1176
+ debate._currentTurnMateId = null;
1177
+ debate._currentTurnText = "";
1178
+ }
1179
+
1180
+ // Inject to moderator immediately if no turn in progress (or just aborted)
1181
+ if (!debate.turnInProgress) {
1182
+ injectUserComment(session);
1183
+ }
1184
+ // If moderator is currently speaking, pendingComment will be picked up after moderator's onDone
1185
+ }
1186
+
1187
+ function injectUserComment(session) {
1188
+ var debate = session._debate;
1189
+ if (!debate || !debate.pendingComment || !debate.moderatorSession || debate.phase === "ended") return;
1190
+
1191
+ var comment = debate.pendingComment;
1192
+ debate.pendingComment = null;
1193
+
1194
+ // Record in debate history
1195
+ debate.history.push({ speaker: "user", mateId: null, mateName: "User", text: comment.text });
1196
+
1197
+ var commentEntry = { type: "debate_comment_injected", text: comment.text };
1198
+ session.history.push(commentEntry);
1199
+ ctx.sm.appendToSessionFile(session, commentEntry);
1200
+ ctx.sendToSession(session.localId, commentEntry);
1201
+
1202
+ // Feed to moderator
1203
+ debate.turnInProgress = true;
1204
+ var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId);
1205
+
1206
+ ctx.sendToSession(session.localId, {
1207
+ type: "debate_turn",
1208
+ mateId: debate.moderatorId,
1209
+ mateName: moderatorProfile.name,
1210
+ role: "moderator",
1211
+ round: debate.round,
1212
+ avatarColor: moderatorProfile.avatarColor,
1213
+ avatarStyle: moderatorProfile.avatarStyle,
1214
+ avatarSeed: moderatorProfile.avatarSeed,
1215
+ });
1216
+
1217
+ var feedText = "[The user raised their hand and said:]\n" +
1218
+ comment.text + "\n" +
1219
+ "[Please acknowledge this and weave it into the discussion. Then continue the debate.]";
1220
+
1221
+ debate.moderatorSession.pushMessage(feedText, buildModeratorCallbacks(session));
1222
+ }
1223
+
1224
+ function handleDebateConfirmBrief(ws) {
1225
+ var session = ctx.getSessionForWs(ws);
1226
+ if (!session) return;
1227
+
1228
+ var debate = session._debate;
1229
+ if (!debate || debate.phase !== "reviewing") {
1230
+ ctx.sendTo(ws, { type: "debate_error", error: "No debate brief to confirm." });
1231
+ return;
1232
+ }
1233
+
1234
+ console.log("[debate] User confirmed brief, transitioning to live. Topic:", debate.topic);
1235
+ startDebateLive(session);
1236
+ }
1237
+
1238
+ function handleDebateStop(ws) {
1239
+ var session = ctx.getSessionForWs(ws);
1240
+ if (!session) return;
1241
+
1242
+ var debate = session._debate;
1243
+ if (!debate) return;
1244
+
1245
+ if (debate.phase === "reviewing") {
1246
+ endDebate(session, "user_stopped");
1247
+ return;
1248
+ }
1249
+
1250
+ if (debate.phase !== "live") return;
1251
+
1252
+ if (debate.turnInProgress) {
1253
+ // Let current turn finish, then end
1254
+ debate.phase = "ending";
1255
+ } else {
1256
+ endDebate(session, "user_stopped");
1257
+ }
1258
+ }
1259
+
1260
+ // Rebuild _debate from session history (for resume after server restart)
1261
+ function rebuildDebateState(session, ws) {
1262
+ // Find debate_started entry in history
1263
+ var startEntry = null;
1264
+ var endEntry = null;
1265
+ var concludeEntry = null;
1266
+ var lastRound = 1;
1267
+ for (var i = 0; i < session.history.length; i++) {
1268
+ var h = session.history[i];
1269
+ if (h.type === "debate_started") startEntry = h;
1270
+ if (h.type === "debate_ended") endEntry = h;
1271
+ if (h.type === "debate_conclude_confirm") concludeEntry = h;
1272
+ if (h.type === "debate_turn_done" && h.round) lastRound = h.round;
1273
+ }
1274
+ if (!startEntry) return null;
1275
+
1276
+ var userId = ws._clayUser ? ws._clayUser.id : null;
1277
+ var mateCtx = matesModule.buildMateCtx(userId);
1278
+
1279
+ var debate = {
1280
+ phase: endEntry ? "ended" : "live",
1281
+ topic: startEntry.topic || "",
1282
+ format: startEntry.format || "free_discussion",
1283
+ context: "",
1284
+ specialRequests: null,
1285
+ moderatorId: startEntry.moderatorId,
1286
+ panelists: (startEntry.panelists || []).map(function (p) {
1287
+ return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" };
1288
+ }),
1289
+ mateCtx: mateCtx,
1290
+ moderatorSession: null,
1291
+ panelistSessions: {},
1292
+ nameMap: buildDebateNameMap(
1293
+ (startEntry.panelists || []).map(function (p) { return { mateId: p.mateId, role: p.role || "" }; }),
1294
+ mateCtx
1295
+ ),
1296
+ turnInProgress: false,
1297
+ pendingComment: null,
1298
+ round: lastRound,
1299
+ history: [],
1300
+ awaitingConcludeConfirm: !endEntry && !!concludeEntry,
1301
+ debateId: (session.loop && session.loop.loopId) || "debate_rebuilt",
1302
+ };
1303
+
1304
+ // Rebuild debate.history from session history turn entries
1305
+ for (var j = 0; j < session.history.length; j++) {
1306
+ var entry = session.history[j];
1307
+ if (entry.type === "debate_turn_done") {
1308
+ debate.history.push({
1309
+ speaker: entry.role === "moderator" ? "moderator" : "panelist",
1310
+ mateId: entry.mateId,
1311
+ mateName: entry.mateName,
1312
+ role: entry.role || "",
1313
+ text: entry.text || "",
1314
+ });
1315
+ }
1316
+ }
1317
+
1318
+ // If no endEntry and no concludeEntry, check if last moderator turn had no mentions (implicit conclude)
1319
+ if (!endEntry && !concludeEntry && debate.history.length > 0) {
1320
+ var lastTurn = debate.history[debate.history.length - 1];
1321
+ if (lastTurn.speaker === "moderator" && lastTurn.text) {
1322
+ var rebuildMentions = detectMentions(lastTurn.text, debate.nameMap);
1323
+ if (rebuildMentions.length === 0) {
1324
+ debate.awaitingConcludeConfirm = true;
1325
+ console.log("[debate] Last moderator turn had no mentions, setting awaitingConcludeConfirm.");
1326
+ }
1327
+ }
1328
+ }
1329
+
1330
+ session._debate = debate;
1331
+ console.log("[debate] Rebuilt debate state from history. Topic:", debate.topic, "Phase:", debate.phase, "Turns:", debate.history.length);
1332
+ return debate;
1333
+ }
1334
+
1335
+ function handleDebateConcludeResponse(ws, msg) {
1336
+ var session = ctx.getSessionForWs(ws);
1337
+ if (!session) return;
1338
+ var debate = session._debate;
1339
+
1340
+ // If _debate is gone (server restart), try to rebuild from history
1341
+ if (!debate) {
1342
+ debate = rebuildDebateState(session, ws);
1343
+ if (!debate) {
1344
+ console.log("[debate] Cannot rebuild debate state for resume.");
1345
+ return;
1346
+ }
1347
+ }
1348
+
1349
+ // Allow resume from both "live + awaiting confirm" and "ended" states
1350
+ var isLiveConfirm = debate.phase === "live" && debate.awaitingConcludeConfirm;
1351
+ var isResume = debate.phase === "ended" && msg.action === "continue";
1352
+ if (!isLiveConfirm && !isResume) return;
1353
+
1354
+ debate.awaitingConcludeConfirm = false;
1355
+
1356
+ if (msg.action === "end") {
1357
+ endDebate(session, "natural");
1358
+ return;
1359
+ }
1360
+
1361
+ if (msg.action === "continue") {
1362
+ var wasEnded = debate.phase === "ended";
1363
+ debate.phase = "live";
1364
+ var instruction = (msg.text || "").trim();
1365
+ var mateCtx = debate.mateCtx || matesModule.buildMateCtx(ws._clayUser ? ws._clayUser.id : null);
1366
+ debate.mateCtx = mateCtx;
1367
+ var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId);
1368
+
1369
+ // Record user's resume message if provided
1370
+ if (instruction) {
1371
+ var resumeEntry = { type: "debate_user_resume", text: instruction };
1372
+ session.history.push(resumeEntry);
1373
+ ctx.sm.appendToSessionFile(session, resumeEntry);
1374
+ ctx.sendToSession(session.localId, resumeEntry);
1375
+ }
1376
+
1377
+ // Notify clients debate is back live and persist to history
1378
+ var resumedMsg = {
1379
+ type: "debate_resumed",
1380
+ topic: debate.topic,
1381
+ round: debate.round,
1382
+ moderatorId: debate.moderatorId,
1383
+ moderatorName: moderatorProfile.name,
1384
+ panelists: debate.panelists.map(function (p) {
1385
+ var prof = ctx.getMateProfile(mateCtx, p.mateId);
1386
+ return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed };
1387
+ }),
1388
+ };
1389
+ session.history.push(resumedMsg);
1390
+ ctx.sm.appendToSessionFile(session, resumedMsg);
1391
+ ctx.sendToSession(session.localId, resumedMsg);
1392
+
1393
+ debate.turnInProgress = true;
1394
+ ctx.sendToSession(session.localId, {
1395
+ type: "debate_turn",
1396
+ mateId: debate.moderatorId,
1397
+ mateName: moderatorProfile.name,
1398
+ role: "moderator",
1399
+ round: debate.round,
1400
+ avatarColor: moderatorProfile.avatarColor,
1401
+ avatarStyle: moderatorProfile.avatarStyle,
1402
+ avatarSeed: moderatorProfile.avatarSeed,
1403
+ });
1404
+
1405
+ var resumePrompt = instruction
1406
+ ? "[The audience has requested the debate continue with the following direction]\nUser: " + instruction + "\n\n[As moderator, acknowledge this input and call on a panelist with @TheirName to continue the discussion.]"
1407
+ : "[The audience has requested the debate continue. Call on the next panelist with @TheirName to explore additional perspectives.]";
1408
+
1409
+ // If resuming from ended state, moderator session may be dead. Create a new one.
1410
+ if (wasEnded || !debate.moderatorSession || !debate.moderatorSession.isAlive()) {
1411
+ console.log("[debate] Creating new moderator session for resume");
1412
+ var claudeMd = ctx.loadMateClaudeMd(mateCtx, debate.moderatorId);
1413
+ var digests = ctx.loadMateDigests(mateCtx, debate.moderatorId, debate.topic);
1414
+ var moderatorContext = buildModeratorContext(debate) + digests;
1415
+
1416
+ // Include debate history so moderator has context
1417
+ moderatorContext += "\n\nDebate history so far:\n---\n";
1418
+ for (var hi = 0; hi < debate.history.length; hi++) {
1419
+ var h = debate.history[hi];
1420
+ moderatorContext += (h.mateName || h.speaker || "Unknown") + " (" + (h.role || "") + "): " + (h.text || "").slice(0, 500) + "\n\n";
1421
+ }
1422
+ moderatorContext += "---\n";
1423
+
1424
+ ctx.sdk.createMentionSession({
1425
+ claudeMd: claudeMd,
1426
+ initialContext: moderatorContext,
1427
+ initialMessage: resumePrompt,
1428
+ onActivity: function (activity) {
1429
+ if (session._debate && session._debate.phase !== "ended") {
1430
+ ctx.sendToSession(session.localId, { type: "debate_activity", mateId: debate.moderatorId, activity: activity });
1431
+ }
1432
+ },
1433
+ onDelta: function (delta) {
1434
+ if (session._debate && session._debate.phase !== "ended") {
1435
+ ctx.sendToSession(session.localId, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta });
1436
+ }
1437
+ },
1438
+ onDone: function (fullText) {
1439
+ handleModeratorTurnDone(session, fullText);
1440
+ },
1441
+ onError: function (errMsg) {
1442
+ console.error("[debate] Moderator resume error:", errMsg);
1443
+ endDebate(session, "error");
1444
+ },
1445
+ canUseTool: buildDebateToolHandler(session),
1446
+ }).then(function (mentionSession) {
1447
+ if (mentionSession) {
1448
+ debate.moderatorSession = mentionSession;
1449
+ }
1450
+ }).catch(function (err) {
1451
+ console.error("[debate] Failed to create resume moderator session:", err.message || err);
1452
+ endDebate(session, "error");
1453
+ });
1454
+ } else {
1455
+ debate.moderatorSession.pushMessage(resumePrompt, buildModeratorCallbacks(session));
1456
+ }
1457
+ return;
1458
+ }
1459
+ }
1460
+
1461
+ // --- End debate ---
1462
+
1463
+ function endDebate(session, reason) {
1464
+ var debate = session._debate;
1465
+ if (!debate || debate.phase === "ended") return;
1466
+
1467
+ debate.phase = "ended";
1468
+ debate.turnInProgress = false;
1469
+ persistDebateState(session);
1470
+
1471
+ // Clean up brief watcher if still active
1472
+ if (debate._briefWatcher) {
1473
+ try { debate._briefWatcher.close(); } catch (e) {}
1474
+ debate._briefWatcher = null;
1475
+ }
1476
+
1477
+ // Notify clients
1478
+ ctx.sendToSession(session.localId, {
1479
+ type: "debate_ended",
1480
+ reason: reason,
1481
+ rounds: debate.round,
1482
+ topic: debate.topic,
1483
+ });
1484
+
1485
+ // Save to session history
1486
+ var endEntry = { type: "debate_ended", topic: debate.topic, rounds: debate.round, reason: reason };
1487
+ session.history.push(endEntry);
1488
+ ctx.sm.appendToSessionFile(session, endEntry);
1489
+
1490
+ // Generate digests for all participants
1491
+ digestDebateParticipant(session, debate.moderatorId, debate, "moderator");
1492
+ for (var i = 0; i < debate.panelists.length; i++) {
1493
+ digestDebateParticipant(session, debate.panelists[i].mateId, debate, debate.panelists[i].role);
1494
+ }
1495
+ }
1496
+
1497
+ function digestDebateParticipant(session, mateId, debate, role) {
1498
+ var mentionSession = null;
1499
+ if (mateId === debate.moderatorId) {
1500
+ mentionSession = debate.moderatorSession;
1501
+ } else {
1502
+ mentionSession = debate.panelistSessions[mateId];
1503
+ }
1504
+ if (!mentionSession || !mentionSession.isAlive()) return;
1505
+
1506
+ var mateDir = matesModule.getMateDir(debate.mateCtx, mateId);
1507
+ var knowledgeDir = path.join(mateDir, "knowledge");
1508
+
1509
+ // Migration: generate initial summary if missing
1510
+ var summaryFile = path.join(knowledgeDir, "memory-summary.md");
1511
+ var digestFileCheck = path.join(knowledgeDir, "session-digests.jsonl");
1512
+ if (!fs.existsSync(summaryFile) && fs.existsSync(digestFileCheck)) {
1513
+ ctx.initMemorySummary(debate.mateCtx, mateId, function () {});
1514
+ }
1515
+
1516
+ // Debates are user-initiated structured events. The moderator already
1517
+ // synthesizes a summary, so skip the memory gate and always create a digest.
1518
+ (function () {
1519
+ var digestPrompt = [
1520
+ "[SYSTEM: Session Digest]",
1521
+ "Summarize this conversation from YOUR perspective for your long-term memory.",
1522
+ "Output ONLY a single valid JSON object (no markdown, no code fences, no extra text).",
1523
+ "",
1524
+ "Schema:",
1525
+ "{",
1526
+ ' "date": "YYYY-MM-DD",',
1527
+ ' "type": "debate",',
1528
+ ' "topic": "short topic description",',
1529
+ ' "my_position": "what I said/recommended",',
1530
+ ' "decisions": "what was decided, or null if pending",',
1531
+ ' "open_items": "what remains unresolved",',
1532
+ ' "user_sentiment": "how the user seemed to feel",',
1533
+ ' "my_role": "' + role + '",',
1534
+ ' "other_perspectives": "key points from others",',
1535
+ ' "outcome": "how the debate concluded",',
1536
+ ' "confidence": "high | medium | low",',
1537
+ ' "revisit_later": true/false,',
1538
+ ' "tags": ["relevant", "topic", "tags"]',
1539
+ "}",
1540
+ "",
1541
+ "IMPORTANT: Output ONLY the JSON object. Nothing else.",
1542
+ ].join("\n");
1543
+
1544
+ var digestText = "";
1545
+ mentionSession.pushMessage(digestPrompt, {
1546
+ onActivity: function () {},
1547
+ onDelta: function (delta) {
1548
+ digestText += delta;
1549
+ },
1550
+ onDone: function () {
1551
+ var digestObj = null;
1552
+ try {
1553
+ var cleaned = digestText.trim();
1554
+ if (cleaned.indexOf("```") === 0) {
1555
+ cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim();
1556
+ }
1557
+ digestObj = JSON.parse(cleaned);
1558
+ } catch (e) {
1559
+ console.error("[debate-digest] Failed to parse digest JSON for mate " + mateId + ":", e.message);
1560
+ digestObj = {
1561
+ date: new Date().toISOString().slice(0, 10),
1562
+ type: "debate",
1563
+ topic: debate.topic,
1564
+ my_role: role,
1565
+ raw: digestText.substring(0, 500),
1566
+ };
1567
+ }
1568
+
1569
+ try {
1570
+ fs.mkdirSync(knowledgeDir, { recursive: true });
1571
+ var digestFile = path.join(knowledgeDir, "session-digests.jsonl");
1572
+ fs.appendFileSync(digestFile, JSON.stringify(digestObj) + "\n");
1573
+ } catch (e) {
1574
+ console.error("[debate-digest] Failed to write digest for mate " + mateId + ":", e.message);
1575
+ }
1576
+
1577
+ // Update memory summary
1578
+ ctx.updateMemorySummary(debate.mateCtx, mateId, digestObj);
1579
+
1580
+ // Close the session after digest
1581
+ mentionSession.close();
1582
+ },
1583
+ onError: function (err) {
1584
+ console.error("[debate-digest] Digest generation failed for mate " + mateId + ":", err);
1585
+ mentionSession.close();
1586
+ },
1587
+ });
1588
+ })();
1589
+ }
1590
+
1591
+ // --- Public API ---
1592
+
1593
+ return {
1594
+ handleDebateStart: handleDebateStart,
1595
+ handleDebateComment: handleDebateComment,
1596
+ handleDebateStop: handleDebateStop,
1597
+ handleDebateConcludeResponse: handleDebateConcludeResponse,
1598
+ handleDebateConfirmBrief: handleDebateConfirmBrief,
1599
+ restoreDebateState: restoreDebateState,
1600
+ checkForDmDebateBrief: checkForDmDebateBrief,
1601
+ };
1602
+ }
1603
+
1604
+ module.exports = { attachDebate: attachDebate };