lib/project-debate.js
Rama main · réplica de lectura del repositorio original.
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 };