clay-server 2.34.0-beta.3 → 2.34.0-beta.5

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.
@@ -1855,7 +1855,7 @@
1855
1855
  <div class="mate-intro-step"><span class="mate-intro-step-num">2</span> Have a short interview where your Mate gets to know you</div>
1856
1856
  <div class="mate-intro-step"><span class="mate-intro-step-num">3</span> Start talking</div>
1857
1857
  </div>
1858
- <p class="mate-intro-privacy">Mates run on Claude Code. No separate API keys or external services needed. Your conversations stay on your Clay server and are never stored elsewhere.</p>
1858
+ <p class="mate-intro-privacy">Mates run on Claude Code or ChatGPT Codex, your choice. No separate API keys or external services needed. Your conversations stay on your Clay server and are never stored elsewhere.</p>
1859
1859
  </div>
1860
1860
  </div>
1861
1861
  <!-- Step 1: Relationship -->
@@ -1996,26 +1996,24 @@
1996
1996
  </div>
1997
1997
  </div>
1998
1998
 
1999
- <!-- Step 4: Autonomy -->
1999
+ <!-- Step 4: Vendor -->
2000
2000
  <div class="mate-step" data-step="4">
2001
- <h3>How much should they do on their own?</h3>
2002
- <p class="mate-hint">This is about how much freedom your mate gets before checking with you.</p>
2003
- <div class="mate-autonomy-options">
2004
- <button class="mate-autonomy-btn" data-value="always_ask">
2005
- <span class="mate-autonomy-title">Ask me everything</span>
2006
- <span class="mate-autonomy-desc">"Before you do anything, run it by me first."</span>
2007
- </button>
2008
- <button class="mate-autonomy-btn active" data-value="minor_stuff_ok">
2009
- <span class="mate-autonomy-title">Small stuff is fine</span>
2010
- <span class="mate-autonomy-desc">"Little things, just do it. Anything big, ask me."</span>
2011
- </button>
2012
- <button class="mate-autonomy-btn" data-value="mostly_autonomous">
2013
- <span class="mate-autonomy-title">Mostly free</span>
2014
- <span class="mate-autonomy-desc">"Do your thing. Only check if it's really important."</span>
2001
+ <h3>Which model should power them?</h3>
2002
+ <p class="mate-hint">You can switch this anytime from the mate's settings.</p>
2003
+ <div class="mate-vendor-option-list">
2004
+ <button class="mate-vendor-option-btn active" data-value="claude">
2005
+ <img src="/claude-code-avatar.png" class="mate-vendor-option-icon" alt="Claude Code">
2006
+ <div class="mate-vendor-option-text">
2007
+ <span class="mate-vendor-option-title">Claude Code</span>
2008
+ <span class="mate-vendor-option-desc">Anthropic's Claude. Thoughtful, conversational, strong at nuanced reasoning and long-form writing. A good default for most mates.</span>
2009
+ </div>
2015
2010
  </button>
2016
- <button class="mate-autonomy-btn" data-value="fully_autonomous">
2017
- <span class="mate-autonomy-title">Full freedom</span>
2018
- <span class="mate-autonomy-desc">"Just get it done and tell me after."</span>
2011
+ <button class="mate-vendor-option-btn" data-value="codex">
2012
+ <img src="/codex-avatar.png" class="mate-vendor-option-icon" alt="ChatGPT Codex">
2013
+ <div class="mate-vendor-option-text">
2014
+ <span class="mate-vendor-option-title">ChatGPT Codex</span>
2015
+ <span class="mate-vendor-option-desc">OpenAI's GPT coding agent. Fast and direct, strong at code generation and tight execution loops. Good for builder-type mates.</span>
2016
+ </div>
2019
2017
  </button>
2020
2018
  </div>
2021
2019
  </div>
@@ -474,8 +474,6 @@ export function buildMateInterviewPrompt(mate) {
474
474
  var styles = sd.communicationStyle.map(function (s) { return styleLabels[s] || s.replace(/_/g, " "); });
475
475
  parts.push("Communication: " + styles.join(", "));
476
476
  }
477
- if (sd.autonomy) parts.push("Autonomy: " + sd.autonomy.replace(/_/g, " "));
478
-
479
477
  return "Use the /clay-mate-interview skill to start the interview.\n\n" +
480
478
  "Mate ID: " + mate.id + "\n" +
481
479
  "Mate Directory: .claude/mates/" + mate.id + "\n\n" +
@@ -732,6 +732,8 @@ export function processMessage(msg) {
732
732
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
733
733
  } else if (msg.name === "propose_debate" || (msg.name && msg.name.indexOf("propose_debate") !== -1)) {
734
734
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
735
+ } else if (msg.name === "ask_user_questions") {
736
+ getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
735
737
  } else if (getTodoTools()[msg.name]) {
736
738
  getTools()[msg.id] = { el: null, name: msg.name, input: null, done: true, hidden: true };
737
739
  } else {
@@ -225,15 +225,6 @@ export function showMateSidebar(mateId, mateData) {
225
225
  if (sd.communicationStyle && sd.communicationStyle.length > 0) {
226
226
  seedTooltip.appendChild(makeSeedTags("Style", sd.communicationStyle.map(function (s) { return s.replace(/_/g, " "); })));
227
227
  }
228
- if (sd.autonomy) {
229
- var autonomyLabels = {
230
- always_ask: "Ask me everything",
231
- minor_stuff_ok: "Small stuff is fine",
232
- mostly_autonomous: "Mostly free",
233
- fully_autonomous: "Full freedom",
234
- };
235
- seedTooltip.appendChild(makeSeedRow("Autonomy", autonomyLabels[sd.autonomy] || sd.autonomy.replace(/_/g, " ")));
236
- }
237
228
  }
238
229
 
239
230
  // Clear session list
@@ -6,7 +6,7 @@ var mateWizardData = {
6
6
  relationship: null,
7
7
  activity: [],
8
8
  communicationStyle: [],
9
- autonomy: "minor_stuff_ok",
9
+ vendor: "claude",
10
10
  };
11
11
 
12
12
  var _sendWs = null;
@@ -85,19 +85,19 @@ export function initMateWizard(sendWs, onMateCreated) {
85
85
  })(styleCards[i]);
86
86
  }
87
87
 
88
- // Step 4: Autonomy button clicks
89
- var autonomyBtns = document.querySelectorAll("#mate-wizard .mate-autonomy-btn");
90
- for (var i = 0; i < autonomyBtns.length; i++) {
88
+ // Step 4: Vendor button clicks
89
+ var vendorBtns = document.querySelectorAll("#mate-wizard .mate-vendor-option-btn");
90
+ for (var i = 0; i < vendorBtns.length; i++) {
91
91
  (function (btn) {
92
92
  btn.addEventListener("click", function () {
93
- var allBtns = document.querySelectorAll("#mate-wizard .mate-autonomy-btn");
93
+ var allBtns = document.querySelectorAll("#mate-wizard .mate-vendor-option-btn");
94
94
  for (var j = 0; j < allBtns.length; j++) {
95
95
  allBtns[j].classList.remove("active");
96
96
  }
97
97
  btn.classList.add("active");
98
- mateWizardData.autonomy = btn.dataset.value;
98
+ mateWizardData.vendor = btn.dataset.value;
99
99
  });
100
- })(autonomyBtns[i]);
100
+ })(vendorBtns[i]);
101
101
  }
102
102
  }
103
103
 
@@ -107,7 +107,7 @@ export function openMateWizard() {
107
107
  relationship: null,
108
108
  activity: [],
109
109
  communicationStyle: [],
110
- autonomy: "minor_stuff_ok",
110
+ vendor: "claude",
111
111
  };
112
112
 
113
113
  // Reset UI
@@ -136,12 +136,12 @@ export function openMateWizard() {
136
136
  styleCards[i].classList.remove("selected");
137
137
  }
138
138
 
139
- // Reset autonomy
140
- var autonomyBtns = el.querySelectorAll(".mate-autonomy-btn");
141
- for (var i = 0; i < autonomyBtns.length; i++) {
142
- autonomyBtns[i].classList.remove("active");
143
- if (autonomyBtns[i].dataset.value === "minor_stuff_ok") {
144
- autonomyBtns[i].classList.add("active");
139
+ // Reset vendor
140
+ var vendorBtns = el.querySelectorAll(".mate-vendor-option-btn");
141
+ for (var i = 0; i < vendorBtns.length; i++) {
142
+ vendorBtns[i].classList.remove("active");
143
+ if (vendorBtns[i].dataset.value === "claude") {
144
+ vendorBtns[i].classList.add("active");
145
145
  }
146
146
  }
147
147
 
@@ -220,7 +220,7 @@ function collectMateWizardData() {
220
220
  mateWizardData.communicationStyle.push(selectedStyles[i].dataset.value);
221
221
  }
222
222
 
223
- // Autonomy (already set via button clicks)
223
+ // Vendor (already set via button clicks)
224
224
  }
225
225
 
226
226
  function mateWizardNext() {
@@ -1587,14 +1587,32 @@ export function stopThinking(duration) {
1587
1587
  } else {
1588
1588
  currentThinking.el.querySelector(".thinking-duration").textContent = " " + secs.toFixed(1) + "s";
1589
1589
  }
1590
- // In mate mode: hide sparkle activity, show compact expandable thinking header
1590
+ // If no thinking text was streamed (e.g. Codex reasoning items arrive
1591
+ // with encrypted/hidden content, or Claude without extended-thinking),
1592
+ // the expand affordance is misleading because there's nothing inside.
1593
+ // Strip the chevron and the click handler so the header reads as a
1594
+ // plain label.
1595
+ var hasContent = !!(currentThinking.fullText && currentThinking.fullText.length > 0);
1596
+ if (!hasContent) {
1597
+ currentThinking.el.classList.add("empty");
1598
+ var chev = currentThinking.el.querySelector(".thinking-chevron");
1599
+ if (chev) chev.style.display = "none";
1600
+ var hdr = currentThinking.el.querySelector(".thinking-header");
1601
+ if (hdr) {
1602
+ hdr.style.cursor = "default";
1603
+ // Replace click listener by cloning the node (cheapest way to strip listeners).
1604
+ var clone = hdr.cloneNode(true);
1605
+ hdr.parentNode.replaceChild(clone, hdr);
1606
+ }
1607
+ }
1608
+ // In mate mode: hide sparkle activity, show compact thinking header.
1591
1609
  if (currentThinking.el.classList.contains("mate-thinking")) {
1592
1610
  var actRow = currentThinking.el.querySelector(".mate-thinking-activity");
1593
1611
  if (actRow) actRow.style.display = "none";
1594
1612
  var header = currentThinking.el.querySelector(".thinking-header");
1595
1613
  if (header) {
1596
1614
  header.style.display = "";
1597
- header.style.cursor = "pointer";
1615
+ header.style.cursor = hasContent ? "pointer" : "default";
1598
1616
  }
1599
1617
  }
1600
1618
  currentThinking = null;
package/lib/sdk-bridge.js CHANGED
@@ -1210,21 +1210,30 @@ function createSDKBridge(opts) {
1210
1210
  }
1211
1211
  }
1212
1212
 
1213
- // Use vendor-specific model: if session vendor differs from default, use that vendor's default model
1213
+ // Pick a model that belongs to the session's vendor. sm.currentModel is
1214
+ // shared project-wide, so a Codex session that last set it to
1215
+ // "gpt-5.4-mini" would otherwise leak into a Claude session in the same
1216
+ // project (or in another session that switches vendor to claude) and
1217
+ // Claude would reject the unknown model. We validate against the
1218
+ // session vendor's model list regardless of which vendor happens to be
1219
+ // the project's default adapter.
1214
1220
  var queryModel = ls.model || sm.currentModel || undefined;
1215
- if (session.vendor && session.vendor !== (adapter && adapter.vendor)) {
1216
- var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[session.vendor]) || [];
1221
+ var sessionVendor = session.vendor || (adapter && adapter.vendor) || null;
1222
+ if (sessionVendor) {
1223
+ var vendorModels = (sm.modelsByVendor && sm.modelsByVendor[sessionVendor]) || [];
1217
1224
  if (vendorModels.length > 0 && queryModel && vendorModels.indexOf(queryModel) === -1) {
1218
1225
  queryModel = vendorModels[0];
1219
1226
  }
1220
1227
  }
1221
1228
 
1222
1229
  var codexConfig = getCodexConfig(sm);
1230
+ var mergedMcpServers = mergeMcpServers(getMcpServers(), getRemoteMcpServers) || undefined;
1223
1231
  var queryOpts = {
1224
1232
  cwd: cwd,
1225
1233
  model: queryModel,
1226
1234
  effort: ls.effort || sm.currentEffort || undefined,
1227
- toolServers: mergeMcpServers(getMcpServers(), getRemoteMcpServers) || undefined,
1235
+ toolServers: mergedMcpServers,
1236
+ toolServerDescriptors: extractMcpDescriptors(mergedMcpServers) || undefined,
1228
1237
  resumeSessionId: session.cliSessionId || undefined,
1229
1238
  abortController: linuxUser ? undefined : session.abortController,
1230
1239
  canUseTool: function(toolName, input, toolOpts) {
@@ -1233,6 +1242,9 @@ function createSDKBridge(opts) {
1233
1242
  onElicitation: function(request, elicitOpts) {
1234
1243
  return handleElicitation(session, request, elicitOpts);
1235
1244
  },
1245
+ callMcpTool: function(serverName, toolName, args) {
1246
+ return callMcpToolHandler(mergedMcpServers, serverName, toolName, args);
1247
+ },
1236
1248
  adapterOptions: {
1237
1249
  CLAUDE: claudeOpts,
1238
1250
  CODEX: {
@@ -1443,22 +1455,14 @@ function createSDKBridge(opts) {
1443
1455
  }
1444
1456
  }
1445
1457
 
1446
- // Initialize other adapters in parallel (skip if already have models cached)
1458
+ // Non-default adapters are NOT eagerly initialized here. Doing so used
1459
+ // to spawn a CodexAppServer and an mcp-bridge child per project even
1460
+ // when the user never touched that vendor. Lazy paths cover the gap:
1461
+ // - get_vendor_models (project.js) inits a vendor when the user
1462
+ // opens its model picker.
1463
+ // - ensureVendorReady (this file) inits a vendor when a session
1464
+ // actually issues a query with it.
1447
1465
  sm.modelsByVendor = sm.modelsByVendor || {};
1448
- var otherVendors = Object.keys(adapters).filter(function(v) {
1449
- return v !== defaultVendor && !sm.modelsByVendor[v];
1450
- });
1451
- for (var i = 0; i < otherVendors.length; i++) {
1452
- (function(v) {
1453
- adapters[v].init({ cwd: cwd, clayPort: clayPort, clayTls: clayTls, clayAuthToken: clayAuthToken, slug: slug }).then(function(r) {
1454
- sm.modelsByVendor[v] = r.models || [];
1455
- sm.capabilitiesByVendor[v] = r.capabilities || {};
1456
- if (r.slashCommands) sm.setSlashCommandsForVendor(v, r.slashCommands);
1457
- }).catch(function(e) {
1458
- console.error("[sdk-bridge] warmup: " + v + " init failed:", e.message || e);
1459
- });
1460
- })(otherVendors[i]);
1461
- }
1462
1466
 
1463
1467
  // Detect installed vendors per-user (binary existence check)
1464
1468
  sm.installedVendors = detectInstalledVendors(linuxUser);
@@ -370,12 +370,23 @@ function attachMessageProcessor(ctx) {
370
370
  session.sentToolResults = {};
371
371
  session.pendingPermissions = {};
372
372
  session.pendingElicitations = {};
373
- // Record ask_user_answered for any leftover pending questions so replay pairs correctly
373
+ // Record ask_user_answered for any leftover pending questions so replay pairs correctly.
374
+ // EXCEPTION: "mcp" mode entries are stateless — the tool returned immediately and the
375
+ // turn is expected to end while the card is still awaiting the user's answer. Those
376
+ // entries must survive across turns so the eventual ask_user_response can inject the
377
+ // answer as the next user message. Only blocking modes (Claude canUseTool) get closed.
374
378
  var leftoverAskIds = Object.keys(session.pendingAskUser);
379
+ var keptAskUser = {};
375
380
  for (var lai = 0; lai < leftoverAskIds.length; lai++) {
376
- sendAndRecord(session, { type: "ask_user_answered", toolId: leftoverAskIds[lai] });
381
+ var lid = leftoverAskIds[lai];
382
+ var lentry = session.pendingAskUser[lid];
383
+ if (lentry && lentry.mode === "mcp") {
384
+ keptAskUser[lid] = lentry;
385
+ continue;
386
+ }
387
+ sendAndRecord(session, { type: "ask_user_answered", toolId: lid });
377
388
  }
378
- session.pendingAskUser = {};
389
+ session.pendingAskUser = keptAskUser;
379
390
  session.activeTaskToolIds = {};
380
391
  session.taskIdMap = {};
381
392
  // Only clear rateLimitResetsAt on genuine success (non-zero cost).
package/lib/server.js CHANGED
@@ -459,75 +459,6 @@ function createServer(opts) {
459
459
  return;
460
460
  }
461
461
 
462
- // --- Global MCP bridge endpoint (localhost only) ---
463
- // Used by Codex mcp-bridge-server.js which can't know the active project slug.
464
- // Aggregates MCP tools from all project contexts.
465
- if (req.method === "POST" && fullUrl === "/api/mcp-bridge") {
466
- var mcpRemoteAddr = req.socket.remoteAddress || "";
467
- var mcpIsLocal = mcpRemoteAddr === "127.0.0.1" || mcpRemoteAddr === "::1" || mcpRemoteAddr === "::ffff:127.0.0.1";
468
- if (!mcpIsLocal) {
469
- res.writeHead(403, { "Content-Type": "application/json" });
470
- res.end('{"error":"Forbidden"}');
471
- return;
472
- }
473
- var parseJsonBody = function(r) {
474
- return new Promise(function(resolve, reject) {
475
- var chunks = [];
476
- r.on("data", function(c) { chunks.push(c); });
477
- r.on("end", function() {
478
- try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
479
- catch(e) { reject(e); }
480
- });
481
- });
482
- };
483
- parseJsonBody(req).then(function(body) {
484
- // Find the first project context that has a MCP bridge handler
485
- var handler = null;
486
- projects.forEach(function(ctx) {
487
- if (handler) return;
488
- if (ctx.getMcpBridgeHandler) {
489
- var h = ctx.getMcpBridgeHandler();
490
- if (h) handler = h;
491
- }
492
- });
493
- if (!handler) {
494
- res.writeHead(500, { "Content-Type": "application/json" });
495
- res.end('{"error":"No MCP bridge handler available"}');
496
- return;
497
- }
498
- if (body.action === "list_tools") {
499
- handler.listTools().then(function(tools) {
500
- var serverCounts = {};
501
- for (var ti = 0; ti < tools.length; ti++) {
502
- serverCounts[tools[ti].server] = (serverCounts[tools[ti].server] || 0) + 1;
503
- }
504
- console.log("[mcp-bridge-http] global list_tools:", tools.length, "tools -", Object.keys(serverCounts).map(function(s) { return s + "(" + serverCounts[s] + ")"; }).join(", ") || "(none)");
505
- res.writeHead(200, { "Content-Type": "application/json" });
506
- res.end(JSON.stringify({ tools: tools }));
507
- }).catch(function(err) {
508
- res.writeHead(500, { "Content-Type": "application/json" });
509
- res.end(JSON.stringify({ error: err.message }));
510
- });
511
- } else if (body.action === "call_tool") {
512
- console.log("[mcp-bridge-http] global call_tool:", body.server + "/" + body.tool);
513
- handler.callTool(body.server, body.tool, body.args || {}).then(function(result) {
514
- res.writeHead(200, { "Content-Type": "application/json" });
515
- res.end(JSON.stringify({ result: result }));
516
- }).catch(function(err) {
517
- res.writeHead(200, { "Content-Type": "application/json" });
518
- res.end(JSON.stringify({ error: err.message }));
519
- });
520
- } else {
521
- res.writeHead(400, { "Content-Type": "application/json" });
522
- res.end('{"error":"Unknown action"}');
523
- }
524
- }).catch(function() {
525
- res.writeHead(400, { "Content-Type": "application/json" });
526
- res.end('{"error":"Invalid JSON"}');
527
- });
528
- return;
529
- }
530
-
531
462
  // --- Skills routes (delegated to server-skills) ---
532
463
  if (skills.handleRequest(req, res, fullUrl)) return;
533
464
 
@@ -1090,7 +1021,11 @@ function createServer(opts) {
1090
1021
  getProject: function (s) { return projects.get(s) || null; },
1091
1022
  });
1092
1023
  projects.set(slug, ctx);
1093
- ctx.warmup();
1024
+ // ctx.warmup() is now deferred to the first websocket connection into
1025
+ // this project (see project-connection.js handleConnection). Warming
1026
+ // every project at startup spawned a CodexAppServer and an mcp-bridge
1027
+ // child for each one, which cost 30+ processes on daemons with many
1028
+ // projects/mates even though the user typically only opens one.
1094
1029
  // Schedule project registry refresh for all mates when a non-mate project is added
1095
1030
  if (!extra.isMate) scheduleRegistryRefresh();
1096
1031
  return true;
@@ -1126,8 +1061,13 @@ function createServer(opts) {
1126
1061
  var ctx = projects.get(slug);
1127
1062
  if (!ctx) return false;
1128
1063
  var wasMate = ctx.getStatus().isMate;
1129
- ctx.destroy();
1064
+ var shutdownResult = ctx.destroy();
1130
1065
  projects.delete(slug);
1066
+ if (shutdownResult && typeof shutdownResult.catch === "function") {
1067
+ shutdownResult.catch(function(err) {
1068
+ console.error("[server] Project destroy failed for " + slug + ":", err && err.message ? err.message : err);
1069
+ });
1070
+ }
1131
1071
  if (!wasMate) scheduleRegistryRefresh();
1132
1072
  return true;
1133
1073
  }
@@ -1289,12 +1229,26 @@ function createServer(opts) {
1289
1229
  });
1290
1230
  }
1291
1231
 
1232
+ function forEachProject(fn) {
1233
+ projects.forEach(function (ctx, slug) {
1234
+ fn(ctx, slug);
1235
+ });
1236
+ }
1237
+
1292
1238
  function destroyAll() {
1239
+ var shutdowns = [];
1293
1240
  projects.forEach(function (ctx, slug) {
1294
1241
  console.log("[server] Destroying project:", slug);
1295
- ctx.destroy();
1242
+ var result = ctx.destroy();
1243
+ if (result && typeof result.then === "function") {
1244
+ shutdowns.push(result.catch(function(err) {
1245
+ console.error("[server] Project destroy failed for " + slug + ":", err && err.message ? err.message : err);
1246
+ return false;
1247
+ }));
1248
+ }
1296
1249
  });
1297
1250
  projects.clear();
1251
+ return Promise.all(shutdowns);
1298
1252
  }
1299
1253
 
1300
1254
  // --- Periodic cleanup of old chat images ---
@@ -1358,6 +1312,8 @@ function createServer(opts) {
1358
1312
  setRecovery: auth.setRecovery,
1359
1313
  clearRecovery: auth.clearRecovery,
1360
1314
  broadcastAll: broadcastAll,
1315
+ forEachProject: forEachProject,
1316
+ destroyProject: removeProject,
1361
1317
  destroyAll: destroyAll,
1362
1318
  };
1363
1319
  }
package/lib/sessions.js CHANGED
@@ -680,35 +680,82 @@ function createSessionManager(opts) {
680
680
  var session = sessions.get(localId);
681
681
  if (!session) return { hits: [], total: 0 };
682
682
  var q = query.toLowerCase();
683
+ var qLen = query.length;
683
684
  var history = session.history;
684
685
  var hits = [];
685
- var lastAssistantHitTurn = -1; // track current assistant turn to deduplicate delta hits
686
- var currentTurnStart = -1;
686
+
687
+ // Assistant turns can consist of many streaming deltas (especially Codex,
688
+ // where agentMessage/delta fragments arrive in small chunks). We accumulate
689
+ // delta text per turn, scan for ALL occurrences of the query across the
690
+ // accumulated buffer, then map each occurrence back to the historyIndex of
691
+ // the delta that contains its starting offset. This catches multiple
692
+ // matches within a single turn and also matches that straddle delta
693
+ // boundaries.
694
+ var turnBuffer = "";
695
+ var turnSegments = []; // [{ start, end, historyIndex, ts }]
696
+
697
+ function pushScalarHits(text, historyIndex, role, ts) {
698
+ if (!text) return;
699
+ var lower = text.toLowerCase();
700
+ var from = 0;
701
+ while (true) {
702
+ var idx = lower.indexOf(q, from);
703
+ if (idx === -1) break;
704
+ var s = Math.max(0, idx - 15);
705
+ var e = Math.min(text.length, idx + qLen + 15);
706
+ var snippet = (s > 0 ? "\u2026" : "") + text.substring(s, e) + (e < text.length ? "\u2026" : "");
707
+ hits.push({ historyIndex: historyIndex, snippet: snippet, role: role, ts: ts });
708
+ from = idx + qLen;
709
+ }
710
+ }
711
+
712
+ function flushTurn() {
713
+ if (!turnBuffer || turnSegments.length === 0) {
714
+ turnBuffer = "";
715
+ turnSegments = [];
716
+ return;
717
+ }
718
+ var lowerBuf = turnBuffer.toLowerCase();
719
+ var from = 0;
720
+ var segCursor = 0;
721
+ while (true) {
722
+ var idx = lowerBuf.indexOf(q, from);
723
+ if (idx === -1) break;
724
+ // Advance segCursor to the segment containing idx.
725
+ while (segCursor < turnSegments.length - 1 && turnSegments[segCursor].end <= idx) {
726
+ segCursor++;
727
+ }
728
+ var seg = turnSegments[segCursor];
729
+ var s = Math.max(0, idx - 15);
730
+ var e = Math.min(turnBuffer.length, idx + qLen + 15);
731
+ var snippet = (s > 0 ? "\u2026" : "") + turnBuffer.substring(s, e) + (e < turnBuffer.length ? "\u2026" : "");
732
+ hits.push({ historyIndex: seg.historyIndex, snippet: snippet, role: "assistant", ts: seg.ts });
733
+ from = idx + qLen;
734
+ }
735
+ turnBuffer = "";
736
+ turnSegments = [];
737
+ }
738
+
687
739
  for (var i = 0; i < history.length; i++) {
688
740
  var entry = history[i];
689
- if (entry.type === "user_message" || entry.type === "mention_user") {
690
- currentTurnStart = i;
691
- lastAssistantHitTurn = -1;
692
- }
693
- if ((entry.type === "delta" || entry.type === "user_message" || entry.type === "mention_user" || entry.type === "mention_response" || entry.type === "debate_turn_done" || entry.type === "debate_comment_injected") && entry.text) {
694
- // Skip duplicate delta hits within the same assistant turn
695
- if (entry.type === "delta" && currentTurnStart === lastAssistantHitTurn) continue;
696
- var text = entry.text;
697
- var lowerText = text.toLowerCase();
698
- var idx = lowerText.indexOf(q);
699
- if (idx === -1) continue;
700
- var start = Math.max(0, idx - 15);
701
- var end = Math.min(text.length, idx + query.length + 15);
702
- var snippet = (start > 0 ? "\u2026" : "") + text.substring(start, end) + (end < text.length ? "\u2026" : "");
703
- if (entry.type === "delta") lastAssistantHitTurn = currentTurnStart;
704
- hits.push({
741
+ var t = entry.type;
742
+ if (t === "user_message" || t === "mention_user") {
743
+ flushTurn();
744
+ pushScalarHits(entry.text, i, t === "user_message" ? "user" : "assistant", entry._ts || null);
745
+ } else if (t === "delta" && entry.text) {
746
+ turnSegments.push({
747
+ start: turnBuffer.length,
748
+ end: turnBuffer.length + entry.text.length,
705
749
  historyIndex: i,
706
- snippet: snippet,
707
- role: entry.type === "user_message" ? "user" : "assistant",
708
750
  ts: entry._ts || null,
709
751
  });
752
+ turnBuffer += entry.text;
753
+ } else if ((t === "mention_response" || t === "debate_turn_done" || t === "debate_comment_injected") && entry.text) {
754
+ flushTurn();
755
+ pushScalarHits(entry.text, i, "assistant", entry._ts || null);
710
756
  }
711
757
  }
758
+ flushTurn();
712
759
  return { hits: hits, total: history.length };
713
760
  }
714
761