var resolveMateIdentityFile = require('./mate-identity-file').resolveMateIdentityFile; var fs = require("fs"); var path = require("path"); var crypto = require("crypto"); var matesModule = require("./mates"); var debateToolPolicy = require("./home-debate-tool-policy"); /** * Attach debate engine to a project context. * * ctx fields: * cwd, slug, send, sendTo, sendToSession, sm, sdk, * getMateProfile, loadMateClaudeMd, loadMateDigests, * hydrateImageRefs, onProcessingChanged, getLinuxUserForSession, getSessionForWs, * updateMemorySummary, initMemorySummary, enqueueDigest */ function attachDebate(ctx) { // For mate projects, enforce latest debate awareness prompt in AGENTS.md // so mates use the propose_debate MCP tool instead of writing files. if (ctx.isMate) { var _debateClaudeMdPath = resolveMateIdentityFile(ctx.cwd); try { matesModule.enforceDebateAwareness(_debateClaudeMdPath); } catch (e) {} } // --- Helpers shared with other modules --- function escapeRegex(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function buildDebateNameMap(panelists, mateCtx) { var nameMap = {}; for (var i = 0; i < panelists.length; i++) { var mate = matesModule.getMate(mateCtx, panelists[i].mateId); if (!mate) continue; var name = (mate.profile && mate.profile.displayName) || mate.name || ""; if (name) { nameMap[name] = panelists[i].mateId; } } return nameMap; } function detectMentions(text, nameMap) { var names = Object.keys(nameMap); // Sort by length descending to match longest name first names.sort(function (a, b) { return b.length - a.length; }); var mentioned = []; // Strip markdown inline formatting so **@Name**, ~~@Name~~, `@Name`, [@Name](url) etc. still match var cleaned = text .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // [text](url) -> text .replace(/`([^`]*)`/g, "$1") // `code` -> code .replace(/(\*{1,3}|_{1,3}|~{2})/g, ""); // bold, italic, strikethrough markers console.log("[debate-mention] nameMap keys:", JSON.stringify(names)); console.log("[debate-mention] text snippet:", cleaned.slice(0, 200)); for (var i = 0; i < names.length; i++) { // Match @Name followed by any non-name character (not alphanumeric, not Korean, not dash/underscore) var pattern = new RegExp("@" + escapeRegex(names[i]) + "(?![\\p{L}\\p{N}_-])", "iu"); var matched = pattern.test(cleaned); console.log("[debate-mention] testing @" + names[i] + " pattern=" + pattern.toString() + " matched=" + matched); if (matched) { var mateId = nameMap[names[i]]; if (mentioned.indexOf(mateId) === -1) { mentioned.push(mateId); } } } return mentioned; } // --- Context builders --- function buildModeratorContext(debate) { var lines = [ "You are moderating a structured debate among your AI teammates.", "", "Topic: " + debate.topic, "Format: " + debate.format, "Context: " + debate.context, ]; if (debate.specialRequests) { lines.push("Special requests: " + debate.specialRequests); } lines.push(""); lines.push("Panelists:"); for (var i = 0; i < debate.panelists.length; i++) { var p = debate.panelists[i]; var profile = ctx.getMateProfile(debate.mateCtx, p.mateId); lines.push("- @" + profile.name + " (" + p.role + "): " + p.brief); } lines.push(""); lines.push("RULES:"); lines.push("1. To call on a panelist, mention them with @TheirName in your response."); lines.push("2. Only mention ONE panelist per response. Wait for their answer before calling the next."); lines.push("3. When you mention a panelist, clearly state what you want them to address."); lines.push("4. After hearing from all panelists, you may start additional rounds."); 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."); lines.push("6. If the user interjects with a comment, acknowledge it and weave it into the discussion."); lines.push("7. You govern debate tool access: non-mutating investigation is permitted and shown to the user, while any capability that could change the system is blocked."); lines.push(""); lines.push("Begin by introducing the topic and calling on the first panelist."); return lines.join("\n"); } function buildPanelistContext(debate, panelistInfo) { var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); var lines = [ "You are participating in a structured debate as a panelist.", "", "Topic: " + debate.topic, "Your role: " + panelistInfo.role, "Your brief: " + panelistInfo.brief, "", "Other panelists:", ]; for (var i = 0; i < debate.panelists.length; i++) { var p = debate.panelists[i]; if (p.mateId === panelistInfo.mateId) continue; var profile = ctx.getMateProfile(debate.mateCtx, p.mateId); lines.push("- @" + profile.name + " (" + p.role + "): " + p.brief); } lines.push(""); lines.push("The moderator is @" + moderatorProfile.name + ". They will call on you when it is your turn."); lines.push(""); lines.push("RULES:"); lines.push("1. Stay in your assigned role and perspective."); lines.push("2. Respond to the specific question or prompt from the moderator."); lines.push("3. You may reference what other panelists have said."); lines.push("4. Keep responses focused and substantive. Do not ramble."); lines.push("5. You may use investigation tools when they strengthen your argument. The moderator automatically permits non-mutating research and blocks system changes."); return lines.join("\n"); } function buildDebateToolHandler(session, mateId) { return function (toolName, input, toolOpts) { var decision = debateToolPolicy.debateToolDecision(toolName, input); var debate = session && session._debate; var profile = debate ? ctx.getMateProfile(debate.mateCtx, mateId) : null; debateSendAndRecord(session, { type: "debate_tool_decision", decisionId: toolOpts && toolOpts.toolUseID ? toolOpts.toolUseID : crypto.randomUUID(), mateId: mateId || null, mateName: profile && profile.name ? profile.name : "Mate", toolName: debateToolPolicy.displayToolName(toolName), action: decision.action, command: decision.command, commandTruncated: decision.commandTruncated, decision: decision.allowed ? "allowed" : "blocked", reason: decision.reason, }); return Promise.resolve({ behavior: decision.behavior, updatedInput: decision.updatedInput, message: decision.message, }); }; } function participantModel(debate, mateId) { var selections = debate && Array.isArray(debate.participantModels) ? debate.participantModels : []; for (var i = 0; i < selections.length; i++) { if (selections[i] && selections[i].mateId === mateId) return selections[i]; } return null; } // --- State persistence --- function persistDebateState(session) { if (!session._debate) return; var d = session._debate; session.debateState = { phase: d.phase, topic: d.topic, format: d.format, context: d.context || "", specialRequests: d.specialRequests || null, moderatorId: d.moderatorId, participantModels: Array.isArray(d.participantModels) ? d.participantModels.map(function (item) { return { mateId: item.mateId, vendor: item.vendor, model: item.model }; }) : [], panelists: d.panelists.map(function (p) { return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" }; }), briefPath: d.briefPath || null, debateId: d.debateId || null, setupSessionId: d.setupSessionId || null, setupStartedAt: d.setupStartedAt || null, round: d.round || 1, awaitingConcludeConfirm: !!d.awaitingConcludeConfirm, awaitingUserFloor: !!d.awaitingUserFloor, ownerId: d.ownerId || null, turnSequence: d.turnSequence || 0, }; ctx.sm.saveSessionFile(session); } function restoreDebateFromState(session, restoreUserId) { var ds = session.debateState; if (!ds) return null; var userId = restoreUserId || ds.ownerId || null; var mateCtx = matesModule.buildMateCtx(userId); var debate = { phase: ds.phase, topic: ds.topic, format: ds.format, context: ds.context || "", specialRequests: ds.specialRequests || null, moderatorId: ds.moderatorId, participantModels: Array.isArray(ds.participantModels) ? ds.participantModels : [], panelists: ds.panelists || [], mateCtx: mateCtx, moderatorSession: null, panelistSessions: {}, nameMap: buildDebateNameMap(ds.panelists || [], mateCtx), turnInProgress: false, pendingComment: null, round: ds.round || 1, history: [], setupSessionId: ds.setupSessionId || null, debateId: ds.debateId || null, setupStartedAt: ds.setupStartedAt || null, briefPath: ds.briefPath || null, awaitingConcludeConfirm: !!ds.awaitingConcludeConfirm, awaitingUserFloor: !!ds.awaitingUserFloor, ownerId: ds.ownerId || userId, turnSequence: ds.turnSequence || 0, }; // Fallback: if awaitingConcludeConfirm was not persisted, detect from history if (!debate.awaitingConcludeConfirm && ds.phase === "live") { var hasEnded = false; var hasConclude = false; var lastModText = null; for (var i = 0; i < session.history.length; i++) { var h = session.history[i]; if (h.type === "debate_ended") hasEnded = true; if (h.type === "debate_conclude_confirm") hasConclude = true; if (h.type === "debate_turn_done" && h.role === "moderator") lastModText = h.text || ""; } // conclude_confirm in history without a subsequent ended = still awaiting user decision if (hasConclude && !hasEnded) { debate.awaitingConcludeConfirm = true; } else if (!hasEnded && !hasConclude && lastModText !== null) { // No explicit entry yet; infer from last moderator text having no @mentions var mentions = detectMentions(lastModText, debate.nameMap); if (mentions.length === 0) { debate.awaitingConcludeConfirm = true; } } } session._debate = debate; return debate; } // --- Brief watcher --- function startDebateBriefWatcher(session, debate, briefPath) { if (!briefPath) { console.error("[debate] No briefPath provided to watcher"); return; } // Persist briefPath on debate so restoration can reuse it debate.briefPath = briefPath; var watchDir = path.dirname(briefPath); var briefFilename = path.basename(briefPath); // Clean up any existing watcher if (debate._briefWatcher) { try { debate._briefWatcher.close(); } catch (e) {} debate._briefWatcher = null; } if (debate._briefDebounce) { clearTimeout(debate._briefDebounce); debate._briefDebounce = null; } function checkDebateBrief() { try { var raw = fs.readFileSync(briefPath, "utf8"); var brief = JSON.parse(raw); // Stop watching if (debate._briefWatcher) { debate._briefWatcher.close(); debate._briefWatcher = null; } if (debate._briefDebounce) { clearTimeout(debate._briefDebounce); debate._briefDebounce = null; } // Clean up the brief file try { fs.unlinkSync(briefPath); } catch (e) {} // Apply brief to debate state debate.topic = brief.topic || debate.topic; debate.format = brief.format || debate.format; debate.context = brief.context || ""; debate.specialRequests = brief.specialRequests || null; // Replace panelists with those selected in the brief if (brief.panelists && brief.panelists.length) { debate.panelists = brief.panelists.map(function (bp) { return { mateId: bp.mateId, role: bp.role || "", brief: bp.brief || "" }; }); } // Rebuild name map with updated roles var mateCtx = debate.mateCtx || matesModule.buildMateCtx(null); debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx); // quickStart from DM or no setupSessionId: show brief card for user approval if (!debate.setupSessionId || debate.quickStart) { console.log("[debate] Brief picked up, entering review phase. Topic:", debate.topic); debate.phase = "reviewing"; persistDebateState(session); var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId); var briefReadyMsg = { type: "debate_brief_ready", debateId: debate.debateId, topic: debate.topic, format: debate.format || "free_discussion", context: debate.context || "", specialRequests: debate.specialRequests || null, moderatorId: debate.moderatorId, moderatorName: moderatorProfile.name, panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, name: prof.name, role: p.role || "", brief: p.brief || "" }; }), }; ctx.sendToSession(session.localId, briefReadyMsg); // Also send to setup session if client switched there (quickStart flow) if (debate.setupSessionId && debate.setupSessionId !== session.localId) { ctx.sendToSession(debate.setupSessionId, briefReadyMsg); } } else { console.log("[debate] Brief picked up, transitioning to live. Topic:", debate.topic); // Transition to live (standard flow via modal/skill) startDebateLive(session); } } catch (e) { // File not ready yet or invalid JSON, keep watching } } try { try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {} debate._briefWatcher = fs.watch(watchDir, function (eventType, filename) { if (filename === briefFilename) { if (debate._briefDebounce) clearTimeout(debate._briefDebounce); debate._briefDebounce = setTimeout(checkDebateBrief, 300); } }); debate._briefWatcher.on("error", function () {}); console.log("[debate] Watching for " + briefFilename + " at " + watchDir); } catch (e) { console.error("[debate] Failed to watch " + watchDir + ":", e.message); } // Check immediately in case the file already exists (server restart scenario) checkDebateBrief(); } // --- Restore debate on reconnect --- function restoreDebateState(ws) { // On server restart, SDK sessions are lost so debates cannot continue. // Clear stale debate state instead of restoring dead UI. ctx.sm.sessions.forEach(function (session) { // A live in-memory debate belongs to this daemon and must survive a // websocket reconnect. Persisted state without its runtime object is // the restart case that cannot safely resume dead mention processes. if (session._debate) return; if (session.debateState) { var phase = session.debateState.phase; if (session.homeDebatePlanning === true && (phase === "live" || phase === "ending")) { session.homeDebatePhase = "interrupted"; session.debateState.phase = "interrupted"; var lastLifecycle = null; for (var li = session.history.length - 1; li >= 0; li--) { var lifecycleType = session.history[li] && session.history[li].type; if (lifecycleType === "debate_started" || lifecycleType === "debate_resumed" || lifecycleType === "debate_ended") { lastLifecycle = lifecycleType; break; } } if (lastLifecycle !== "debate_ended") session.history.push({ type: "debate_ended", topic: session.debateState.topic || session.title || "Debate", rounds: session.debateState.round || 1, reason: "interrupted", _ts: Date.now() }); ctx.sm.saveSessionFile(session); return; } if (phase === "preparing" || phase === "reviewing" || phase === "live") { console.log("[debate] Clearing stale debate state:", session.debateState.topic); session.debateState = null; ctx.sm.saveSessionFile(session); } } }); } // --- Check for DM debate brief --- function checkForDmDebateBrief(session, mateId, mateCtx) { // Skip if there's already an active debate on this session if (session._debate && (session._debate.phase === "preparing" || session._debate.phase === "reviewing" || session._debate.phase === "live")) return; var debatesDir = path.join(ctx.cwd, ".clay", "debates"); var dirs; try { dirs = fs.readdirSync(debatesDir); } catch (e) { return; // No debates directory } for (var i = 0; i < dirs.length; i++) { var briefPath = path.join(debatesDir, dirs[i], "brief.json"); var raw; try { raw = fs.readFileSync(briefPath, "utf8"); } catch (e) { continue; // No brief.json in this dir } var brief; try { brief = JSON.parse(raw); } catch (e) { continue; // Invalid JSON } // Found a valid brief - create debate state var debateId = dirs[i]; console.log("[debate] Found DM debate brief from mate " + mateId + ", debateId:", debateId); // Clean up the brief file try { fs.unlinkSync(briefPath); } catch (e) {} var debate = { phase: "reviewing", topic: brief.topic || "Untitled debate", format: brief.format || "free_discussion", context: brief.context || "", specialRequests: brief.specialRequests || null, moderatorId: mateId, panelists: (brief.panelists || []).map(function (p) { return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" }; }), mateCtx: mateCtx, moderatorSession: null, panelistSessions: {}, nameMap: null, turnInProgress: false, pendingComment: null, round: 1, history: [], setupSessionId: null, debateId: debateId, briefPath: briefPath, ownerId: mateCtx.userId || null, }; debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx); session._debate = debate; persistDebateState(session); var moderatorProfile = ctx.getMateProfile(mateCtx, mateId); ctx.sendToSession(session.localId, { type: "debate_brief_ready", debateId: debateId, topic: debate.topic, format: debate.format, context: debate.context, specialRequests: debate.specialRequests, moderatorId: mateId, moderatorName: moderatorProfile.name, panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, name: prof.name, role: p.role || "", brief: p.brief || "" }; }), }); return; // Only process first brief found } } // --- Main debate handlers --- function handleDebateStart(ws, msg) { var session = ctx.getSessionForWs(ws); if (!session) return; if (!msg.moderatorId || !msg.topic) { ctx.sendTo(ws, { type: "debate_error", error: "Missing required fields: moderatorId, topic." }); return; } // delegatePanelists: moderator picks panelists, populate all available mates if (msg.delegatePanelists) { var userId = ws._clayUser ? ws._clayUser.id : null; var tmpCtx = matesModule.buildMateCtx(userId); var matesData = matesModule.loadMates(tmpCtx); var allMates = matesData.mates || []; msg.panelists = []; for (var mi = 0; mi < allMates.length; mi++) { if (allMates[mi].id !== msg.moderatorId && allMates[mi].status !== "interviewing") { msg.panelists.push({ mateId: allMates[mi].id, role: "", brief: "" }); } } } if (!msg.panelists || !msg.panelists.length) { ctx.sendTo(ws, { type: "debate_error", error: "No panelists available." }); return; } if (session._debate && (session._debate.phase === "live" || session._debate.phase === "preparing")) { ctx.sendTo(ws, { type: "debate_error", error: "A debate is already in progress." }); return; } // Block mentions during debate if (session._mentionInProgress) { ctx.sendTo(ws, { type: "debate_error", error: "A mention is in progress. Wait for it to finish." }); return; } var userId = ws._clayUser ? ws._clayUser.id : null; var mateCtx = matesModule.buildMateCtx(userId); var moderatorProfile = ctx.getMateProfile(mateCtx, msg.moderatorId); // --- Phase 1: Preparing (clay-debate-setup skill) --- var debate = { phase: "preparing", topic: msg.topic, format: "free_discussion", context: "", specialRequests: null, moderatorId: msg.moderatorId, panelists: msg.panelists, mateCtx: mateCtx, moderatorSession: null, panelistSessions: {}, nameMap: buildDebateNameMap(msg.panelists, mateCtx), turnInProgress: false, pendingComment: null, round: 1, history: [], setupSessionId: null, ownerId: userId, }; session._debate = debate; var debateId = "debate_" + Date.now(); var debateDir = path.join(ctx.cwd, ".clay", "debates", debateId); try { fs.mkdirSync(debateDir, { recursive: true }); } catch (e) {} var briefPath = path.join(debateDir, "brief.json"); console.log("[debate] cwd=" + ctx.cwd + " debateDir=" + debateDir + " briefPath=" + briefPath); debate.debateId = debateId; debate.briefPath = briefPath; if (msg.quickStart) { // --- Quick Start: moderator mate generates brief from DM context --- handleDebateQuickStart(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath); } else { // --- Standard: clay-debate-setup skill --- handleDebateSkillSetup(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath); } } // Quick start: moderator mate uses DM conversation context to generate the debate brief directly function handleDebateQuickStart(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath) { debate.quickStart = true; var debateId = debate.debateId; // Create setup session (still needed for session grouping) var setupOpts = debate.ownerId ? { ownerId: debate.ownerId } : null; var setupSession = ctx.sm.createSession(setupOpts); setupSession.title = "Debate Setup: " + (msg.topic || "Quick").slice(0, 40); setupSession.debateSetupMode = true; setupSession.loop = { active: true, iteration: 0, role: "crafting", loopId: debateId, name: (msg.topic || "Quick").slice(0, 40), source: "debate", startedAt: Date.now() }; ctx.sm.saveSessionFile(setupSession); ctx.sm.switchSession(setupSession.localId, null, ctx.hydrateImageRefs); debate.setupSessionId = setupSession.localId; debate.setupStartedAt = setupSession.loop.startedAt; // Share debate state with setup session so confirm_brief works from either setupSession._debate = debate; // Build DM conversation context for the moderator var dmContext = msg.dmContext || ""; // Build panelist info var panelistInfo = msg.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return "- " + (prof.name || p.mateId) + " (ID: " + p.mateId + ", bio: " + (prof.bio || "none") + ")"; }).join("\n"); var quickBriefPrompt = [ "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.", "", "## Recent DM Conversation", dmContext, "", "## Topic Suggestion", msg.topic || "(Derive from conversation above)", "", "## Available Panelists", panelistInfo, "", "## Your Task", "Based on the conversation context, create a debate brief. You know the topic well because you were just discussing it.", msg.delegatePanelists ? "IMPORTANT: Select only 2-4 panelists who are most relevant to this specific topic. Do NOT include all of them. Be selective. Only pick mates whose expertise or personality directly contributes to this debate." : "The user already selected these panelists. Assign each one a role and perspective that will create the most productive debate.", "", "Output ONLY a valid JSON object (no markdown fences, no extra text):", "{", ' "topic": "refined debate topic",', ' "format": "free_discussion",', ' "context": "key context from DM conversation that panelists should know",', ' "specialRequests": "any special instructions (null if none)",', ' "panelists": [', ' { "mateId": "...", "role": "perspective/stance", "brief": "what this panelist should argue for" }', " ]", "}", ].join("\n"); // Persist and start watcher persistDebateState(session); startDebateBriefWatcher(session, debate, briefPath); // Notify clients var preparingMsg = { type: "debate_preparing", topic: debate.topic || "(Setting up...)", moderatorId: debate.moderatorId, moderatorName: moderatorProfile.name, setupSessionId: setupSession.localId, panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, name: prof.name }; }), }; ctx.sendTo(ws, preparingMsg); ctx.sendToSession(session.localId, preparingMsg); ctx.sendToSession(setupSession.localId, preparingMsg); // Use moderator's own Claude identity to generate the brief via mention session var claudeMd = ctx.loadMateClaudeMd(mateCtx, debate.moderatorId); var digests = ctx.loadMateDigests(mateCtx, debate.moderatorId, debate.topic); var briefText = ""; var _modMate = matesModule.getMate(mateCtx, debate.moderatorId); ctx.sdk.createMentionSession({ vendor: _modMate ? _modMate.vendor : null, linuxUser: mateCtx.linuxUser || undefined, claudeMd: claudeMd, initialContext: digests, initialMessage: quickBriefPrompt, onActivity: function () {}, onDelta: function (delta) { briefText += delta; }, onDone: function () { try { var cleaned = briefText.trim(); if (cleaned.indexOf("```") === 0) { cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim(); } // Validate it is parseable JSON JSON.parse(cleaned); // Write brief.json for the watcher to pick up fs.writeFileSync(briefPath, cleaned, "utf8"); console.log("[debate-quick] Moderator generated brief, wrote to " + briefPath); } catch (e) { console.error("[debate-quick] Failed to generate brief:", e.message); console.error("[debate-quick] Raw output:", briefText.substring(0, 500)); // Fall back: write a minimal brief var fallbackBrief = { topic: debate.topic || "Discussion", format: "free_discussion", context: "", specialRequests: null, panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, role: "participant", brief: "Share your perspective on the topic." }; }), }; try { fs.writeFileSync(briefPath, JSON.stringify(fallbackBrief), "utf8"); console.log("[debate-quick] Wrote fallback brief"); } catch (fe) { console.error("[debate-quick] Failed to write fallback brief:", fe.message); endDebate(session, "error"); } } }, onError: function (err) { console.error("[debate-quick] Moderator brief generation failed:", err); endDebate(session, "error"); }, }); } // Standard debate setup via clay-debate-setup skill function handleDebateSkillSetup(ws, session, debate, msg, mateCtx, moderatorProfile, briefPath) { var debateId = debate.debateId; // Create a new session for the setup skill (like Ralph crafting) var skillSetupOpts = debate.ownerId ? { ownerId: debate.ownerId } : null; var setupSession = ctx.sm.createSession(skillSetupOpts); setupSession.title = "Debate Setup: " + msg.topic.slice(0, 40); setupSession.debateSetupMode = true; setupSession.loop = { active: true, iteration: 0, role: "crafting", loopId: debateId, name: msg.topic.slice(0, 40), source: "debate", startedAt: Date.now() }; ctx.sm.saveSessionFile(setupSession); ctx.sm.switchSession(setupSession.localId, null, ctx.hydrateImageRefs); debate.setupSessionId = setupSession.localId; debate.setupStartedAt = setupSession.loop.startedAt; // Build panelist info for the skill prompt var panelistNames = msg.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return prof.name || p.mateId; }).join(", "); var craftingPrompt = "Use the /clay-debate-setup skill to prepare a structured debate. " + "You MUST invoke the clay-debate-setup skill. Do NOT start the debate yourself.\n\n" + "## Initial Topic\n" + msg.topic + "\n\n" + "## Moderator\n" + (moderatorProfile.name || msg.moderatorId) + "\n\n" + "## Selected Panelists\n" + msg.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return "- " + (prof.name || p.mateId) + " (ID: " + p.mateId + ")"; }).join("\n") + "\n\n" + "## Debate Brief Output Path\n" + "When the setup is complete, write the debate brief JSON to this EXACT absolute path:\n" + "`" + briefPath + "`\n" + "This is where the debate engine watches for the file. Do NOT write it anywhere else.\n\n" + "## Spoken Language\nKorean (unless user switches)"; // Persist debate state before starting watcher persistDebateState(session); // Watch for brief.json in the debate-specific directory startDebateBriefWatcher(session, debate, briefPath); // Notify clients that setup is in progress var preparingMsg = { type: "debate_preparing", topic: debate.topic || "(Setting up...)", moderatorId: debate.moderatorId, moderatorName: moderatorProfile.name, setupSessionId: setupSession.localId, panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, name: prof.name }; }), }; ctx.sendTo(ws, preparingMsg); ctx.sendToSession(session.localId, preparingMsg); // Start the setup skill session (don't send user_message to client — it's an internal prompt) setupSession.history.push({ type: "user_message", text: craftingPrompt, _internal: true }); ctx.sm.appendToSessionFile(setupSession, { type: "user_message", text: craftingPrompt, _internal: true }); setupSession.isProcessing = true; ctx.onProcessingChanged(); setupSession.sentToolResults = {}; ctx.sendToSession(setupSession.localId, { type: "status", status: "processing" }); ctx.sdk.startQuery(setupSession, craftingPrompt, undefined, ctx.getLinuxUserForSession(setupSession)); } // --- Mate strip processing indicator --- // Broadcast mention_processing so the correct mate's active dot lights up // on the mate strip during debate turns (instead of always the moderator's). function debateMateProcessing(mateId, active) { ctx.send({ type: "mention_processing", mateId: mateId, active: active }); } // Persist a debate message to session history and send to clients function debateSendAndRecord(session, msg) { var debate = session._debate; if (debate && msg.mateId && (msg.type === "debate_turn" || msg.type === "debate_turn_done")) { var identity = ctx.getMateProfile(debate.mateCtx, msg.mateId); var identityMate = matesModule.getMate(debate.mateCtx, msg.mateId); var identityProfile = identityMate && identityMate.profile ? identityMate.profile : (identityMate || {}); if (!msg.mateName) msg.mateName = identity.name; if (!msg.avatarCatalogId) msg.avatarCatalogId = identityProfile.avatarCatalogId || ""; if (!msg.avatarStyle) msg.avatarStyle = identity.avatarStyle; if (!msg.avatarSeed) msg.avatarSeed = identity.avatarSeed; if (!msg.avatarColor) msg.avatarColor = identity.avatarColor; if (!msg.avatarCustom && identityProfile.avatarCustom) msg.avatarCustom = identityProfile.avatarCustom; } if (debate && msg.type === "debate_turn") { debate.turnSequence = (debate.turnSequence || 0) + 1; debate.activeTurnId = (debate.debateId || "debate") + ":" + debate.turnSequence; msg.turnId = debate.activeTurnId; } else if (debate && !msg.turnId && (msg.type === "debate_activity" || msg.type === "debate_stream" || msg.type === "debate_turn_done")) { msg.turnId = debate.activeTurnId || null; } ctx.sm.sendAndRecord(session, msg); if (debate && msg.type === "debate_turn_done") debate.activeTurnId = null; } // --- Live debate --- function startDebateLive(session, targetWs) { var debate = session._debate; if (!debate || debate.phase === "live") return; debate.phase = "live"; debate.turnInProgress = true; debate.round = 1; var mateCtx = debate.mateCtx; var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId); var reuseHomeSession = session.homeDebatePlanning === true; var debateSession = session; if (reuseHomeSession) { session.homeDebatePhase = "live"; session.title = "Debate: " + debate.topic.slice(0, 42); session.titleManuallySet = true; session.isProcessing = false; ctx.onProcessingChanged(); if (session.abortController) { try { session.abortController.abort(); } catch (e) {} } if (session.queryInstance && typeof session.queryInstance.close === "function") { try { session.queryInstance.close(); } catch (e) {} } session.abortController = null; session.queryInstance = null; if (session.messageQueue && typeof session.messageQueue.end === "function") { try { session.messageQueue.end(); } catch (e) {} } session.messageQueue = null; if (targetWs) targetWs._clayActiveSession = session.localId; } else { // Legacy project debates retain their dedicated live session. var liveOpts = debate.ownerId ? { ownerId: debate.ownerId } : null; debateSession = ctx.sm.createSession(liveOpts, targetWs || null); debateSession.title = debate.topic.slice(0, 50); debateSession.loop = { active: true, iteration: 1, role: "debate", loopId: debate.debateId, name: debate.topic.slice(0, 40), source: "debate", startedAt: debate.setupStartedAt || Date.now() }; ctx.sm.saveSessionFile(debateSession); ctx.sm.switchSession(debateSession.localId, null, ctx.hydrateImageRefs); debate.liveSessionId = debateSession.localId; debateSession._debate = debate; delete session._debate; session.debateState = null; ctx.sm.saveSessionFile(session); } persistDebateState(debateSession); // Save to session history var debateStartEntry = { type: "debate_started", topic: debate.topic, format: debate.format, moderatorId: debate.moderatorId, moderatorName: moderatorProfile.name, participantModels: Array.isArray(debate.participantModels) ? debate.participantModels.map(function (item) { return { mateId: item.mateId, vendor: item.vendor, model: item.model }; }) : [], panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed }; }), }; debateSendAndRecord(debateSession, debateStartEntry); // Signal moderator's first turn debateMateProcessing(debate.moderatorId, true); debateSendAndRecord(debateSession, { type: "debate_turn", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, avatarColor: moderatorProfile.avatarColor, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, }); // Create moderator mention session var claudeMd = ctx.loadMateClaudeMd(mateCtx, debate.moderatorId); var digests = ctx.loadMateDigests(mateCtx, debate.moderatorId, debate.topic); var moderatorContext = buildModeratorContext(debate) + digests; var _modMate2 = matesModule.getMate(mateCtx, debate.moderatorId); var moderatorModel = participantModel(debate, debate.moderatorId); ctx.sdk.createMentionSession({ vendor: moderatorModel ? moderatorModel.vendor : (_modMate2 ? _modMate2.vendor : null), model: moderatorModel ? moderatorModel.model : undefined, linuxUser: mateCtx.linuxUser || undefined, claudeMd: claudeMd, initialContext: moderatorContext, initialMessage: "Begin the debate on: " + debate.topic, onActivity: function (activity) { if (debateSession._debate && debateSession._debate.phase !== "ended") { debateSendAndRecord(debateSession, { type: "debate_activity", mateId: debate.moderatorId, activity: activity }); } }, onDelta: function (delta) { if (debateSession._debate && debateSession._debate.phase !== "ended") { debateSendAndRecord(debateSession, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta }); } }, onDone: function (fullText) { handleModeratorTurnDone(debateSession, fullText); }, onError: function (errMsg) { console.error("[debate] Moderator error:", errMsg); endDebate(debateSession, "error"); }, canUseTool: buildDebateToolHandler(debateSession, debate.moderatorId), }).then(function (mentionSession) { if (mentionSession) { debate.moderatorSession = mentionSession; } }).catch(function (err) { console.error("[debate] Failed to create moderator session:", err.message || err); endDebate(debateSession, "error"); }); } // --- Turn management --- function handleModeratorTurnDone(session, fullText) { var debate = session._debate; if (!debate || debate.phase === "ended") return; debateMateProcessing(debate.moderatorId, false); debate.turnInProgress = false; // Record in debate history var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); debate.history.push({ speaker: "moderator", mateId: debate.moderatorId, mateName: moderatorProfile.name, text: fullText }); // Save to session history 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 }; debateSendAndRecord(session, turnEntry); // Check if user stopped the debate during this turn if (debate.phase === "ending") { endDebate(session, "user_stopped"); return; } // Detect @mentions console.log("[debate] nameMap keys:", JSON.stringify(Object.keys(debate.nameMap))); console.log("[debate] moderator text (last 200):", fullText.slice(-200)); var mentionedIds = detectMentions(fullText, debate.nameMap); console.log("[debate] detected mentions:", JSON.stringify(mentionedIds)); if (mentionedIds.length === 0) { // No mentions = moderator wants to conclude. Ask user to confirm. console.log("[debate] No mentions detected, requesting user confirmation to end."); debate.turnInProgress = false; debate.awaitingConcludeConfirm = true; persistDebateState(session); var concludeEntry = { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round }; debateSendAndRecord(session, concludeEntry); return; } // Check for pending user comment before triggering panelist if (debate.pendingComment) { injectUserComment(session); return; } // Check if user raised hand if (debate.handRaised) { yieldFloorToUser(session); return; } // Trigger the first mentioned panelist triggerPanelist(session, mentionedIds[0], fullText); } function triggerPanelist(session, mateId, moderatorText) { var debate = session._debate; if (!debate || debate.phase === "ended") return; debate.turnInProgress = true; debate._currentTurnMateId = mateId; debate._currentTurnText = ""; var profile = ctx.getMateProfile(debate.mateCtx, mateId); var panelistInfo = null; for (var i = 0; i < debate.panelists.length; i++) { if (debate.panelists[i].mateId === mateId) { panelistInfo = debate.panelists[i]; break; } } if (!panelistInfo) { console.error("[debate] Panelist not found:", mateId); debateMateProcessing(mateId, false); debate._currentTurnMateId = null; // Feed error back to moderator feedBackToModerator(session, mateId, "[This panelist is not part of the debate panel.]"); return; } // Notify clients of new turn debateMateProcessing(mateId, true); debateSendAndRecord(session, { type: "debate_turn", mateId: mateId, mateName: profile.name, role: panelistInfo.role, round: debate.round, avatarColor: profile.avatarColor, avatarStyle: profile.avatarStyle, avatarSeed: profile.avatarSeed, }); var panelistCallbacks = { onActivity: function (activity) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_activity", mateId: mateId, activity: activity }); } }, onDelta: function (delta) { if (session._debate && session._debate.phase !== "ended") { debate._currentTurnText += delta; debateSendAndRecord(session, { type: "debate_stream", mateId: mateId, mateName: profile.name, delta: delta }); } }, onDone: function (fullText) { handlePanelistTurnDone(session, mateId, fullText); }, onError: function (errMsg) { console.error("[debate] Panelist error for " + mateId + ":", errMsg); debateMateProcessing(mateId, false); debate.turnInProgress = false; // Feed error back to moderator so the debate can continue feedBackToModerator(session, mateId, "[" + profile.name + " encountered an error and could not respond. Please continue with other panelists or wrap up.]"); }, }; // Check for existing session var existing = debate.panelistSessions[mateId]; if (existing && existing.isAlive()) { // Build recent debate context for continuation var recentHistory = ""; var lastPanelistIdx = -1; for (var hi = debate.history.length - 1; hi >= 0; hi--) { if (debate.history[hi].mateId === mateId) { lastPanelistIdx = hi; break; } } if (lastPanelistIdx >= 0 && lastPanelistIdx < debate.history.length - 1) { recentHistory = "\n\n[Debate turns since your last response:]\n---\n"; for (var hj = lastPanelistIdx + 1; hj < debate.history.length; hj++) { var h = debate.history[hj]; recentHistory += h.mateName + " (" + (h.speaker === "moderator" ? "moderator" : h.role || h.speaker) + "): " + h.text.substring(0, 500) + "\n\n"; } recentHistory += "---"; } var continuationMsg = recentHistory + "\n\n[The moderator is now addressing you. Please respond.]\n\nModerator said:\n" + moderatorText; existing.pushMessage(continuationMsg, panelistCallbacks); } else { // Create new panelist session var claudeMd = ctx.loadMateClaudeMd(debate.mateCtx, mateId); var digests = ctx.loadMateDigests(debate.mateCtx, mateId, debate.topic); var panelistContext = buildPanelistContext(debate, panelistInfo) + digests; // Include debate history so far for context var historyContext = ""; if (debate.history.length > 0) { historyContext = "\n\n[Debate so far:]\n---\n"; for (var hk = 0; hk < debate.history.length; hk++) { var he = debate.history[hk]; historyContext += he.mateName + " (" + (he.speaker === "moderator" ? "moderator" : he.role || he.speaker) + "): " + he.text.substring(0, 500) + "\n\n"; } historyContext += "---"; } var _panMate = matesModule.getMate(debate.mateCtx, mateId); var panelistModel = participantModel(debate, mateId); ctx.sdk.createMentionSession({ vendor: panelistModel ? panelistModel.vendor : (_panMate ? _panMate.vendor : null), model: panelistModel ? panelistModel.model : undefined, linuxUser: debate.mateCtx && debate.mateCtx.linuxUser ? debate.mateCtx.linuxUser : undefined, claudeMd: claudeMd, initialContext: panelistContext + historyContext, initialMessage: "The moderator addresses you:\n\n" + moderatorText, onActivity: panelistCallbacks.onActivity, onDelta: panelistCallbacks.onDelta, onDone: panelistCallbacks.onDone, onError: panelistCallbacks.onError, canUseTool: buildDebateToolHandler(session, mateId), }).then(function (mentionSession) { if (mentionSession) { debate.panelistSessions[mateId] = mentionSession; } }).catch(function (err) { console.error("[debate] Failed to create panelist session for " + mateId + ":", err.message || err); debateMateProcessing(mateId, false); debate.turnInProgress = false; feedBackToModerator(session, mateId, "[" + profile.name + " is unavailable. Please continue with other panelists or wrap up.]"); }); } } function handlePanelistTurnDone(session, mateId, fullText) { var debate = session._debate; if (!debate || debate.phase === "ended") return; debateMateProcessing(mateId, false); debate.turnInProgress = false; debate._currentTurnMateId = null; debate._currentTurnText = ""; var profile = ctx.getMateProfile(debate.mateCtx, mateId); var panelistInfo = null; for (var i = 0; i < debate.panelists.length; i++) { if (debate.panelists[i].mateId === mateId) { panelistInfo = debate.panelists[i]; break; } } // Record in debate history debate.history.push({ speaker: "panelist", mateId: mateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", text: fullText }); // Save to session history 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 }; debateSendAndRecord(session, turnEntry); // Check if user stopped the debate if (debate.phase === "ending") { endDebate(session, "user_stopped"); return; } // Check for pending user comment (legacy) if (debate.pendingComment) { injectUserComment(session); return; } // Check if user raised hand (no comment, just wants the floor) if (debate.handRaised) { yieldFloorToUser(session); return; } // Feed panelist response back to moderator feedBackToModerator(session, mateId, fullText); } function feedBackToModerator(session, panelistMateId, panelistText) { var debate = session._debate; if (!debate || !debate.moderatorSession || debate.phase === "ended") return; debate.round++; debate.turnInProgress = true; var panelistProfile = ctx.getMateProfile(debate.mateCtx, panelistMateId); var panelistInfo = null; for (var i = 0; i < debate.panelists.length; i++) { if (debate.panelists[i].mateId === panelistMateId) { panelistInfo = debate.panelists[i]; break; } } var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); // Notify clients of moderator turn debateMateProcessing(debate.moderatorId, true); debateSendAndRecord(session, { type: "debate_turn", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, avatarColor: moderatorProfile.avatarColor, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, }); var feedText = "[Panelist Response]\n\n" + "@" + panelistProfile.name + " (" + (panelistInfo ? panelistInfo.role : "panelist") + ") responded:\n" + panelistText + "\n\n" + "Continue the debate. Call on the next panelist with @TheirName, or provide a closing summary (without any @mentions) to end the debate."; debate.moderatorSession.pushMessage(feedText, buildModeratorCallbacks(session)); } function buildModeratorCallbacks(session) { var debate = session._debate; var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); return { onActivity: function (activity) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_activity", mateId: debate.moderatorId, activity: activity }); } }, onDelta: function (delta) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta }); } }, onDone: function (fullText) { handleModeratorTurnDone(session, fullText); }, onError: function (errMsg) { console.error("[debate] Moderator error:", errMsg); endDebate(session, "error"); }, }; } // --- User interaction during debate --- function handleDebateHandRaise(ws, exactSession) { var session = exactSession || ctx.getSessionForWs(ws); if (!session) return; var debate = session._debate; if (!debate || debate.phase !== "live") return; if (debate.awaitingUserFloor || debate.handRaised) return; if (debate.awaitingConcludeConfirm) { if (session.homeDebatePlanning === true) debateSendAndRecord(session, { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round }); else ctx.sendTo(ws, { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round }); return; } debate.handRaised = true; debateSendAndRecord(session, { type: "debate_hand_raised" }); // If no one is speaking, yield floor immediately if (!debate.turnInProgress) { yieldFloorToUser(session); } // Otherwise: current speaker finishes -> handRaised detected -> yieldFloorToUser } function handleDebateComment(ws, msg, exactSession) { // Legacy: kept for compatibility but now hand raise is separate var session = exactSession || ctx.getSessionForWs(ws); if (!session) return; var debate = session._debate; if (!debate || debate.phase !== "live") { ctx.sendTo(ws, { type: "debate_error", error: "No active debate." }); return; } if (debate.awaitingUserFloor) return; if (!msg.text) return; debate.pendingComment = { text: msg.text }; debate.handRaised = true; debateSendAndRecord(session, { type: "debate_comment_queued", text: msg.text }); if (!debate.turnInProgress) { injectUserComment(session); } } function yieldFloorToUser(session) { var debate = session._debate; if (!debate || !debate.moderatorSession || debate.phase === "ended") return; debate.handRaised = false; debate.turnInProgress = true; var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); debateSendAndRecord(session, { type: "debate_turn", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, avatarColor: moderatorProfile.avatarColor, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, }); var feedText = "[The user raised their hand to speak.]\n" + "[Acknowledge this briefly and yield the floor to the user. Say something like " + "\"Go ahead\" or \"The floor is yours\". Do NOT call on any panelist (no @mentions). " + "The debate will pause for the user to speak.]"; debate.moderatorSession.pushMessage(feedText, buildModeratorYieldCallbacks(session)); } function injectUserComment(session) { var debate = session._debate; if (!debate || !debate.pendingComment || !debate.moderatorSession || debate.phase === "ended") return; var comment = debate.pendingComment; debate.pendingComment = null; debate.handRaised = false; // Record in debate history debate.history.push({ speaker: "user", mateId: null, mateName: "User", text: comment.text }); var commentEntry = { type: "debate_comment_injected", text: comment.text }; debateSendAndRecord(session, commentEntry); // Feed to moderator: yield the floor to the user debate.turnInProgress = true; var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); debateSendAndRecord(session, { type: "debate_turn", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, avatarColor: moderatorProfile.avatarColor, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, }); var feedText = "[The user raised their hand and said:]\n" + comment.text + "\n" + "[Acknowledge the user's input. Briefly respond, then YIELD THE FLOOR to the user by saying something like " + "\"The floor is yours\" or \"Go ahead\". Do NOT call on any panelist (no @mentions). " + "The debate will pause for the user to speak.]"; debate.moderatorSession.pushMessage(feedText, buildModeratorYieldCallbacks(session)); } function buildModeratorYieldCallbacks(session) { var debate = session._debate; var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); return { onActivity: function (activity) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_activity", mateId: debate.moderatorId, activity: activity }); } }, onDelta: function (delta) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta }); } }, onDone: function (fullText) { if (!debate || debate.phase === "ended") return; debate.turnInProgress = false; // Record moderator yield turn debate.history.push({ speaker: "moderator", mateId: debate.moderatorId, mateName: moderatorProfile.name, text: fullText }); 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 }; debateSendAndRecord(session, turnEntry); // Enter user floor mode: pause debate and show input debate.awaitingUserFloor = true; persistDebateState(session); debateSendAndRecord(session, { type: "debate_user_floor", topic: debate.topic, round: debate.round }); }, onError: function (errMsg) { console.error("[debate] Moderator yield error:", errMsg); endDebate(session, "error"); }, }; } function handleDebateUserFloorResponse(ws, msg, exactSession) { var session = exactSession || ctx.getSessionForWs(ws); if (!session) return; var debate = session._debate; if (!debate || !debate.awaitingUserFloor || debate.phase !== "live") return; debate.awaitingUserFloor = false; var userText = (msg && msg.text) ? msg.text.trim() : ""; if (!userText) return; // Record user's floor contribution debate.history.push({ speaker: "user", mateId: null, mateName: "User", text: userText }); var floorEntry = { type: "debate_user_floor_done", text: userText }; debateSendAndRecord(session, floorEntry); // Feed to moderator to resume debate debate.turnInProgress = true; var moderatorProfile = ctx.getMateProfile(debate.mateCtx, debate.moderatorId); debateMateProcessing(debate.moderatorId, true); debateSendAndRecord(session, { type: "debate_turn", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, avatarColor: moderatorProfile.avatarColor, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, }); var feedText = "[The user took the floor and said:]\n" + userText + "\n" + "[Acknowledge the user's contribution and resume the debate. Call on the next panelist with @TheirName.]"; debate.moderatorSession.pushMessage(feedText, buildModeratorCallbacks(session)); persistDebateState(session); } function handleDebateConfirmBrief(ws) { var session = ctx.getSessionForWs(ws); if (!session) return; var debate = session._debate; if (!debate || debate.phase !== "reviewing") { ctx.sendTo(ws, { type: "debate_error", error: "No debate brief to confirm." }); return; } console.log("[debate] User confirmed brief, transitioning to live. Topic:", debate.topic); startDebateLive(session); } function handleDebateStop(ws, exactSession) { var session = exactSession || ctx.getSessionForWs(ws); if (!session) return false; var debate = session._debate; if (!debate) return false; if (debate.phase === "reviewing") { endDebate(session, "user_stopped"); return true; } if (debate.phase !== "live") return false; if (debate.turnInProgress) { // Let current turn finish, then end debate.phase = "ending"; persistDebateState(session); debateSendAndRecord(session, { type: "debate_stop_requested", topic: debate.topic, round: debate.round }); } else { endDebate(session, "user_stopped"); } return true; } function handleDebateCancelStop(ws, exactSession) { var session = exactSession || ctx.getSessionForWs(ws); var debate = session && session._debate; if (!debate || debate.phase !== "ending") return false; debate.phase = "live"; persistDebateState(session); debateSendAndRecord(session, { type: "debate_stop_cancelled", topic: debate.topic, round: debate.round }); return true; } // Rebuild _debate from session history (for resume after server restart) function rebuildDebateState(session, ws) { // Find debate_started entry in history var startEntry = null; var latestPhase = null; var concludeEntry = null; var lastRound = 1; for (var i = 0; i < session.history.length; i++) { var h = session.history[i]; if (h.type === "debate_started") { startEntry = h; latestPhase = "live"; concludeEntry = null; } if (h.type === "debate_resumed") { latestPhase = "live"; concludeEntry = null; } if (h.type === "debate_ended") { latestPhase = "ended"; concludeEntry = null; } if (h.type === "debate_conclude_confirm" && latestPhase === "live") concludeEntry = h; if (h.type === "debate_turn_done" && h.round) lastRound = h.round; } if (!startEntry) return null; var userId = ws._clayUser ? ws._clayUser.id : null; var mateCtx = matesModule.buildMateCtx(userId); var savedState = session.debateState || {}; var savedPanelists = Array.isArray(savedState.panelists) && savedState.panelists.length ? savedState.panelists : (startEntry.panelists || []); var debate = { phase: session.homeDebatePhase === "ended" || session.homeDebatePhase === "interrupted" ? "ended" : (latestPhase || "live"), topic: savedState.topic || startEntry.topic || "", format: savedState.format || startEntry.format || "free_discussion", context: savedState.context || "", specialRequests: savedState.specialRequests || null, moderatorId: savedState.moderatorId || startEntry.moderatorId, participantModels: Array.isArray(savedState.participantModels) ? savedState.participantModels : (startEntry.participantModels || []), panelists: savedPanelists.map(function (p) { return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" }; }), mateCtx: mateCtx, moderatorSession: null, panelistSessions: {}, nameMap: buildDebateNameMap( savedPanelists.map(function (p) { return { mateId: p.mateId, role: p.role || "" }; }), mateCtx ), turnInProgress: false, pendingComment: null, round: lastRound, history: [], awaitingConcludeConfirm: latestPhase === "live" && !!concludeEntry, ownerId: savedState.ownerId || userId, debateId: savedState.debateId || (session.loop && session.loop.loopId) || "debate_rebuilt", }; // Rebuild debate.history from session history turn entries for (var j = 0; j < session.history.length; j++) { var entry = session.history[j]; if (entry.type === "debate_turn_done") { debate.history.push({ speaker: entry.role === "moderator" ? "moderator" : "panelist", mateId: entry.mateId, mateName: entry.mateName, role: entry.role || "", text: entry.text || "", }); } } // If no endEntry and no concludeEntry, check if last moderator turn had no mentions (implicit conclude) if (debate.phase === "live" && !concludeEntry && debate.history.length > 0) { var lastTurn = debate.history[debate.history.length - 1]; if (lastTurn.speaker === "moderator" && lastTurn.text) { var rebuildMentions = detectMentions(lastTurn.text, debate.nameMap); if (rebuildMentions.length === 0) { debate.awaitingConcludeConfirm = true; console.log("[debate] Last moderator turn had no mentions, setting awaitingConcludeConfirm."); } } } session._debate = debate; console.log("[debate] Rebuilt debate state from history. Topic:", debate.topic, "Phase:", debate.phase, "Turns:", debate.history.length); return debate; } function handleDebateConcludeResponse(ws, msg, exactSession) { var session = exactSession || ctx.getSessionForWs(ws); if (!session) return; var debate = session._debate; // If _debate is gone (server restart), try to rebuild from history if (!debate) { debate = rebuildDebateState(session, ws); if (!debate) { console.log("[debate] Cannot rebuild debate state for resume."); return; } } // Allow resume from both "live + awaiting confirm" and "ended" states var isLiveConfirm = debate.phase === "live" && debate.awaitingConcludeConfirm; var isResume = (debate.phase === "ended" || debate.phase === "interrupted") && msg.action === "continue"; if (!isLiveConfirm && !isResume) return; debate.awaitingConcludeConfirm = false; if (msg.action === "end") { endDebate(session, "natural"); return; } if (msg.action === "continue") { var wasEnded = debate.phase === "ended"; debate.phase = "live"; if (session.homeDebatePlanning === true) session.homeDebatePhase = "live"; var instruction = (msg.text || "").trim(); var mateCtx = debate.mateCtx || matesModule.buildMateCtx(ws._clayUser ? ws._clayUser.id : null); debate.mateCtx = mateCtx; var moderatorProfile = ctx.getMateProfile(mateCtx, debate.moderatorId); // Record user's resume message if provided if (instruction) { var resumeEntry = { type: "debate_user_resume", text: instruction }; debateSendAndRecord(session, resumeEntry); } // Notify clients debate is back live and persist to history var resumedMsg = { type: "debate_resumed", topic: debate.topic, round: debate.round, moderatorId: debate.moderatorId, moderatorName: moderatorProfile.name, panelists: debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed }; }), }; persistDebateState(session); debateSendAndRecord(session, resumedMsg); debate.turnInProgress = true; debateMateProcessing(debate.moderatorId, true); debateSendAndRecord(session, { type: "debate_turn", mateId: debate.moderatorId, mateName: moderatorProfile.name, role: "moderator", round: debate.round, avatarColor: moderatorProfile.avatarColor, avatarStyle: moderatorProfile.avatarStyle, avatarSeed: moderatorProfile.avatarSeed, }); // Build explicit panelist list so the moderator knows who to call on var panelistNames = debate.panelists.map(function (p) { var prof = ctx.getMateProfile(mateCtx, p.mateId); return "@" + prof.name; }); var panelistList = panelistNames.join(", "); var resumePrompt = instruction ? "[SYSTEM: The audience has requested the debate continue with new direction. You MUST call on a panelist to continue. Available panelists: " + panelistList + "]\n\nUser direction: " + instruction + "\n\n[Acknowledge this input briefly, then call on a panelist by writing their @Name to continue the discussion on this new direction. You must @mention exactly one panelist.]" : "[SYSTEM: The audience has requested the debate continue. You MUST call on the next panelist. Available panelists: " + panelistList + "]\n\n[Call on a panelist by writing their @Name to explore additional perspectives. You must @mention exactly one panelist.]"; // If resuming from ended state, moderator session may be dead. Create a new one. if (wasEnded || !debate.moderatorSession || !debate.moderatorSession.isAlive()) { console.log("[debate] Creating new moderator session for resume"); var claudeMd = ctx.loadMateClaudeMd(mateCtx, debate.moderatorId); var digests = ctx.loadMateDigests(mateCtx, debate.moderatorId, debate.topic); var moderatorContext = buildModeratorContext(debate) + digests; // Include debate history so moderator has context moderatorContext += "\n\nIMPORTANT: This debate was previously paused and is now being RESUMED. You must continue the debate by calling on a panelist with @TheirName. Do NOT conclude or summarize.\n"; moderatorContext += "\nDebate history so far:\n---\n"; for (var hi = 0; hi < debate.history.length; hi++) { var h = debate.history[hi]; moderatorContext += (h.mateName || h.speaker || "Unknown") + " (" + (h.role || "") + "): " + (h.text || "").slice(0, 500) + "\n\n"; } moderatorContext += "---\n"; var _modMate3 = matesModule.getMate(mateCtx, debate.moderatorId); var resumedModeratorModel = participantModel(debate, debate.moderatorId); ctx.sdk.createMentionSession({ vendor: resumedModeratorModel ? resumedModeratorModel.vendor : (_modMate3 ? _modMate3.vendor : null), model: resumedModeratorModel ? resumedModeratorModel.model : undefined, linuxUser: mateCtx.linuxUser || undefined, claudeMd: claudeMd, initialContext: moderatorContext, initialMessage: resumePrompt, onActivity: function (activity) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_activity", mateId: debate.moderatorId, activity: activity }); } }, onDelta: function (delta) { if (session._debate && session._debate.phase !== "ended") { debateSendAndRecord(session, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta }); } }, onDone: function (fullText) { handleModeratorTurnDone(session, fullText); }, onError: function (errMsg) { console.error("[debate] Moderator resume error:", errMsg); endDebate(session, "error"); }, canUseTool: buildDebateToolHandler(session, debate.moderatorId), }).then(function (mentionSession) { if (mentionSession) { debate.moderatorSession = mentionSession; } }).catch(function (err) { console.error("[debate] Failed to create resume moderator session:", err.message || err); endDebate(session, "error"); }); } else { debate.moderatorSession.pushMessage(resumePrompt, buildModeratorCallbacks(session)); } return; } } // --- End debate --- function endDebate(session, reason) { var debate = session._debate; if (!debate || debate.phase === "ended") return; // Clear all mate strip processing dots debateMateProcessing(debate.moderatorId, false); for (var ei = 0; ei < debate.panelists.length; ei++) { debateMateProcessing(debate.panelists[ei].mateId, false); } debate.phase = "ended"; debate.turnInProgress = false; if (session.homeDebatePlanning === true) session.homeDebatePhase = reason === "interrupted" ? "interrupted" : "ended"; persistDebateState(session); // Clean up brief watcher if still active if (debate._briefWatcher) { try { debate._briefWatcher.close(); } catch (e) {} debate._briefWatcher = null; } // Notify clients debateSendAndRecord(session, { type: "debate_ended", reason: reason, rounds: debate.round, topic: debate.topic, }); if (debate.homeClient && session.homeDebatePlanning !== true) { if (typeof ctx.unregisterHomeDebateClient === "function") ctx.unregisterHomeDebateClient(debate.homeClient); debate.homeClient._clayActiveSession = debate.homeClient._clayHomePreviousSessionId || null; debate.homeClient._clayHomePreviousSessionId = null; debate.homeClient._clayHomeDebateSlug = null; debate.homeClient = null; } // Generate digests for all participants digestDebateParticipant(session, debate.moderatorId, debate, "moderator"); for (var i = 0; i < debate.panelists.length; i++) { digestDebateParticipant(session, debate.panelists[i].mateId, debate, debate.panelists[i].role); } } function digestDebateParticipant(session, mateId, debate, role) { var mentionSession = null; if (mateId === debate.moderatorId) { mentionSession = debate.moderatorSession; } else { mentionSession = debate.panelistSessions[mateId]; } if (!mentionSession || !mentionSession.isAlive()) return; var mateDir = matesModule.getMateDir(debate.mateCtx, mateId); var knowledgeDir = path.join(mateDir, "knowledge"); // Migration: generate initial summary if missing var summaryFile = path.join(knowledgeDir, "memory-summary.md"); var digestFileCheck = path.join(knowledgeDir, "session-digests.jsonl"); if (!fs.existsSync(summaryFile) && fs.existsSync(digestFileCheck)) { ctx.initMemorySummary(debate.mateCtx, mateId, function () {}); } // Debates are user-initiated structured events. The moderator already // synthesizes a summary, so skip the memory gate and always create a digest. (function () { var digestPrompt = [ "[SYSTEM: Session Digest]", "Summarize this conversation from YOUR perspective for your long-term memory.", "Output ONLY a single valid JSON object (no markdown, no code fences, no extra text).", "", "Schema:", "{", ' "date": "YYYY-MM-DD",', ' "type": "debate",', ' "topic": "short topic description",', ' "my_position": "what I said/recommended",', ' "decisions": "what was decided, or null if pending",', ' "open_items": "what remains unresolved",', ' "user_sentiment": "how the user seemed to feel",', ' "my_role": "' + role + '",', ' "other_perspectives": "key points from others",', ' "outcome": "how the debate concluded",', ' "confidence": "high | medium | low",', ' "revisit_later": true/false,', ' "tags": ["relevant", "topic", "tags"]', "}", "", "IMPORTANT: Output ONLY the JSON object. Nothing else.", ].join("\n"); var digestText = ""; mentionSession.pushMessage(digestPrompt, { onActivity: function () {}, onDelta: function (delta) { digestText += delta; }, onDone: function () { var digestObj = null; try { var cleaned = digestText.trim(); if (cleaned.indexOf("```") === 0) { cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim(); } digestObj = JSON.parse(cleaned); } catch (e) { console.error("[debate-digest] Failed to parse digest JSON for mate " + mateId + ":", e.message); digestObj = { date: new Date().toISOString().slice(0, 10), type: "debate", topic: debate.topic, my_role: role, raw: digestText.substring(0, 500), }; } try { fs.mkdirSync(knowledgeDir, { recursive: true }); var digestFile = path.join(knowledgeDir, "session-digests.jsonl"); fs.appendFileSync(digestFile, JSON.stringify(digestObj) + "\n"); } catch (e) { console.error("[debate-digest] Failed to write digest for mate " + mateId + ":", e.message); } // Update memory summary ctx.updateMemorySummary(debate.mateCtx, mateId, digestObj); // Close the session after digest mentionSession.close(); }, onError: function (err) { console.error("[debate-digest] Digest generation failed for mate " + mateId + ":", err); mentionSession.close(); }, }); })(); } // --- MCP-based debate proposal approval --- function handleMcpDebateApproval(session, briefData, mateId, ws) { if (session._debate && (session._debate.phase === "preparing" || session._debate.phase === "reviewing" || session._debate.phase === "live")) { console.warn("[debate] Cannot start MCP debate: another debate is active on this session"); return; } var userId = ws && ws._clayUser ? ws._clayUser.id : (session.ownerId || ctx.projectOwnerId || null); var mateCtx = matesModule.buildMateCtx(userId); var debateId = "debate_" + Date.now(); var debate = { phase: "reviewing", topic: briefData.topic || "Untitled debate", format: briefData.format || "free_discussion", context: briefData.context || "", specialRequests: briefData.specialRequests || null, moderatorId: mateId, participantModels: Array.isArray(briefData.participantModels) ? briefData.participantModels : [], panelists: (briefData.panelists || []).map(function (p) { return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" }; }), mateCtx: mateCtx, moderatorSession: null, panelistSessions: {}, nameMap: null, turnInProgress: false, pendingComment: null, round: 1, history: [], setupSessionId: null, debateId: debateId, briefPath: null, ownerId: userId, }; debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx); session._debate = debate; console.log("[debate] MCP debate approved. Topic:", debate.topic, "debateId:", debateId); var homeWs = ws && ws._homeChatTap && ws._homeChatTap.mateSlug === ctx.slug && ws._homeChatTap.sessionId === session.localId ? ws : null; startDebateLive(session, homeWs); return { ok: true }; } function handleHomeControl(ws, msg, session) { if (!session || session.homeDebatePlanning !== true) return false; if (!session._debate && (msg.action === "conclude" || msg.action === "resume")) rebuildDebateState(session, ws); if (!session._debate) return false; if (msg.action === "hand_raise") handleDebateHandRaise(ws, session); else if (msg.action === "comment") handleDebateComment(ws, msg, session); else if (msg.action === "stop") return handleDebateStop(ws, session); else if (msg.action === "cancel_stop") return handleDebateCancelStop(ws, session); else if (msg.action === "conclude") handleDebateConcludeResponse(ws, { action: msg.response, text: msg.text || "" }, session); else if (msg.action === "resume") handleDebateConcludeResponse(ws, { action: "continue", text: msg.text || "" }, session); else if (msg.action === "user_floor") handleDebateUserFloorResponse(ws, msg, session); else return false; return true; } // --- Public API --- return { handleDebateStart: handleDebateStart, handleDebateHandRaise: handleDebateHandRaise, handleDebateComment: handleDebateComment, handleDebateStop: handleDebateStop, handleDebateCancelStop: handleDebateCancelStop, handleDebateConcludeResponse: handleDebateConcludeResponse, handleDebateConfirmBrief: handleDebateConfirmBrief, handleDebateUserFloorResponse: handleDebateUserFloorResponse, restoreDebateState: restoreDebateState, checkForDmDebateBrief: checkForDmDebateBrief, handleMcpDebateApproval: handleMcpDebateApproval, handleHomeControl: handleHomeControl, }; } module.exports = { attachDebate: attachDebate };