clay-server 2.18.0-beta.8 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/project.js CHANGED
@@ -13,6 +13,7 @@ var usersModule = require("./users");
13
13
  var { resolveOsUserInfo, fsAsUser } = require("./os-users");
14
14
  var crisisSafety = require("./crisis-safety");
15
15
  var matesModule = require("./mates");
16
+ var userPresence = require("./user-presence");
16
17
  var MAX_UPLOAD_BYTES = 50 * 1024 * 1024; // 50 MB
17
18
 
18
19
  // Validate environment variable string (KEY=VALUE per line)
@@ -670,30 +671,31 @@ function createProjectContext(opts) {
670
671
  }
671
672
 
672
673
  // For schedule records, resolve the linked task to get loop files
673
- var loopId = record.id;
674
+ var loopFilesId = record.id;
674
675
  if (record.source === "schedule") {
675
676
  if (!record.linkedTaskId) {
676
677
  console.error("[loop-registry] Schedule has no linked task: " + record.name);
677
678
  return;
678
679
  }
679
- loopId = record.linkedTaskId;
680
- console.log("[loop-registry] Schedule triggered: " + record.name + " → linked task " + loopId);
680
+ loopFilesId = record.linkedTaskId;
681
+ console.log("[loop-registry] Schedule triggered: " + record.name + " → linked task " + loopFilesId);
681
682
  }
682
683
 
683
684
  // Verify the loop directory and PROMPT.md exist
684
- var recDir = path.join(cwd, ".claude", "loops", loopId);
685
+ var recDir = path.join(cwd, ".claude", "loops", loopFilesId);
685
686
  try {
686
687
  fs.accessSync(path.join(recDir, "PROMPT.md"));
687
688
  } catch (e) {
688
- console.error("[loop-registry] PROMPT.md missing for " + loopId);
689
+ console.error("[loop-registry] PROMPT.md missing for " + loopFilesId);
689
690
  return;
690
691
  }
691
- // Set the loopId and start
692
- loopState.loopId = loopId;
692
+ // Set the loopId to the schedule's own id (not the linked task) so sidebar groups correctly
693
+ loopState.loopId = record.id;
694
+ loopState.wizardData = null;
693
695
  activeRegistryId = record.id;
694
696
  console.log("[loop-registry] Auto-starting loop: " + record.name + " (" + loopId + ")");
695
697
  send({ type: "schedule_run_started", recordId: record.id });
696
- startLoop({ maxIterations: record.maxIterations });
698
+ startLoop({ maxIterations: record.maxIterations, name: record.name });
697
699
  },
698
700
  onChange: function () {
699
701
  send({ type: "loop_registry_updated", records: getHubSchedules() });
@@ -752,18 +754,19 @@ function createProjectContext(opts) {
752
754
  loopState.promptText = promptText;
753
755
  loopState.judgeText = judgeText;
754
756
  loopState.iteration = 0;
755
- loopState.maxIterations = judgeText ? (loopConfig.maxIterations || loopOpts.maxIterations || 20) : 1;
757
+ loopState.maxIterations = judgeText ? ((loopOpts.maxIterations >= 1 ? loopOpts.maxIterations : null) || loopConfig.maxIterations || 20) : 1;
756
758
  loopState.baseCommit = baseCommit;
757
759
  loopState.currentSessionId = null;
758
760
  loopState.judgeSessionId = null;
759
761
  loopState.results = [];
760
762
  loopState.stopping = false;
763
+ loopState.name = loopOpts.name || null;
761
764
  loopState.startedAt = Date.now();
762
765
  saveLoopState();
763
766
 
764
767
  stopClaudeDirWatch();
765
768
 
766
- send({ type: "loop_started", maxIterations: loopState.maxIterations });
769
+ send({ type: "loop_started", maxIterations: loopState.maxIterations, name: loopState.name });
767
770
  runNextIteration();
768
771
  }
769
772
 
@@ -781,8 +784,8 @@ function createProjectContext(opts) {
781
784
  }
782
785
 
783
786
  var session = sm.createSession();
784
- var loopName = (loopState.wizardData && loopState.wizardData.name) || "";
785
787
  var loopSource = loopRegistry.getById(loopState.loopId);
788
+ var loopName = (loopState.wizardData && loopState.wizardData.name) || (loopSource && loopSource.name) || "";
786
789
  var loopSourceTag = (loopSource && loopSource.source) || null;
787
790
  var isRalphLoop = loopSourceTag === "ralph";
788
791
  session.loop = { active: true, iteration: loopState.iteration, role: "coder", loopId: loopState.loopId, name: loopName, source: loopSourceTag, startedAt: loopState.startedAt };
@@ -830,7 +833,7 @@ function createProjectContext(opts) {
830
833
  setTimeout(function() { runNextIteration(); }, 2000);
831
834
  return;
832
835
  }
833
- if (loopState.judgeText) {
836
+ if (loopState.judgeText && loopState.maxIterations > 1) {
834
837
  runJudge();
835
838
  } else {
836
839
  finishLoop("pass");
@@ -909,8 +912,8 @@ function createProjectContext(opts) {
909
912
  "- FAIL: [brief explanation of what is still missing]";
910
913
 
911
914
  var judgeSession = sm.createSession();
912
- var judgeName = (loopState.wizardData && loopState.wizardData.name) || "";
913
915
  var judgeSource = loopRegistry.getById(loopState.loopId);
916
+ var judgeName = (loopState.wizardData && loopState.wizardData.name) || (judgeSource && judgeSource.name) || "";
914
917
  var judgeSourceTag = (judgeSource && judgeSource.source) || null;
915
918
  var isRalphJudge = judgeSourceTag === "ralph";
916
919
  judgeSession.loop = { active: true, iteration: loopState.iteration, role: "judge", loopId: loopState.loopId, name: judgeName, source: judgeSourceTag, startedAt: loopState.startedAt };
@@ -1187,6 +1190,7 @@ function createProjectContext(opts) {
1187
1190
  active: loopState.active,
1188
1191
  iteration: loopState.iteration,
1189
1192
  maxIterations: loopState.maxIterations,
1193
+ name: loopState.name || null,
1190
1194
  });
1191
1195
 
1192
1196
  // Ralph phase state
@@ -1247,14 +1251,26 @@ function createProjectContext(opts) {
1247
1251
  }),
1248
1252
  });
1249
1253
 
1250
- // Restore active session for this client (check access)
1251
- var active = sm.getActiveSession();
1252
- if (active && usersModule.isMultiUser() && wsUser) {
1253
- if (!usersModule.canAccessSession(wsUser.id, active, { visibility: "public" })) {
1254
+ // Restore active session for this client from server-side presence
1255
+ var active = null;
1256
+ var presenceKey = wsUser ? wsUser.id : "_default";
1257
+ var storedPresence = userPresence.getPresence(slug, presenceKey);
1258
+ if (storedPresence && storedPresence.sessionId) {
1259
+ // Look up stored session by localId
1260
+ if (sm.sessions.has(storedPresence.sessionId)) {
1261
+ active = sm.sessions.get(storedPresence.sessionId);
1262
+ } else {
1263
+ // Try matching by cliSessionId (survives server restarts where localIds change)
1264
+ sm.sessions.forEach(function (s) {
1265
+ if (s.cliSessionId && s.cliSessionId === storedPresence.sessionId) active = s;
1266
+ });
1267
+ }
1268
+ // Validate access
1269
+ if (active && usersModule.isMultiUser() && wsUser) {
1270
+ if (!usersModule.canAccessSession(wsUser.id, active, { visibility: "public" })) active = null;
1271
+ } else if (active && !usersModule.isMultiUser() && active.ownerId) {
1254
1272
  active = null;
1255
1273
  }
1256
- } else if (active && !usersModule.isMultiUser() && active.ownerId) {
1257
- active = null;
1258
1274
  }
1259
1275
  // Fallback: pick the most recent accessible session
1260
1276
  if (!active && allSessions.length > 0) {
@@ -1269,13 +1285,13 @@ function createProjectContext(opts) {
1269
1285
  var autoCreated = false;
1270
1286
  if (!active) {
1271
1287
  var autoOpts = {};
1272
- if (wsUser) autoOpts.ownerId = wsUser.id;
1288
+ if (wsUser && usersModule.isMultiUser()) autoOpts.ownerId = wsUser.id;
1273
1289
  active = sm.createSession(autoOpts, ws);
1274
1290
  autoCreated = true;
1275
1291
  }
1276
1292
  if (active && !autoCreated) {
1277
- // Backfill ownerId for legacy sessions restored without one
1278
- if (!active.ownerId && wsUser) {
1293
+ // Backfill ownerId for legacy sessions restored without one (multi-user only)
1294
+ if (!active.ownerId && wsUser && usersModule.isMultiUser()) {
1279
1295
  active.ownerId = wsUser.id;
1280
1296
  sm.saveSessionFile(active);
1281
1297
  }
@@ -1289,7 +1305,11 @@ function createProjectContext(opts) {
1289
1305
  }
1290
1306
  sendTo(ws, { type: "history_meta", total: total, from: fromIndex });
1291
1307
  for (var i = fromIndex; i < total; i++) {
1292
- sendTo(ws, hydrateImageRefs(active.history[i]));
1308
+ var _hitem = active.history[i];
1309
+ if (_hitem && (_hitem.type === "mention_user" || _hitem.type === "mention_response")) {
1310
+ console.log("[DEBUG handleConnection] sending mention at index=" + i + " from=" + fromIndex + " total=" + total + " type=" + _hitem.type + " mate=" + (_hitem.mateName || "") + " slug=" + slug);
1311
+ }
1312
+ sendTo(ws, hydrateImageRefs(_hitem));
1293
1313
  }
1294
1314
  sendTo(ws, { type: "history_done" });
1295
1315
 
@@ -1306,12 +1326,24 @@ function createProjectContext(opts) {
1306
1326
  toolInput: p.toolInput,
1307
1327
  toolUseId: p.toolUseId,
1308
1328
  decisionReason: p.decisionReason,
1329
+ mateId: p.mateId || undefined,
1309
1330
  });
1310
1331
  }
1311
1332
  }
1312
1333
 
1334
+ // Record presence for this user + send mate DM restore hint if applicable
1335
+ if (active) {
1336
+ userPresence.setPresence(slug, presenceKey, active.localId, storedPresence ? storedPresence.mateDm : null);
1337
+ }
1338
+ if (storedPresence && storedPresence.mateDm) {
1339
+ sendTo(ws, { type: "restore_mate_dm", mateId: storedPresence.mateDm });
1340
+ }
1341
+
1313
1342
  broadcastPresence();
1314
1343
 
1344
+ // Restore debate state and brief watcher if a debate was in progress
1345
+ restoreDebateState(ws);
1346
+
1315
1347
  ws.on("message", function (raw) {
1316
1348
  var msg;
1317
1349
  try { msg = JSON.parse(raw.toString()); } catch (e) { return; }
@@ -1343,6 +1375,24 @@ function createProjectContext(opts) {
1343
1375
  return;
1344
1376
  }
1345
1377
 
1378
+ // --- Debate ---
1379
+ if (msg.type === "debate_start") {
1380
+ handleDebateStart(ws, msg);
1381
+ return;
1382
+ }
1383
+ if (msg.type === "debate_comment") {
1384
+ handleDebateComment(ws, msg);
1385
+ return;
1386
+ }
1387
+ if (msg.type === "debate_stop") {
1388
+ handleDebateStop(ws);
1389
+ return;
1390
+ }
1391
+ if (msg.type === "debate_conclude_response") {
1392
+ handleDebateConcludeResponse(ws, msg);
1393
+ return;
1394
+ }
1395
+
1346
1396
  // --- Knowledge file management ---
1347
1397
  if (msg.type === "knowledge_list") {
1348
1398
  var knowledgeDir = path.join(cwd, "knowledge");
@@ -1526,10 +1576,12 @@ function createProjectContext(opts) {
1526
1576
 
1527
1577
  if (msg.type === "new_session") {
1528
1578
  var sessionOpts = {};
1529
- if (ws._clayUser) sessionOpts.ownerId = ws._clayUser.id;
1579
+ if (ws._clayUser && usersModule.isMultiUser()) sessionOpts.ownerId = ws._clayUser.id;
1530
1580
  if (msg.sessionVisibility) sessionOpts.sessionVisibility = msg.sessionVisibility;
1531
1581
  var newSess = sm.createSession(sessionOpts, ws);
1532
1582
  ws._clayActiveSession = newSess.localId;
1583
+ var nsPresKey = ws._clayUser ? ws._clayUser.id : "_default";
1584
+ userPresence.setPresence(slug, nsPresKey, newSess.localId, null);
1533
1585
  if (usersModule.isMultiUser()) {
1534
1586
  broadcastPresence();
1535
1587
  }
@@ -1660,10 +1712,18 @@ function createProjectContext(opts) {
1660
1712
  ws._clayActiveSession = msg.id;
1661
1713
  sm.switchSession(msg.id, ws, hydrateImageRefs);
1662
1714
  }
1715
+ var swPresKey = ws._clayUser ? ws._clayUser.id : "_default";
1716
+ userPresence.setPresence(slug, swPresKey, msg.id, null);
1663
1717
  }
1664
1718
  return;
1665
1719
  }
1666
1720
 
1721
+ if (msg.type === "set_mate_dm") {
1722
+ var dmPresKey = ws._clayUser ? ws._clayUser.id : "_default";
1723
+ userPresence.setMateDm(slug, dmPresKey, msg.mateId || null);
1724
+ return;
1725
+ }
1726
+
1667
1727
  if (msg.type === "delete_session") {
1668
1728
  if (ws._clayUser) {
1669
1729
  var sdPerms = usersModule.getEffectivePermissions(ws._clayUser, osUsers);
@@ -3320,6 +3380,7 @@ function createProjectContext(opts) {
3320
3380
  color: sData.color || null,
3321
3381
  recurrenceEnd: sData.recurrenceEnd || null,
3322
3382
  skipIfRunning: sData.skipIfRunning !== undefined ? sData.skipIfRunning : true,
3383
+ intervalEnd: sData.intervalEnd || null,
3323
3384
  });
3324
3385
  return;
3325
3386
  }
@@ -3424,8 +3485,8 @@ function createProjectContext(opts) {
3424
3485
  var session = getSessionForWs(ws);
3425
3486
  if (!session) return;
3426
3487
 
3427
- // Backfill ownerId for legacy sessions restored without one
3428
- if (!session.ownerId && ws._clayUser) {
3488
+ // Backfill ownerId for legacy sessions restored without one (multi-user only)
3489
+ if (!session.ownerId && ws._clayUser && usersModule.isMultiUser()) {
3429
3490
  session.ownerId = ws._clayUser.id;
3430
3491
  sm.saveSessionFile(session);
3431
3492
  }
@@ -3499,7 +3560,7 @@ function createProjectContext(opts) {
3499
3560
  }
3500
3561
 
3501
3562
  // --- @Mention handler ---
3502
- var MENTION_WINDOW = 15; // turns to check for session continuity
3563
+ var MENTION_WINDOW = 20; // turns to check for session continuity
3503
3564
 
3504
3565
  function getRecentTurns(session, n) {
3505
3566
  var turns = [];
@@ -3655,6 +3716,12 @@ function createProjectContext(opts) {
3655
3716
  var session = getSessionForWs(ws);
3656
3717
  if (!session) return;
3657
3718
 
3719
+ // Block mentions during an active debate
3720
+ if (session._debate && session._debate.phase === "live") {
3721
+ sendTo(ws, { type: "mention_error", mateId: msg.mateId, error: "Cannot use @mentions during an active debate." });
3722
+ return;
3723
+ }
3724
+
3658
3725
  // Check if a mention is already in progress for this session
3659
3726
  if (session._mentionInProgress) {
3660
3727
  sendTo(ws, { type: "mention_error", mateId: msg.mateId, error: "A mention is already in progress." });
@@ -3832,7 +3899,7 @@ function createProjectContext(opts) {
3832
3899
  onDone: mentionCallbacks.onDone,
3833
3900
  onError: mentionCallbacks.onError,
3834
3901
  canUseTool: function (toolName, input, toolOpts) {
3835
- var autoAllow = { Read: true, Glob: true, Grep: true };
3902
+ var autoAllow = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
3836
3903
  if (autoAllow[toolName]) {
3837
3904
  return Promise.resolve({ behavior: "allow", updatedInput: input });
3838
3905
  }
@@ -3846,6 +3913,7 @@ function createProjectContext(opts) {
3846
3913
  toolInput: input,
3847
3914
  toolUseId: toolOpts ? toolOpts.toolUseID : undefined,
3848
3915
  decisionReason: (toolOpts && toolOpts.decisionReason) || "",
3916
+ mateId: msg.mateId,
3849
3917
  };
3850
3918
  sendToSession(session.localId, {
3851
3919
  type: "permission_request",
@@ -3854,6 +3922,7 @@ function createProjectContext(opts) {
3854
3922
  toolInput: input,
3855
3923
  toolUseId: toolOpts ? toolOpts.toolUseID : undefined,
3856
3924
  decisionReason: (toolOpts && toolOpts.decisionReason) || "",
3925
+ mateId: msg.mateId,
3857
3926
  });
3858
3927
  onProcessingChanged();
3859
3928
  if (toolOpts && toolOpts.signal) {
@@ -3878,6 +3947,1313 @@ function createProjectContext(opts) {
3878
3947
  }
3879
3948
  }
3880
3949
 
3950
+ // --- Debate engine ---
3951
+
3952
+ function escapeRegex(str) {
3953
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3954
+ }
3955
+
3956
+ function buildDebateNameMap(panelists, mateCtx) {
3957
+ var nameMap = {};
3958
+ for (var i = 0; i < panelists.length; i++) {
3959
+ var mate = matesModule.getMate(mateCtx, panelists[i].mateId);
3960
+ if (!mate) continue;
3961
+ var name = (mate.profile && mate.profile.displayName) || mate.name || "";
3962
+ if (name) {
3963
+ nameMap[name] = panelists[i].mateId;
3964
+ }
3965
+ }
3966
+ return nameMap;
3967
+ }
3968
+
3969
+ function detectMentions(text, nameMap) {
3970
+ var names = Object.keys(nameMap);
3971
+ // Sort by length descending to match longest name first
3972
+ names.sort(function (a, b) { return b.length - a.length; });
3973
+ var mentioned = [];
3974
+ console.log("[debate-mention] nameMap keys:", JSON.stringify(names));
3975
+ console.log("[debate-mention] text snippet:", text.slice(0, 200));
3976
+ for (var i = 0; i < names.length; i++) {
3977
+ var pattern = new RegExp("@" + escapeRegex(names[i]) + "(?=[\\s,.:;!?()\\]}>\"']|$)", "i");
3978
+ var matched = pattern.test(text);
3979
+ console.log("[debate-mention] testing @" + names[i] + " pattern=" + pattern.toString() + " matched=" + matched);
3980
+ if (matched) {
3981
+ var mateId = nameMap[names[i]];
3982
+ if (mentioned.indexOf(mateId) === -1) {
3983
+ mentioned.push(mateId);
3984
+ }
3985
+ }
3986
+ }
3987
+ return mentioned;
3988
+ }
3989
+
3990
+ function getMateProfile(mateCtx, mateId) {
3991
+ var mate = matesModule.getMate(mateCtx, mateId);
3992
+ if (!mate) return { name: "Mate", avatarColor: "#6c5ce7", avatarStyle: "bottts", avatarSeed: mateId };
3993
+ return {
3994
+ name: (mate.profile && mate.profile.displayName) || mate.name || "Mate",
3995
+ avatarColor: (mate.profile && mate.profile.avatarColor) || "#6c5ce7",
3996
+ avatarStyle: (mate.profile && mate.profile.avatarStyle) || "bottts",
3997
+ avatarSeed: (mate.profile && mate.profile.avatarSeed) || mateId,
3998
+ };
3999
+ }
4000
+
4001
+ function loadMateClaudeMd(mateCtx, mateId) {
4002
+ var mateDir = matesModule.getMateDir(mateCtx, mateId);
4003
+ try {
4004
+ return fs.readFileSync(path.join(mateDir, "CLAUDE.md"), "utf8");
4005
+ } catch (e) {
4006
+ return "";
4007
+ }
4008
+ }
4009
+
4010
+ function loadMateDigests(mateCtx, mateId) {
4011
+ var mateDir = matesModule.getMateDir(mateCtx, mateId);
4012
+ try {
4013
+ var digestFile = path.join(mateDir, "knowledge", "session-digests.jsonl");
4014
+ if (!fs.existsSync(digestFile)) return "";
4015
+ var allLines = fs.readFileSync(digestFile, "utf8").trim().split("\n");
4016
+ var recent = allLines.slice(-5);
4017
+ if (recent.length === 0) return "";
4018
+ var lines = ["\n\nYour recent session memories:"];
4019
+ for (var i = 0; i < recent.length; i++) {
4020
+ try {
4021
+ var d = JSON.parse(recent[i]);
4022
+ lines.push("- [" + (d.date || "?") + "] " + (d.topic || "unknown") + ": " + (d.my_position || "") +
4023
+ (d.decisions ? " | Decisions: " + d.decisions : "") +
4024
+ (d.open_items ? " | Open: " + d.open_items : "") );
4025
+ } catch (e) {}
4026
+ }
4027
+ return lines.join("\n");
4028
+ } catch (e) {
4029
+ return "";
4030
+ }
4031
+ }
4032
+
4033
+ function buildModeratorContext(debate) {
4034
+ var lines = [
4035
+ "You are moderating a structured debate among your AI teammates.",
4036
+ "",
4037
+ "Topic: " + debate.topic,
4038
+ "Format: " + debate.format,
4039
+ "Context: " + debate.context,
4040
+ ];
4041
+ if (debate.specialRequests) {
4042
+ lines.push("Special requests: " + debate.specialRequests);
4043
+ }
4044
+ lines.push("");
4045
+ lines.push("Panelists:");
4046
+ for (var i = 0; i < debate.panelists.length; i++) {
4047
+ var p = debate.panelists[i];
4048
+ var profile = getMateProfile(debate.mateCtx, p.mateId);
4049
+ lines.push("- @" + profile.name + " (" + p.role + "): " + p.brief);
4050
+ }
4051
+ lines.push("");
4052
+ lines.push("RULES:");
4053
+ lines.push("1. To call on a panelist, mention them with @TheirName in your response.");
4054
+ lines.push("2. Only mention ONE panelist per response. Wait for their answer before calling the next.");
4055
+ lines.push("3. When you mention a panelist, clearly state what you want them to address.");
4056
+ lines.push("4. After hearing from all panelists, you may start additional rounds.");
4057
+ 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.");
4058
+ lines.push("6. If the user interjects with a comment, acknowledge it and weave it into the discussion.");
4059
+ lines.push("");
4060
+ lines.push("Begin by introducing the topic and calling on the first panelist.");
4061
+ return lines.join("\n");
4062
+ }
4063
+
4064
+ function buildPanelistContext(debate, panelistInfo) {
4065
+ var moderatorProfile = getMateProfile(debate.mateCtx, debate.moderatorId);
4066
+ var lines = [
4067
+ "You are participating in a structured debate as a panelist.",
4068
+ "",
4069
+ "Topic: " + debate.topic,
4070
+ "Your role: " + panelistInfo.role,
4071
+ "Your brief: " + panelistInfo.brief,
4072
+ "",
4073
+ "Other panelists:",
4074
+ ];
4075
+ for (var i = 0; i < debate.panelists.length; i++) {
4076
+ var p = debate.panelists[i];
4077
+ if (p.mateId === panelistInfo.mateId) continue;
4078
+ var profile = getMateProfile(debate.mateCtx, p.mateId);
4079
+ lines.push("- @" + profile.name + " (" + p.role + "): " + p.brief);
4080
+ }
4081
+ lines.push("");
4082
+ lines.push("The moderator is @" + moderatorProfile.name + ". They will call on you when it is your turn.");
4083
+ lines.push("");
4084
+ lines.push("RULES:");
4085
+ lines.push("1. Stay in your assigned role and perspective.");
4086
+ lines.push("2. Respond to the specific question or prompt from the moderator.");
4087
+ lines.push("3. You may reference what other panelists have said.");
4088
+ lines.push("4. Keep responses focused and substantive. Do not ramble.");
4089
+ lines.push("5. You have read-only access to project files if needed to support your arguments.");
4090
+ return lines.join("\n");
4091
+ }
4092
+
4093
+ // --- Debate brief watcher (reusable for initial start and session restoration) ---
4094
+ function startDebateBriefWatcher(session, debate, briefPath) {
4095
+ if (!briefPath) {
4096
+ console.error("[debate] No briefPath provided to watcher");
4097
+ return;
4098
+ }
4099
+ // Persist briefPath on debate so restoration can reuse it
4100
+ debate.briefPath = briefPath;
4101
+ var watchDir = path.dirname(briefPath);
4102
+ var briefFilename = path.basename(briefPath);
4103
+
4104
+ // Clean up any existing watcher
4105
+ if (debate._briefWatcher) {
4106
+ try { debate._briefWatcher.close(); } catch (e) {}
4107
+ debate._briefWatcher = null;
4108
+ }
4109
+ if (debate._briefDebounce) {
4110
+ clearTimeout(debate._briefDebounce);
4111
+ debate._briefDebounce = null;
4112
+ }
4113
+
4114
+ function checkDebateBrief() {
4115
+ try {
4116
+ var raw = fs.readFileSync(briefPath, "utf8");
4117
+ var brief = JSON.parse(raw);
4118
+
4119
+ // Stop watching
4120
+ if (debate._briefWatcher) { debate._briefWatcher.close(); debate._briefWatcher = null; }
4121
+ if (debate._briefDebounce) { clearTimeout(debate._briefDebounce); debate._briefDebounce = null; }
4122
+
4123
+ // Clean up the brief file
4124
+ try { fs.unlinkSync(briefPath); } catch (e) {}
4125
+
4126
+ // Apply brief to debate state
4127
+ debate.topic = brief.topic || debate.topic;
4128
+ debate.format = brief.format || debate.format;
4129
+ debate.context = brief.context || "";
4130
+ debate.specialRequests = brief.specialRequests || null;
4131
+
4132
+ // Update panelists with roles from the brief
4133
+ if (brief.panelists && brief.panelists.length) {
4134
+ for (var i = 0; i < brief.panelists.length; i++) {
4135
+ var bp = brief.panelists[i];
4136
+ for (var j = 0; j < debate.panelists.length; j++) {
4137
+ if (debate.panelists[j].mateId === bp.mateId) {
4138
+ debate.panelists[j].role = bp.role || "";
4139
+ debate.panelists[j].brief = bp.brief || "";
4140
+ }
4141
+ }
4142
+ }
4143
+ }
4144
+
4145
+ // Rebuild name map with updated roles
4146
+ var mateCtx = debate.mateCtx || matesModule.buildMateCtx(null);
4147
+ debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx);
4148
+
4149
+ console.log("[debate] Brief picked up, transitioning to live. Topic:", debate.topic);
4150
+
4151
+ // Transition to live
4152
+ startDebateLive(session);
4153
+ } catch (e) {
4154
+ // File not ready yet or invalid JSON, keep watching
4155
+ }
4156
+ }
4157
+
4158
+ try {
4159
+ try { fs.mkdirSync(watchDir, { recursive: true }); } catch (e) {}
4160
+ debate._briefWatcher = fs.watch(watchDir, function (eventType, filename) {
4161
+ if (filename === briefFilename) {
4162
+ if (debate._briefDebounce) clearTimeout(debate._briefDebounce);
4163
+ debate._briefDebounce = setTimeout(checkDebateBrief, 300);
4164
+ }
4165
+ });
4166
+ debate._briefWatcher.on("error", function () {});
4167
+ console.log("[debate] Watching for " + briefFilename + " at " + watchDir);
4168
+ } catch (e) {
4169
+ console.error("[debate] Failed to watch " + watchDir + ":", e.message);
4170
+ }
4171
+
4172
+ // Check immediately in case the file already exists (server restart scenario)
4173
+ checkDebateBrief();
4174
+ }
4175
+
4176
+ // Restore debate state and brief watcher on WS reconnect (after server restart)
4177
+ function restoreDebateState(ws) {
4178
+ var userId = ws._clayUser ? ws._clayUser.id : null;
4179
+ var mateCtx = matesModule.buildMateCtx(userId);
4180
+
4181
+ sm.sessions.forEach(function (session) {
4182
+ // Already restored
4183
+ if (session._debate) return;
4184
+
4185
+ // Has persisted debate state?
4186
+ if (!session.debateState) return;
4187
+
4188
+ var phase = session.debateState.phase;
4189
+ if (phase !== "preparing" && phase !== "live") return;
4190
+
4191
+ // Restore _debate from persisted state
4192
+ var debate = restoreDebateFromState(session);
4193
+ if (!debate) return;
4194
+
4195
+ // Update mateCtx with the connected user's context
4196
+ debate.mateCtx = mateCtx;
4197
+ debate.nameMap = buildDebateNameMap(debate.panelists, mateCtx);
4198
+
4199
+ var moderatorProfile = getMateProfile(mateCtx, debate.moderatorId);
4200
+
4201
+ if (phase === "preparing") {
4202
+ var briefPath = debate.briefPath;
4203
+ if (!briefPath && debate.debateId) {
4204
+ briefPath = path.join(cwd, ".clay", "debates", debate.debateId, "brief.json");
4205
+ }
4206
+ if (!briefPath) return;
4207
+
4208
+ console.log("[debate] Restoring debate (preparing). topic:", debate.topic, "briefPath:", briefPath);
4209
+ startDebateBriefWatcher(session, debate, briefPath);
4210
+
4211
+ // Send preparing sticky to the connected client
4212
+ sendTo(ws, {
4213
+ type: "debate_preparing",
4214
+ topic: debate.topic,
4215
+ moderatorId: debate.moderatorId,
4216
+ moderatorName: moderatorProfile.name,
4217
+ setupSessionId: debate.setupSessionId,
4218
+ panelists: debate.panelists.map(function (p) {
4219
+ var prof = getMateProfile(mateCtx, p.mateId);
4220
+ return { mateId: p.mateId, name: prof.name };
4221
+ }),
4222
+ });
4223
+ } else if (phase === "live") {
4224
+ console.log("[debate] Restoring debate (live). topic:", debate.topic, "awaitingConclude:", debate.awaitingConcludeConfirm);
4225
+ // Debate was live when server restarted. It can't resume AI turns,
4226
+ // but we can show the sticky and let user see history.
4227
+ sendTo(ws, {
4228
+ type: "debate_started",
4229
+ topic: debate.topic,
4230
+ format: debate.format,
4231
+ round: debate.round,
4232
+ moderatorId: debate.moderatorId,
4233
+ moderatorName: moderatorProfile.name,
4234
+ panelists: debate.panelists.map(function (p) {
4235
+ var prof = getMateProfile(mateCtx, p.mateId);
4236
+ return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed };
4237
+ }),
4238
+ });
4239
+ // If moderator had concluded, re-send conclude confirm so client shows End/Continue UI
4240
+ if (debate.awaitingConcludeConfirm) {
4241
+ sendTo(ws, { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round });
4242
+ }
4243
+ }
4244
+ });
4245
+ }
4246
+
4247
+ // Persist debate state to session file (survives server restart)
4248
+ function persistDebateState(session) {
4249
+ if (!session._debate) return;
4250
+ var d = session._debate;
4251
+ session.debateState = {
4252
+ phase: d.phase,
4253
+ topic: d.topic,
4254
+ format: d.format,
4255
+ context: d.context || "",
4256
+ specialRequests: d.specialRequests || null,
4257
+ moderatorId: d.moderatorId,
4258
+ panelists: d.panelists.map(function (p) {
4259
+ return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" };
4260
+ }),
4261
+ briefPath: d.briefPath || null,
4262
+ debateId: d.debateId || null,
4263
+ setupSessionId: d.setupSessionId || null,
4264
+ setupStartedAt: d.setupStartedAt || null,
4265
+ round: d.round || 1,
4266
+ awaitingConcludeConfirm: !!d.awaitingConcludeConfirm,
4267
+ };
4268
+ sm.saveSessionFile(session);
4269
+ }
4270
+
4271
+ // Restore _debate from persisted debateState
4272
+ function restoreDebateFromState(session) {
4273
+ var ds = session.debateState;
4274
+ if (!ds) return null;
4275
+ var userId = null; // Will be set when WS connects
4276
+ var mateCtx = matesModule.buildMateCtx(userId);
4277
+ var debate = {
4278
+ phase: ds.phase,
4279
+ topic: ds.topic,
4280
+ format: ds.format,
4281
+ context: ds.context || "",
4282
+ specialRequests: ds.specialRequests || null,
4283
+ moderatorId: ds.moderatorId,
4284
+ panelists: ds.panelists || [],
4285
+ mateCtx: mateCtx,
4286
+ moderatorSession: null,
4287
+ panelistSessions: {},
4288
+ nameMap: buildDebateNameMap(ds.panelists || [], mateCtx),
4289
+ turnInProgress: false,
4290
+ pendingComment: null,
4291
+ round: ds.round || 1,
4292
+ history: [],
4293
+ setupSessionId: ds.setupSessionId || null,
4294
+ debateId: ds.debateId || null,
4295
+ setupStartedAt: ds.setupStartedAt || null,
4296
+ briefPath: ds.briefPath || null,
4297
+ awaitingConcludeConfirm: !!ds.awaitingConcludeConfirm,
4298
+ };
4299
+
4300
+ // Fallback: if awaitingConcludeConfirm was not persisted, detect from history
4301
+ if (!debate.awaitingConcludeConfirm && ds.phase === "live") {
4302
+ var hasEnded = false;
4303
+ var hasConclude = false;
4304
+ var lastModText = null;
4305
+ for (var i = 0; i < session.history.length; i++) {
4306
+ var h = session.history[i];
4307
+ if (h.type === "debate_ended") hasEnded = true;
4308
+ if (h.type === "debate_conclude_confirm") hasConclude = true;
4309
+ if (h.type === "debate_turn_done" && h.role === "moderator") lastModText = h.text || "";
4310
+ }
4311
+ if (!hasEnded && !hasConclude && lastModText !== null) {
4312
+ var mentions = detectMentions(lastModText, debate.nameMap);
4313
+ if (mentions.length === 0) {
4314
+ debate.awaitingConcludeConfirm = true;
4315
+ }
4316
+ }
4317
+ }
4318
+
4319
+ session._debate = debate;
4320
+ return debate;
4321
+ }
4322
+
4323
+ function buildDebateToolHandler(session) {
4324
+ return function (toolName, input, toolOpts) {
4325
+ var autoAllow = { Read: true, Glob: true, Grep: true, WebFetch: true, WebSearch: true };
4326
+ if (autoAllow[toolName]) {
4327
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
4328
+ }
4329
+ return Promise.resolve({
4330
+ behavior: "deny",
4331
+ message: "Read-only access during debate. You cannot make changes.",
4332
+ });
4333
+ };
4334
+ }
4335
+
4336
+ function handleDebateStart(ws, msg) {
4337
+ var session = getSessionForWs(ws);
4338
+ if (!session) return;
4339
+
4340
+ if (!msg.moderatorId || !msg.topic || !msg.panelists || !msg.panelists.length) {
4341
+ sendTo(ws, { type: "debate_error", error: "Missing required fields: moderatorId, topic, panelists." });
4342
+ return;
4343
+ }
4344
+
4345
+ if (session._debate && (session._debate.phase === "live" || session._debate.phase === "preparing")) {
4346
+ sendTo(ws, { type: "debate_error", error: "A debate is already in progress." });
4347
+ return;
4348
+ }
4349
+
4350
+ // Block mentions during debate
4351
+ if (session._mentionInProgress) {
4352
+ sendTo(ws, { type: "debate_error", error: "A mention is in progress. Wait for it to finish." });
4353
+ return;
4354
+ }
4355
+
4356
+ var userId = ws._clayUser ? ws._clayUser.id : null;
4357
+ var mateCtx = matesModule.buildMateCtx(userId);
4358
+ var moderatorProfile = getMateProfile(mateCtx, msg.moderatorId);
4359
+
4360
+ // --- Phase 1: Preparing (clay-debate-setup skill) ---
4361
+ var debate = {
4362
+ phase: "preparing",
4363
+ topic: msg.topic,
4364
+ format: "free_discussion",
4365
+ context: "",
4366
+ specialRequests: null,
4367
+ moderatorId: msg.moderatorId,
4368
+ panelists: msg.panelists,
4369
+ mateCtx: mateCtx,
4370
+ moderatorSession: null,
4371
+ panelistSessions: {},
4372
+ nameMap: buildDebateNameMap(msg.panelists, mateCtx),
4373
+ turnInProgress: false,
4374
+ pendingComment: null,
4375
+ round: 1,
4376
+ history: [],
4377
+ setupSessionId: null,
4378
+ };
4379
+ session._debate = debate;
4380
+
4381
+ // Create a new session for the setup skill (like Ralph crafting)
4382
+ var debateId = "debate_" + Date.now();
4383
+ var setupSession = sm.createSession();
4384
+ setupSession.title = "Debate Setup: " + msg.topic.slice(0, 40);
4385
+ setupSession.debateSetupMode = true;
4386
+ setupSession.loop = { active: true, iteration: 0, role: "crafting", loopId: debateId, name: msg.topic.slice(0, 40), source: "debate", startedAt: Date.now() };
4387
+ sm.saveSessionFile(setupSession);
4388
+ sm.switchSession(setupSession.localId, null, hydrateImageRefs);
4389
+ debate.setupSessionId = setupSession.localId;
4390
+ debate.debateId = debateId;
4391
+ debate.setupStartedAt = setupSession.loop.startedAt;
4392
+
4393
+ // Build panelist info for the skill prompt
4394
+ var panelistNames = msg.panelists.map(function (p) {
4395
+ var prof = getMateProfile(mateCtx, p.mateId);
4396
+ return prof.name || p.mateId;
4397
+ }).join(", ");
4398
+
4399
+ var debateDir = path.join(cwd, ".clay", "debates", debateId);
4400
+ try { fs.mkdirSync(debateDir, { recursive: true }); } catch (e) {}
4401
+ var briefPath = path.join(debateDir, "brief.json");
4402
+ console.log("[debate] cwd=" + cwd + " debateDir=" + debateDir + " briefPath=" + briefPath);
4403
+
4404
+ var craftingPrompt = "Use the /clay-debate-setup skill to prepare a structured debate. " +
4405
+ "You MUST invoke the clay-debate-setup skill. Do NOT start the debate yourself.\n\n" +
4406
+ "## Initial Topic\n" + msg.topic + "\n\n" +
4407
+ "## Moderator\n" + (moderatorProfile.name || msg.moderatorId) + "\n\n" +
4408
+ "## Selected Panelists\n" + msg.panelists.map(function (p) {
4409
+ var prof = getMateProfile(mateCtx, p.mateId);
4410
+ return "- " + (prof.name || p.mateId) + " (ID: " + p.mateId + ")";
4411
+ }).join("\n") + "\n\n" +
4412
+ "## Debate Brief Output Path\n" +
4413
+ "When the setup is complete, write the debate brief JSON to this EXACT absolute path:\n" +
4414
+ "`" + briefPath + "`\n" +
4415
+ "This is where the debate engine watches for the file. Do NOT write it anywhere else.\n\n" +
4416
+ "## Spoken Language\nKorean (unless user switches)";
4417
+
4418
+ // Persist debate state before starting watcher
4419
+ debate.briefPath = briefPath;
4420
+ persistDebateState(session);
4421
+
4422
+ // Watch for brief.json in the debate-specific directory
4423
+ startDebateBriefWatcher(session, debate, briefPath);
4424
+
4425
+ // Notify clients that we are in preparing phase (send to both original and setup session)
4426
+ var preparingMsg = {
4427
+ type: "debate_preparing",
4428
+ topic: debate.topic,
4429
+ moderatorId: debate.moderatorId,
4430
+ moderatorName: moderatorProfile.name,
4431
+ setupSessionId: setupSession.localId,
4432
+ panelists: debate.panelists.map(function (p) {
4433
+ var prof = getMateProfile(mateCtx, p.mateId);
4434
+ return { mateId: p.mateId, name: prof.name };
4435
+ }),
4436
+ };
4437
+ // Send directly to the requesting ws (session switch may not have propagated yet)
4438
+ sendTo(ws, preparingMsg);
4439
+ // Also broadcast to any other clients on either session
4440
+ sendToSession(session.localId, preparingMsg);
4441
+ sendToSession(setupSession.localId, preparingMsg);
4442
+
4443
+ // Start the setup skill session
4444
+ setupSession.history.push({ type: "user_message", text: craftingPrompt });
4445
+ sm.appendToSessionFile(setupSession, { type: "user_message", text: craftingPrompt });
4446
+ sendToSession(setupSession.localId, { type: "user_message", text: craftingPrompt });
4447
+ setupSession.isProcessing = true;
4448
+ onProcessingChanged();
4449
+ setupSession.sentToolResults = {};
4450
+ sendToSession(setupSession.localId, { type: "status", status: "processing" });
4451
+ sdk.startQuery(setupSession, craftingPrompt, undefined, getLinuxUserForSession(setupSession));
4452
+ }
4453
+
4454
+ function startDebateLive(session) {
4455
+ var debate = session._debate;
4456
+ if (!debate || debate.phase === "live") return;
4457
+
4458
+ debate.phase = "live";
4459
+ debate.turnInProgress = true;
4460
+ debate.round = 1;
4461
+
4462
+ var mateCtx = debate.mateCtx;
4463
+ var moderatorProfile = getMateProfile(mateCtx, debate.moderatorId);
4464
+
4465
+ // Create a dedicated debate session, grouped with the setup session
4466
+ var debateSession = sm.createSession();
4467
+ debateSession.title = debate.topic.slice(0, 50);
4468
+ debateSession.loop = { active: true, iteration: 1, role: "debate", loopId: debate.debateId, name: debate.topic.slice(0, 40), source: "debate", startedAt: debate.setupStartedAt || Date.now() };
4469
+ // Assign cliSessionId manually so saveSessionFile works (no SDK query for debate sessions)
4470
+ if (!debateSession.cliSessionId) {
4471
+ debateSession.cliSessionId = require("crypto").randomUUID();
4472
+ }
4473
+ sm.saveSessionFile(debateSession);
4474
+ sm.switchSession(debateSession.localId, null, hydrateImageRefs);
4475
+ debate.liveSessionId = debateSession.localId;
4476
+
4477
+ // Move _debate to the new session so all debate logic uses it
4478
+ debateSession._debate = debate;
4479
+ delete session._debate;
4480
+ // Clear persisted state from setup session, persist on live session
4481
+ session.debateState = null;
4482
+ sm.saveSessionFile(session);
4483
+ persistDebateState(debateSession);
4484
+
4485
+ // Save to session history
4486
+ var debateStartEntry = {
4487
+ type: "debate_started",
4488
+ topic: debate.topic,
4489
+ format: debate.format,
4490
+ moderatorId: debate.moderatorId,
4491
+ moderatorName: moderatorProfile.name,
4492
+ panelists: debate.panelists.map(function (p) {
4493
+ var prof = getMateProfile(mateCtx, p.mateId);
4494
+ return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed };
4495
+ }),
4496
+ };
4497
+ debateSession.history.push(debateStartEntry);
4498
+ sm.appendToSessionFile(debateSession, debateStartEntry);
4499
+
4500
+ // Notify clients (same data as history entry)
4501
+ sendToSession(debateSession.localId, debateStartEntry);
4502
+
4503
+ // Signal moderator's first turn
4504
+ sendToSession(debateSession.localId, {
4505
+ type: "debate_turn",
4506
+ mateId: debate.moderatorId,
4507
+ mateName: moderatorProfile.name,
4508
+ role: "moderator",
4509
+ round: debate.round,
4510
+ avatarColor: moderatorProfile.avatarColor,
4511
+ avatarStyle: moderatorProfile.avatarStyle,
4512
+ avatarSeed: moderatorProfile.avatarSeed,
4513
+ });
4514
+
4515
+ // Create moderator mention session
4516
+ var claudeMd = loadMateClaudeMd(mateCtx, debate.moderatorId);
4517
+ var digests = loadMateDigests(mateCtx, debate.moderatorId);
4518
+ var moderatorContext = buildModeratorContext(debate) + digests;
4519
+
4520
+ sdk.createMentionSession({
4521
+ claudeMd: claudeMd,
4522
+ initialContext: moderatorContext,
4523
+ initialMessage: "Begin the debate on: " + debate.topic,
4524
+ onActivity: function (activity) {
4525
+ if (debateSession._debate && debateSession._debate.phase !== "ended") {
4526
+ sendToSession(debateSession.localId, { type: "debate_activity", mateId: debate.moderatorId, activity: activity });
4527
+ }
4528
+ },
4529
+ onDelta: function (delta) {
4530
+ if (debateSession._debate && debateSession._debate.phase !== "ended") {
4531
+ sendToSession(debateSession.localId, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta });
4532
+ }
4533
+ },
4534
+ onDone: function (fullText) {
4535
+ handleModeratorTurnDone(debateSession, fullText);
4536
+ },
4537
+ onError: function (errMsg) {
4538
+ console.error("[debate] Moderator error:", errMsg);
4539
+ endDebate(debateSession, "error");
4540
+ },
4541
+ canUseTool: buildDebateToolHandler(debateSession),
4542
+ }).then(function (mentionSession) {
4543
+ if (mentionSession) {
4544
+ debate.moderatorSession = mentionSession;
4545
+ }
4546
+ }).catch(function (err) {
4547
+ console.error("[debate] Failed to create moderator session:", err.message || err);
4548
+ endDebate(debateSession, "error");
4549
+ });
4550
+ }
4551
+
4552
+ function handleModeratorTurnDone(session, fullText) {
4553
+ var debate = session._debate;
4554
+ if (!debate || debate.phase === "ended") return;
4555
+
4556
+ debate.turnInProgress = false;
4557
+
4558
+ // Record in debate history
4559
+ var moderatorProfile = getMateProfile(debate.mateCtx, debate.moderatorId);
4560
+ debate.history.push({ speaker: "moderator", mateId: debate.moderatorId, mateName: moderatorProfile.name, text: fullText });
4561
+
4562
+ // Save to session history
4563
+ 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 };
4564
+ session.history.push(turnEntry);
4565
+ sm.appendToSessionFile(session, turnEntry);
4566
+ sendToSession(session.localId, turnEntry);
4567
+
4568
+ // Check if user stopped the debate during this turn
4569
+ if (debate.phase === "ending") {
4570
+ endDebate(session, "user_stopped");
4571
+ return;
4572
+ }
4573
+
4574
+ // Detect @mentions
4575
+ console.log("[debate] nameMap keys:", JSON.stringify(Object.keys(debate.nameMap)));
4576
+ console.log("[debate] moderator text (last 200):", fullText.slice(-200));
4577
+ var mentionedIds = detectMentions(fullText, debate.nameMap);
4578
+ console.log("[debate] detected mentions:", JSON.stringify(mentionedIds));
4579
+
4580
+ if (mentionedIds.length === 0) {
4581
+ // No mentions = moderator wants to conclude. Ask user to confirm.
4582
+ console.log("[debate] No mentions detected, requesting user confirmation to end.");
4583
+ debate.turnInProgress = false;
4584
+ debate.awaitingConcludeConfirm = true;
4585
+ var concludeEntry = { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round };
4586
+ session.history.push(concludeEntry);
4587
+ sm.appendToSessionFile(session, concludeEntry);
4588
+ sendToSession(session.localId, concludeEntry);
4589
+ return;
4590
+ }
4591
+
4592
+ // Check for pending user comment before triggering panelist
4593
+ if (debate.pendingComment) {
4594
+ injectUserComment(session);
4595
+ return;
4596
+ }
4597
+
4598
+ // Trigger the first mentioned panelist
4599
+ triggerPanelist(session, mentionedIds[0], fullText);
4600
+ }
4601
+
4602
+ function triggerPanelist(session, mateId, moderatorText) {
4603
+ var debate = session._debate;
4604
+ if (!debate || debate.phase === "ended") return;
4605
+
4606
+ debate.turnInProgress = true;
4607
+ debate._currentTurnMateId = mateId;
4608
+ debate._currentTurnText = "";
4609
+
4610
+ var profile = getMateProfile(debate.mateCtx, mateId);
4611
+ var panelistInfo = null;
4612
+ for (var i = 0; i < debate.panelists.length; i++) {
4613
+ if (debate.panelists[i].mateId === mateId) {
4614
+ panelistInfo = debate.panelists[i];
4615
+ break;
4616
+ }
4617
+ }
4618
+ if (!panelistInfo) {
4619
+ console.error("[debate] Panelist not found:", mateId);
4620
+ debate._currentTurnMateId = null;
4621
+ // Feed error back to moderator
4622
+ feedBackToModerator(session, mateId, "[This panelist is not part of the debate panel.]");
4623
+ return;
4624
+ }
4625
+
4626
+ // Notify clients of new turn
4627
+ sendToSession(session.localId, {
4628
+ type: "debate_turn",
4629
+ mateId: mateId,
4630
+ mateName: profile.name,
4631
+ role: panelistInfo.role,
4632
+ round: debate.round,
4633
+ avatarColor: profile.avatarColor,
4634
+ avatarStyle: profile.avatarStyle,
4635
+ avatarSeed: profile.avatarSeed,
4636
+ });
4637
+
4638
+ var panelistCallbacks = {
4639
+ onActivity: function (activity) {
4640
+ if (session._debate && session._debate.phase !== "ended") {
4641
+ sendToSession(session.localId, { type: "debate_activity", mateId: mateId, activity: activity });
4642
+ }
4643
+ },
4644
+ onDelta: function (delta) {
4645
+ if (session._debate && session._debate.phase !== "ended") {
4646
+ debate._currentTurnText += delta;
4647
+ sendToSession(session.localId, { type: "debate_stream", mateId: mateId, mateName: profile.name, delta: delta });
4648
+ }
4649
+ },
4650
+ onDone: function (fullText) {
4651
+ handlePanelistTurnDone(session, mateId, fullText);
4652
+ },
4653
+ onError: function (errMsg) {
4654
+ console.error("[debate] Panelist error for " + mateId + ":", errMsg);
4655
+ debate.turnInProgress = false;
4656
+ // Feed error back to moderator so the debate can continue
4657
+ feedBackToModerator(session, mateId, "[" + profile.name + " encountered an error and could not respond. Please continue with other panelists or wrap up.]");
4658
+ },
4659
+ };
4660
+
4661
+ // Check for existing session
4662
+ var existing = debate.panelistSessions[mateId];
4663
+ if (existing && existing.isAlive()) {
4664
+ // Build recent debate context for continuation
4665
+ var recentHistory = "";
4666
+ var lastPanelistIdx = -1;
4667
+ for (var hi = debate.history.length - 1; hi >= 0; hi--) {
4668
+ if (debate.history[hi].mateId === mateId) {
4669
+ lastPanelistIdx = hi;
4670
+ break;
4671
+ }
4672
+ }
4673
+ if (lastPanelistIdx >= 0 && lastPanelistIdx < debate.history.length - 1) {
4674
+ recentHistory = "\n\n[Debate turns since your last response:]\n---\n";
4675
+ for (var hj = lastPanelistIdx + 1; hj < debate.history.length; hj++) {
4676
+ var h = debate.history[hj];
4677
+ recentHistory += h.mateName + " (" + (h.speaker === "moderator" ? "moderator" : h.role || h.speaker) + "): " + h.text.substring(0, 500) + "\n\n";
4678
+ }
4679
+ recentHistory += "---";
4680
+ }
4681
+ var continuationMsg = recentHistory + "\n\n[The moderator is now addressing you. Please respond.]\n\nModerator said:\n" + moderatorText;
4682
+ existing.pushMessage(continuationMsg, panelistCallbacks);
4683
+ } else {
4684
+ // Create new panelist session
4685
+ var claudeMd = loadMateClaudeMd(debate.mateCtx, mateId);
4686
+ var digests = loadMateDigests(debate.mateCtx, mateId);
4687
+ var panelistContext = buildPanelistContext(debate, panelistInfo) + digests;
4688
+
4689
+ // Include debate history so far for context
4690
+ var historyContext = "";
4691
+ if (debate.history.length > 0) {
4692
+ historyContext = "\n\n[Debate so far:]\n---\n";
4693
+ for (var hk = 0; hk < debate.history.length; hk++) {
4694
+ var he = debate.history[hk];
4695
+ historyContext += he.mateName + " (" + (he.speaker === "moderator" ? "moderator" : he.role || he.speaker) + "): " + he.text.substring(0, 500) + "\n\n";
4696
+ }
4697
+ historyContext += "---";
4698
+ }
4699
+
4700
+ sdk.createMentionSession({
4701
+ claudeMd: claudeMd,
4702
+ initialContext: panelistContext + historyContext,
4703
+ initialMessage: "The moderator addresses you:\n\n" + moderatorText,
4704
+ onActivity: panelistCallbacks.onActivity,
4705
+ onDelta: panelistCallbacks.onDelta,
4706
+ onDone: panelistCallbacks.onDone,
4707
+ onError: panelistCallbacks.onError,
4708
+ canUseTool: buildDebateToolHandler(session),
4709
+ }).then(function (mentionSession) {
4710
+ if (mentionSession) {
4711
+ debate.panelistSessions[mateId] = mentionSession;
4712
+ }
4713
+ }).catch(function (err) {
4714
+ console.error("[debate] Failed to create panelist session for " + mateId + ":", err.message || err);
4715
+ debate.turnInProgress = false;
4716
+ feedBackToModerator(session, mateId, "[" + profile.name + " is unavailable. Please continue with other panelists or wrap up.]");
4717
+ });
4718
+ }
4719
+ }
4720
+
4721
+ function handlePanelistTurnDone(session, mateId, fullText) {
4722
+ var debate = session._debate;
4723
+ if (!debate || debate.phase === "ended") return;
4724
+
4725
+ debate.turnInProgress = false;
4726
+ debate._currentTurnMateId = null;
4727
+ debate._currentTurnText = "";
4728
+
4729
+ var profile = getMateProfile(debate.mateCtx, mateId);
4730
+ var panelistInfo = null;
4731
+ for (var i = 0; i < debate.panelists.length; i++) {
4732
+ if (debate.panelists[i].mateId === mateId) {
4733
+ panelistInfo = debate.panelists[i];
4734
+ break;
4735
+ }
4736
+ }
4737
+
4738
+ // Record in debate history
4739
+ debate.history.push({ speaker: "panelist", mateId: mateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", text: fullText });
4740
+
4741
+ // Save to session history
4742
+ 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 };
4743
+ session.history.push(turnEntry);
4744
+ sm.appendToSessionFile(session, turnEntry);
4745
+ sendToSession(session.localId, turnEntry);
4746
+
4747
+ // Check if user stopped the debate
4748
+ if (debate.phase === "ending") {
4749
+ endDebate(session, "user_stopped");
4750
+ return;
4751
+ }
4752
+
4753
+ // Check for pending user comment
4754
+ if (debate.pendingComment) {
4755
+ injectUserComment(session);
4756
+ return;
4757
+ }
4758
+
4759
+ // Feed panelist response back to moderator
4760
+ feedBackToModerator(session, mateId, fullText);
4761
+ }
4762
+
4763
+ function feedBackToModerator(session, panelistMateId, panelistText) {
4764
+ var debate = session._debate;
4765
+ if (!debate || !debate.moderatorSession || debate.phase === "ended") return;
4766
+
4767
+ debate.round++;
4768
+ debate.turnInProgress = true;
4769
+
4770
+ var panelistProfile = getMateProfile(debate.mateCtx, panelistMateId);
4771
+ var panelistInfo = null;
4772
+ for (var i = 0; i < debate.panelists.length; i++) {
4773
+ if (debate.panelists[i].mateId === panelistMateId) {
4774
+ panelistInfo = debate.panelists[i];
4775
+ break;
4776
+ }
4777
+ }
4778
+
4779
+ var moderatorProfile = getMateProfile(debate.mateCtx, debate.moderatorId);
4780
+
4781
+ // Notify clients of moderator turn
4782
+ sendToSession(session.localId, {
4783
+ type: "debate_turn",
4784
+ mateId: debate.moderatorId,
4785
+ mateName: moderatorProfile.name,
4786
+ role: "moderator",
4787
+ round: debate.round,
4788
+ avatarColor: moderatorProfile.avatarColor,
4789
+ avatarStyle: moderatorProfile.avatarStyle,
4790
+ avatarSeed: moderatorProfile.avatarSeed,
4791
+ });
4792
+
4793
+ var feedText = "[Panelist Response]\n\n" +
4794
+ "@" + panelistProfile.name + " (" + (panelistInfo ? panelistInfo.role : "panelist") + ") responded:\n" +
4795
+ panelistText + "\n\n" +
4796
+ "Continue the debate. Call on the next panelist with @TheirName, or provide a closing summary (without any @mentions) to end the debate.";
4797
+
4798
+ debate.moderatorSession.pushMessage(feedText, buildModeratorCallbacks(session));
4799
+ }
4800
+
4801
+ function buildModeratorCallbacks(session) {
4802
+ var debate = session._debate;
4803
+ var moderatorProfile = getMateProfile(debate.mateCtx, debate.moderatorId);
4804
+ return {
4805
+ onActivity: function (activity) {
4806
+ if (session._debate && session._debate.phase !== "ended") {
4807
+ sendToSession(session.localId, { type: "debate_activity", mateId: debate.moderatorId, activity: activity });
4808
+ }
4809
+ },
4810
+ onDelta: function (delta) {
4811
+ if (session._debate && session._debate.phase !== "ended") {
4812
+ sendToSession(session.localId, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta });
4813
+ }
4814
+ },
4815
+ onDone: function (fullText) {
4816
+ handleModeratorTurnDone(session, fullText);
4817
+ },
4818
+ onError: function (errMsg) {
4819
+ console.error("[debate] Moderator error:", errMsg);
4820
+ endDebate(session, "error");
4821
+ },
4822
+ };
4823
+ }
4824
+
4825
+ function handleDebateComment(ws, msg) {
4826
+ var session = getSessionForWs(ws);
4827
+ if (!session) return;
4828
+
4829
+ var debate = session._debate;
4830
+ if (!debate || debate.phase !== "live") {
4831
+ sendTo(ws, { type: "debate_error", error: "No active debate." });
4832
+ return;
4833
+ }
4834
+
4835
+ // If awaiting conclude confirmation, re-send the confirm prompt instead
4836
+ if (debate.awaitingConcludeConfirm) {
4837
+ sendTo(ws, { type: "debate_conclude_confirm", topic: debate.topic, round: debate.round });
4838
+ return;
4839
+ }
4840
+
4841
+ if (!msg.text) return;
4842
+
4843
+ debate.pendingComment = { text: msg.text };
4844
+ sendToSession(session.localId, { type: "debate_comment_queued", text: msg.text });
4845
+
4846
+ // If a panelist turn is in progress, abort it and go straight to moderator
4847
+ if (debate.turnInProgress && debate._currentTurnMateId && debate._currentTurnMateId !== debate.moderatorId) {
4848
+ var abortMateId = debate._currentTurnMateId;
4849
+ console.log("[debate] User raised hand during panelist turn, aborting " + abortMateId);
4850
+
4851
+ // Close the panelist's mention session to stop generation
4852
+ if (debate.panelistSessions[abortMateId]) {
4853
+ try { debate.panelistSessions[abortMateId].close(); } catch (e) {}
4854
+ delete debate.panelistSessions[abortMateId];
4855
+ }
4856
+
4857
+ // Save partial text as interrupted turn
4858
+ var partialText = debate._currentTurnText || "(interrupted by audience)";
4859
+ var profile = getMateProfile(debate.mateCtx, abortMateId);
4860
+ var panelistInfo = null;
4861
+ for (var pi = 0; pi < debate.panelists.length; pi++) {
4862
+ if (debate.panelists[pi].mateId === abortMateId) { panelistInfo = debate.panelists[pi]; break; }
4863
+ }
4864
+
4865
+ sendToSession(session.localId, {
4866
+ type: "debate_turn_done",
4867
+ mateId: abortMateId,
4868
+ mateName: profile.name,
4869
+ role: panelistInfo ? panelistInfo.role : "",
4870
+ text: partialText,
4871
+ interrupted: true,
4872
+ avatarStyle: profile.avatarStyle,
4873
+ avatarSeed: profile.avatarSeed,
4874
+ avatarColor: profile.avatarColor,
4875
+ });
4876
+
4877
+ var turnEntry = { type: "debate_turn_done", mateId: abortMateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", round: debate.round, text: partialText, avatarStyle: profile.avatarStyle, avatarSeed: profile.avatarSeed, avatarColor: profile.avatarColor, interrupted: true };
4878
+ session.history.push(turnEntry);
4879
+ sm.appendToSessionFile(session, turnEntry);
4880
+ debate.history.push({ speaker: "panelist", mateId: abortMateId, mateName: profile.name, role: panelistInfo ? panelistInfo.role : "", text: partialText });
4881
+
4882
+ debate.turnInProgress = false;
4883
+ debate._currentTurnMateId = null;
4884
+ debate._currentTurnText = "";
4885
+ }
4886
+
4887
+ // Inject to moderator immediately if no turn in progress (or just aborted)
4888
+ if (!debate.turnInProgress) {
4889
+ injectUserComment(session);
4890
+ }
4891
+ // If moderator is currently speaking, pendingComment will be picked up after moderator's onDone
4892
+ }
4893
+
4894
+ function injectUserComment(session) {
4895
+ var debate = session._debate;
4896
+ if (!debate || !debate.pendingComment || !debate.moderatorSession || debate.phase === "ended") return;
4897
+
4898
+ var comment = debate.pendingComment;
4899
+ debate.pendingComment = null;
4900
+
4901
+ // Record in debate history
4902
+ debate.history.push({ speaker: "user", mateId: null, mateName: "User", text: comment.text });
4903
+
4904
+ var commentEntry = { type: "debate_comment_injected", text: comment.text };
4905
+ session.history.push(commentEntry);
4906
+ sm.appendToSessionFile(session, commentEntry);
4907
+ sendToSession(session.localId, commentEntry);
4908
+
4909
+ // Feed to moderator
4910
+ debate.turnInProgress = true;
4911
+ var moderatorProfile = getMateProfile(debate.mateCtx, debate.moderatorId);
4912
+
4913
+ sendToSession(session.localId, {
4914
+ type: "debate_turn",
4915
+ mateId: debate.moderatorId,
4916
+ mateName: moderatorProfile.name,
4917
+ role: "moderator",
4918
+ round: debate.round,
4919
+ avatarColor: moderatorProfile.avatarColor,
4920
+ avatarStyle: moderatorProfile.avatarStyle,
4921
+ avatarSeed: moderatorProfile.avatarSeed,
4922
+ });
4923
+
4924
+ var feedText = "[The user raised their hand and said:]\n" +
4925
+ comment.text + "\n" +
4926
+ "[Please acknowledge this and weave it into the discussion. Then continue the debate.]";
4927
+
4928
+ debate.moderatorSession.pushMessage(feedText, buildModeratorCallbacks(session));
4929
+ }
4930
+
4931
+ function handleDebateStop(ws) {
4932
+ var session = getSessionForWs(ws);
4933
+ if (!session) return;
4934
+
4935
+ var debate = session._debate;
4936
+ if (!debate || debate.phase !== "live") return;
4937
+
4938
+ if (debate.turnInProgress) {
4939
+ // Let current turn finish, then end
4940
+ debate.phase = "ending";
4941
+ } else {
4942
+ endDebate(session, "user_stopped");
4943
+ }
4944
+ }
4945
+
4946
+ // Rebuild _debate from session history (for resume after server restart)
4947
+ function rebuildDebateState(session, ws) {
4948
+ // Find debate_started entry in history
4949
+ var startEntry = null;
4950
+ var endEntry = null;
4951
+ var concludeEntry = null;
4952
+ var lastRound = 1;
4953
+ for (var i = 0; i < session.history.length; i++) {
4954
+ var h = session.history[i];
4955
+ if (h.type === "debate_started") startEntry = h;
4956
+ if (h.type === "debate_ended") endEntry = h;
4957
+ if (h.type === "debate_conclude_confirm") concludeEntry = h;
4958
+ if (h.type === "debate_turn_done" && h.round) lastRound = h.round;
4959
+ }
4960
+ if (!startEntry) return null;
4961
+
4962
+ var userId = ws._clayUser ? ws._clayUser.id : null;
4963
+ var mateCtx = matesModule.buildMateCtx(userId);
4964
+
4965
+ var debate = {
4966
+ phase: endEntry ? "ended" : "live",
4967
+ topic: startEntry.topic || "",
4968
+ format: startEntry.format || "free_discussion",
4969
+ context: "",
4970
+ specialRequests: null,
4971
+ moderatorId: startEntry.moderatorId,
4972
+ panelists: (startEntry.panelists || []).map(function (p) {
4973
+ return { mateId: p.mateId, role: p.role || "", brief: p.brief || "" };
4974
+ }),
4975
+ mateCtx: mateCtx,
4976
+ moderatorSession: null,
4977
+ panelistSessions: {},
4978
+ nameMap: buildDebateNameMap(
4979
+ (startEntry.panelists || []).map(function (p) { return { mateId: p.mateId, role: p.role || "" }; }),
4980
+ mateCtx
4981
+ ),
4982
+ turnInProgress: false,
4983
+ pendingComment: null,
4984
+ round: lastRound,
4985
+ history: [],
4986
+ awaitingConcludeConfirm: !endEntry && !!concludeEntry,
4987
+ debateId: (session.loop && session.loop.loopId) || "debate_rebuilt",
4988
+ };
4989
+
4990
+ // Rebuild debate.history from session history turn entries
4991
+ for (var j = 0; j < session.history.length; j++) {
4992
+ var entry = session.history[j];
4993
+ if (entry.type === "debate_turn_done") {
4994
+ debate.history.push({
4995
+ speaker: entry.role === "moderator" ? "moderator" : "panelist",
4996
+ mateId: entry.mateId,
4997
+ mateName: entry.mateName,
4998
+ role: entry.role || "",
4999
+ text: entry.text || "",
5000
+ });
5001
+ }
5002
+ }
5003
+
5004
+ // If no endEntry and no concludeEntry, check if last moderator turn had no mentions (implicit conclude)
5005
+ if (!endEntry && !concludeEntry && debate.history.length > 0) {
5006
+ var lastTurn = debate.history[debate.history.length - 1];
5007
+ if (lastTurn.speaker === "moderator" && lastTurn.text) {
5008
+ var rebuildMentions = detectMentions(lastTurn.text, debate.nameMap);
5009
+ if (rebuildMentions.length === 0) {
5010
+ debate.awaitingConcludeConfirm = true;
5011
+ console.log("[debate] Last moderator turn had no mentions, setting awaitingConcludeConfirm.");
5012
+ }
5013
+ }
5014
+ }
5015
+
5016
+ session._debate = debate;
5017
+ console.log("[debate] Rebuilt debate state from history. Topic:", debate.topic, "Phase:", debate.phase, "Turns:", debate.history.length);
5018
+ return debate;
5019
+ }
5020
+
5021
+ function handleDebateConcludeResponse(ws, msg) {
5022
+ var session = getSessionForWs(ws);
5023
+ if (!session) return;
5024
+ var debate = session._debate;
5025
+
5026
+ // If _debate is gone (server restart), try to rebuild from history
5027
+ if (!debate) {
5028
+ debate = rebuildDebateState(session, ws);
5029
+ if (!debate) {
5030
+ console.log("[debate] Cannot rebuild debate state for resume.");
5031
+ return;
5032
+ }
5033
+ }
5034
+
5035
+ // Allow resume from both "live + awaiting confirm" and "ended" states
5036
+ var isLiveConfirm = debate.phase === "live" && debate.awaitingConcludeConfirm;
5037
+ var isResume = debate.phase === "ended" && msg.action === "continue";
5038
+ if (!isLiveConfirm && !isResume) return;
5039
+
5040
+ debate.awaitingConcludeConfirm = false;
5041
+
5042
+ if (msg.action === "end") {
5043
+ endDebate(session, "natural");
5044
+ return;
5045
+ }
5046
+
5047
+ if (msg.action === "continue") {
5048
+ var wasEnded = debate.phase === "ended";
5049
+ debate.phase = "live";
5050
+ var instruction = (msg.text || "").trim();
5051
+ var mateCtx = debate.mateCtx || matesModule.buildMateCtx(ws._clayUser ? ws._clayUser.id : null);
5052
+ debate.mateCtx = mateCtx;
5053
+ var moderatorProfile = getMateProfile(mateCtx, debate.moderatorId);
5054
+
5055
+ // Record user's resume message if provided
5056
+ if (instruction) {
5057
+ var resumeEntry = { type: "debate_user_resume", text: instruction };
5058
+ session.history.push(resumeEntry);
5059
+ sm.appendToSessionFile(session, resumeEntry);
5060
+ sendToSession(session.localId, resumeEntry);
5061
+ }
5062
+
5063
+ // Notify clients debate is back live and persist to history
5064
+ var resumedMsg = {
5065
+ type: "debate_resumed",
5066
+ topic: debate.topic,
5067
+ round: debate.round,
5068
+ moderatorId: debate.moderatorId,
5069
+ moderatorName: moderatorProfile.name,
5070
+ panelists: debate.panelists.map(function (p) {
5071
+ var prof = getMateProfile(mateCtx, p.mateId);
5072
+ return { mateId: p.mateId, name: prof.name, role: p.role, avatarColor: prof.avatarColor, avatarStyle: prof.avatarStyle, avatarSeed: prof.avatarSeed };
5073
+ }),
5074
+ };
5075
+ session.history.push(resumedMsg);
5076
+ sm.appendToSessionFile(session, resumedMsg);
5077
+ sendToSession(session.localId, resumedMsg);
5078
+
5079
+ debate.turnInProgress = true;
5080
+ sendToSession(session.localId, {
5081
+ type: "debate_turn",
5082
+ mateId: debate.moderatorId,
5083
+ mateName: moderatorProfile.name,
5084
+ role: "moderator",
5085
+ round: debate.round,
5086
+ avatarColor: moderatorProfile.avatarColor,
5087
+ avatarStyle: moderatorProfile.avatarStyle,
5088
+ avatarSeed: moderatorProfile.avatarSeed,
5089
+ });
5090
+
5091
+ var resumePrompt = instruction
5092
+ ? "[The audience has requested the debate continue with the following direction]\nUser: " + instruction + "\n\n[As moderator, acknowledge this input and call on a panelist with @TheirName to continue the discussion.]"
5093
+ : "[The audience has requested the debate continue. Call on the next panelist with @TheirName to explore additional perspectives.]";
5094
+
5095
+ // If resuming from ended state, moderator session may be dead. Create a new one.
5096
+ if (wasEnded || !debate.moderatorSession || !debate.moderatorSession.isAlive()) {
5097
+ console.log("[debate] Creating new moderator session for resume");
5098
+ var claudeMd = loadMateClaudeMd(mateCtx, debate.moderatorId);
5099
+ var digests = loadMateDigests(mateCtx, debate.moderatorId);
5100
+ var moderatorContext = buildModeratorContext(debate) + digests;
5101
+
5102
+ // Include debate history so moderator has context
5103
+ moderatorContext += "\n\nDebate history so far:\n---\n";
5104
+ for (var hi = 0; hi < debate.history.length; hi++) {
5105
+ var h = debate.history[hi];
5106
+ moderatorContext += (h.mateName || h.speaker || "Unknown") + " (" + (h.role || "") + "): " + (h.text || "").slice(0, 500) + "\n\n";
5107
+ }
5108
+ moderatorContext += "---\n";
5109
+
5110
+ sdk.createMentionSession({
5111
+ claudeMd: claudeMd,
5112
+ initialContext: moderatorContext,
5113
+ initialMessage: resumePrompt,
5114
+ onActivity: function (activity) {
5115
+ if (session._debate && session._debate.phase !== "ended") {
5116
+ sendToSession(session.localId, { type: "debate_activity", mateId: debate.moderatorId, activity: activity });
5117
+ }
5118
+ },
5119
+ onDelta: function (delta) {
5120
+ if (session._debate && session._debate.phase !== "ended") {
5121
+ sendToSession(session.localId, { type: "debate_stream", mateId: debate.moderatorId, mateName: moderatorProfile.name, delta: delta });
5122
+ }
5123
+ },
5124
+ onDone: function (fullText) {
5125
+ handleModeratorTurnDone(session, fullText);
5126
+ },
5127
+ onError: function (errMsg) {
5128
+ console.error("[debate] Moderator resume error:", errMsg);
5129
+ endDebate(session, "error");
5130
+ },
5131
+ canUseTool: buildDebateToolHandler(session),
5132
+ }).then(function (mentionSession) {
5133
+ if (mentionSession) {
5134
+ debate.moderatorSession = mentionSession;
5135
+ }
5136
+ }).catch(function (err) {
5137
+ console.error("[debate] Failed to create resume moderator session:", err.message || err);
5138
+ endDebate(session, "error");
5139
+ });
5140
+ } else {
5141
+ debate.moderatorSession.pushMessage(resumePrompt, buildModeratorCallbacks(session));
5142
+ }
5143
+ return;
5144
+ }
5145
+ }
5146
+
5147
+ function endDebate(session, reason) {
5148
+ var debate = session._debate;
5149
+ if (!debate || debate.phase === "ended") return;
5150
+
5151
+ debate.phase = "ended";
5152
+ debate.turnInProgress = false;
5153
+ persistDebateState(session);
5154
+
5155
+ // Clean up brief watcher if still active
5156
+ if (debate._briefWatcher) {
5157
+ try { debate._briefWatcher.close(); } catch (e) {}
5158
+ debate._briefWatcher = null;
5159
+ }
5160
+
5161
+ // Notify clients
5162
+ sendToSession(session.localId, {
5163
+ type: "debate_ended",
5164
+ reason: reason,
5165
+ rounds: debate.round,
5166
+ topic: debate.topic,
5167
+ });
5168
+
5169
+ // Save to session history
5170
+ var endEntry = { type: "debate_ended", topic: debate.topic, rounds: debate.round, reason: reason };
5171
+ session.history.push(endEntry);
5172
+ sm.appendToSessionFile(session, endEntry);
5173
+
5174
+ // Generate digests for all participants
5175
+ digestDebateParticipant(session, debate.moderatorId, debate, "moderator");
5176
+ for (var i = 0; i < debate.panelists.length; i++) {
5177
+ digestDebateParticipant(session, debate.panelists[i].mateId, debate, debate.panelists[i].role);
5178
+ }
5179
+ }
5180
+
5181
+ function digestDebateParticipant(session, mateId, debate, role) {
5182
+ var mentionSession = null;
5183
+ if (mateId === debate.moderatorId) {
5184
+ mentionSession = debate.moderatorSession;
5185
+ } else {
5186
+ mentionSession = debate.panelistSessions[mateId];
5187
+ }
5188
+ if (!mentionSession || !mentionSession.isAlive()) return;
5189
+
5190
+ var mateDir = matesModule.getMateDir(debate.mateCtx, mateId);
5191
+ var knowledgeDir = path.join(mateDir, "knowledge");
5192
+
5193
+ var digestPrompt = [
5194
+ "[SYSTEM: Debate Session Digest Request]",
5195
+ "The debate has ended. Summarize this debate from YOUR perspective for your long-term memory.",
5196
+ "Topic: " + debate.topic,
5197
+ "Your role: " + role,
5198
+ "Output ONLY a single valid JSON object (no markdown, no code fences, no extra text).",
5199
+ "Schema:",
5200
+ "{",
5201
+ ' "date": "YYYY-MM-DD",',
5202
+ ' "type": "debate",',
5203
+ ' "topic": "the debate topic",',
5204
+ ' "my_role": "your role in the debate",',
5205
+ ' "my_position": "what you argued/said",',
5206
+ ' "other_perspectives": "key points from other participants",',
5207
+ ' "outcome": "how the debate concluded",',
5208
+ ' "open_items": "unresolved points"',
5209
+ "}",
5210
+ "",
5211
+ "IMPORTANT: Output ONLY the JSON object. Nothing else.",
5212
+ ].join("\n");
5213
+
5214
+ var digestText = "";
5215
+ mentionSession.pushMessage(digestPrompt, {
5216
+ onActivity: function () {},
5217
+ onDelta: function (delta) {
5218
+ digestText += delta;
5219
+ },
5220
+ onDone: function () {
5221
+ var digestObj = null;
5222
+ try {
5223
+ var cleaned = digestText.trim();
5224
+ if (cleaned.indexOf("```") === 0) {
5225
+ cleaned = cleaned.replace(/^```[a-z]*\n?/, "").replace(/\n?```$/, "").trim();
5226
+ }
5227
+ digestObj = JSON.parse(cleaned);
5228
+ } catch (e) {
5229
+ console.error("[debate-digest] Failed to parse digest JSON for mate " + mateId + ":", e.message);
5230
+ digestObj = {
5231
+ date: new Date().toISOString().slice(0, 10),
5232
+ type: "debate",
5233
+ topic: debate.topic,
5234
+ my_role: role,
5235
+ raw: digestText.substring(0, 500),
5236
+ };
5237
+ }
5238
+
5239
+ try {
5240
+ fs.mkdirSync(knowledgeDir, { recursive: true });
5241
+ var digestFile = path.join(knowledgeDir, "session-digests.jsonl");
5242
+ fs.appendFileSync(digestFile, JSON.stringify(digestObj) + "\n");
5243
+ } catch (e) {
5244
+ console.error("[debate-digest] Failed to write digest for mate " + mateId + ":", e.message);
5245
+ }
5246
+
5247
+ // Close the session after digest
5248
+ mentionSession.close();
5249
+ },
5250
+ onError: function (err) {
5251
+ console.error("[debate-digest] Digest generation failed for mate " + mateId + ":", err);
5252
+ mentionSession.close();
5253
+ },
5254
+ });
5255
+ }
5256
+
3881
5257
  // --- Session presence (who is viewing which session) ---
3882
5258
  function broadcastPresence() {
3883
5259
  if (!usersModule.isMultiUser()) return;
@@ -3908,6 +5284,12 @@ function createProjectContext(opts) {
3908
5284
 
3909
5285
  // --- WS disconnection handler ---
3910
5286
  function handleDisconnection(ws) {
5287
+ // Persist last active session for this user before cleanup
5288
+ if (ws._clayActiveSession) {
5289
+ var dcPresKey = ws._clayUser ? ws._clayUser.id : "_default";
5290
+ var dcExisting = userPresence.getPresence(slug, dcPresKey);
5291
+ userPresence.setPresence(slug, dcPresKey, ws._clayActiveSession, dcExisting ? dcExisting.mateDm : null);
5292
+ }
3911
5293
  tm.detachAll(ws);
3912
5294
  clients.delete(ws);
3913
5295
  if (clients.size === 0) {
@@ -4126,15 +5508,38 @@ function createProjectContext(opts) {
4126
5508
  var scopeFlag = scope === "global" ? "--global" : "--project";
4127
5509
  var skillSpawnOpts = {
4128
5510
  cwd: spawnCwd,
4129
- stdio: "ignore",
5511
+ stdio: ["ignore", "pipe", "pipe"],
4130
5512
  detached: false,
4131
5513
  };
4132
5514
  if (skillUserInfo) {
4133
5515
  skillSpawnOpts.uid = skillUserInfo.uid;
4134
5516
  skillSpawnOpts.gid = skillUserInfo.gid;
4135
5517
  }
5518
+ console.log("[skill-install] spawning: npx skills add " + url + " --skill " + skill + " --yes " + scopeFlag + " (cwd: " + spawnCwd + ")");
4136
5519
  var child = spawn("npx", ["skills", "add", url, "--skill", skill, "--yes", scopeFlag], skillSpawnOpts);
5520
+ var stdoutBuf = "";
5521
+ var stderrBuf = "";
5522
+ child.stdout.on("data", function (chunk) {
5523
+ stdoutBuf += chunk.toString();
5524
+ console.log("[skill-install] " + skill + " stdout chunk: " + chunk.toString().trim().slice(0, 500));
5525
+ });
5526
+ child.stderr.on("data", function (chunk) {
5527
+ stderrBuf += chunk.toString();
5528
+ console.log("[skill-install] " + skill + " stderr chunk: " + chunk.toString().trim().slice(0, 500));
5529
+ });
5530
+ // Timeout after 60 seconds
5531
+ var installTimeout = setTimeout(function () {
5532
+ console.error("[skill-install] " + skill + " timed out after 60s, killing process");
5533
+ try { child.kill("SIGTERM"); } catch (e) {}
5534
+ try {
5535
+ send({ type: "skill_installed", skill: skill, scope: scope, success: false, error: "Installation timed out after 60 seconds" });
5536
+ } catch (e) {}
5537
+ }, 60000);
4137
5538
  child.on("close", function (code) {
5539
+ clearTimeout(installTimeout);
5540
+ console.log("[skill-install] " + skill + " exited with code " + code + " (stdout=" + stdoutBuf.length + "b, stderr=" + stderrBuf.length + "b)");
5541
+ if (stdoutBuf) console.log("[skill-install] stdout: " + stdoutBuf.slice(0, 2000));
5542
+ if (stderrBuf) console.log("[skill-install] stderr: " + stderrBuf.slice(0, 2000));
4138
5543
  try {
4139
5544
  var success = code === 0;
4140
5545
  send({
@@ -4149,6 +5554,8 @@ function createProjectContext(opts) {
4149
5554
  }
4150
5555
  });
4151
5556
  child.on("error", function (err) {
5557
+ clearTimeout(installTimeout);
5558
+ console.error("[skill-install] " + skill + " spawn error:", err.message || err);
4152
5559
  try {
4153
5560
  send({
4154
5561
  type: "skill_installed",
@@ -4158,7 +5565,7 @@ function createProjectContext(opts) {
4158
5565
  error: err.message,
4159
5566
  });
4160
5567
  } catch (e) {
4161
- console.error("[project] skill_installed error send failed:", e.message || e);
5568
+ console.error("[skill-install] " + skill + " send failed:", e.message || e);
4162
5569
  }
4163
5570
  });
4164
5571
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -4346,6 +5753,7 @@ function createProjectContext(opts) {
4346
5753
  (function (skill) {
4347
5754
  var installedVer = getInstalledVersion(skill.name);
4348
5755
  var installed = !!installedVer;
5756
+ console.log("[skill-check] " + skill.name + " installed=" + installed + " localVersion=" + (installedVer || "none"));
4349
5757
  // Convert GitHub repo URL to raw SKILL.md URL
4350
5758
  var rawUrl = "";
4351
5759
  var ghMatch = skill.url.match(/github\.com\/([^/]+)\/([^/]+)/);
@@ -4353,13 +5761,16 @@ function createProjectContext(opts) {
4353
5761
  rawUrl = "https://raw.githubusercontent.com/" + ghMatch[1] + "/" + ghMatch[2] + "/main/SKILL.md";
4354
5762
  }
4355
5763
  if (!rawUrl) {
5764
+ console.log("[skill-check] " + skill.name + " no valid GitHub URL, skipping remote check");
4356
5765
  results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
4357
5766
  finishOne();
4358
5767
  return;
4359
5768
  }
5769
+ console.log("[skill-check] " + skill.name + " fetching remote: " + rawUrl);
4360
5770
  // Fetch remote SKILL.md
4361
5771
  var https = require("https");
4362
5772
  https.get(rawUrl, function (resp) {
5773
+ console.log("[skill-check] " + skill.name + " remote response status=" + resp.statusCode);
4363
5774
  var data = "";
4364
5775
  resp.on("data", function (chunk) { data += chunk; });
4365
5776
  resp.on("end", function () {
@@ -4371,15 +5782,17 @@ function createProjectContext(opts) {
4371
5782
  } else if (remoteVer && compareVersions(installedVer, remoteVer) < 0) {
4372
5783
  status = "outdated";
4373
5784
  }
5785
+ console.log("[skill-check] " + skill.name + " remoteVersion=" + remoteVer + " status=" + status);
4374
5786
  results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: remoteVer, status: status });
4375
5787
  finishOne();
4376
5788
  } catch (e) {
4377
- console.error("[project] Skill version check failed for " + skill.name + ":", e.message || e);
5789
+ console.error("[skill-check] " + skill.name + " version parse failed:", e.message || e);
4378
5790
  results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "error" });
4379
5791
  finishOne();
4380
5792
  }
4381
5793
  });
4382
- }).on("error", function () {
5794
+ }).on("error", function (err) {
5795
+ console.error("[skill-check] " + skill.name + " fetch error:", err.message || err);
4383
5796
  results.push({ name: skill.name, installed: installed, installedVersion: installedVer, remoteVersion: "", status: installed ? "ok" : "missing" });
4384
5797
  finishOne();
4385
5798
  });