@geravant/sinain 1.22.8 → 1.23.1

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.
@@ -31,6 +31,12 @@ export interface AgentLoopDeps {
31
31
  profiler?: Profiler;
32
32
  /** Called after each successful SITUATION.md write with the content string. */
33
33
  onSituationUpdate?: (content: string) => void;
34
+ /** Predicate to skip SITUATION.md writes entirely when no consumer is
35
+ * listening. Today only the openclaw module reads SITUATION.md, so when
36
+ * no gateway-typed agent is selected this returns false and the disk
37
+ * write is skipped on every tick. Defaults to "always write" if absent
38
+ * (preserves prior behavior for callers that don't pass the predicate). */
39
+ shouldWriteSituation?: () => boolean;
34
40
  /** Optional: path to sinain-knowledge.md for startup recap. */
35
41
  getKnowledgeDocPath?: () => string | null;
36
42
  /** Optional: feedback store for startup recap context. */
@@ -376,9 +382,14 @@ export class AgentLoop extends EventEmitter {
376
382
  // Calculate escalation score for both SITUATION.md and escalation check
377
383
  const escalationScore = calculateEscalationScore(digest, contextWindow);
378
384
 
379
- // Write SITUATION.md (enhanced with escalation context and recorder status)
380
- const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus);
381
- this.deps.onSituationUpdate?.(situationContent);
385
+ // Write SITUATION.md only when something consumes it (today: an openclaw
386
+ // gateway lane is selected). Without a consumer, skip the disk write to
387
+ // avoid pinning ~/.openclaw/workspace/SITUATION.md on every tick of users
388
+ // who chose claude/openclaude/etc as both lanes.
389
+ if (this.deps.shouldWriteSituation?.() ?? true) {
390
+ const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus);
391
+ this.deps.onSituationUpdate?.(situationContent);
392
+ }
382
393
 
383
394
  // Notify for escalation check
384
395
  traceCtx?.startSpan("escalation-check");
@@ -170,17 +170,38 @@ export class Escalator {
170
170
  log(TAG, `user command set: "${preview}"`);
171
171
  }
172
172
 
173
+ /** True iff a gateway-typed profile is the active agent on at least one
174
+ * lane. WS-bearing operations (connect, reset, situation push) are gated
175
+ * on this so a user with a configured-but-unselected gateway pays no
176
+ * reconnect tax. */
177
+ private isGatewayLaneSelected(): boolean {
178
+ const isGw = this.deps.isGatewayAgent;
179
+ if (!isGw) return false;
180
+ const esc = this.deps.getEscalationAgent?.() ?? "";
181
+ const spawn = this.deps.getSpawnAgent?.() ?? "";
182
+ return isGw(esc) || isGw(spawn);
183
+ }
184
+
185
+ /** Public predicate so the agent loop / index.ts can ask "should I do
186
+ * openclaw-only side-effects on this tick?" without depending on the
187
+ * internals. Mirrors isGatewayLaneSelected. */
188
+ shouldDriveGateway(): boolean {
189
+ const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
190
+ return wsConfigured && this.isGatewayLaneSelected();
191
+ }
192
+
173
193
  /** Start the WS connection to OpenClaw.
174
194
  *
175
- * Connects whenever the gateway URL is configured AND escalation isn't
176
- * fully off. WS is the transport for the openclaw lane the user selects
177
- * it via the overlay's agent picker, and dispatch routes accordingly.
178
- * Removing the openclaw profile from agents.json (and unsetting the env
179
- * vars) leaves gatewayWsUrl empty no connect attempt.
195
+ * Connects whenever the gateway URL is configured AND a gateway-typed
196
+ * profile is selected on a lane AND escalation isn't fully off. WS is
197
+ * the transport for the openclaw lane the user selects it via the
198
+ * overlay's agent picker, and dispatch routes accordingly. Removing the
199
+ * openclaw profile from agents.json (and unsetting the env vars) leaves
200
+ * gatewayWsUrl empty → no connect attempt. Likewise, if the profile
201
+ * exists but no lane selects it, no connect attempt.
180
202
  */
181
203
  start(): void {
182
- const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
183
- if (this.deps.escalationConfig.mode !== "off" && wsConfigured) {
204
+ if (this.deps.escalationConfig.mode !== "off" && this.shouldDriveGateway()) {
184
205
  this.wsClient.connect();
185
206
  const tokenHash = this.deps.openclawConfig.gatewayToken
186
207
  ? createHash("sha256").update(this.deps.openclawConfig.gatewayToken).digest("hex").slice(0, 12)
@@ -194,11 +215,26 @@ export class Escalator {
194
215
  this.wsClient.disconnect();
195
216
  }
196
217
 
218
+ /** Re-evaluate WS lifecycle after lane selection changes. Connects when a
219
+ * gateway lane just got selected; disconnects when the user moved off
220
+ * every gateway lane. Called from the set_agent overlay handler. */
221
+ evaluateGatewayLifecycle(): void {
222
+ const shouldConnect =
223
+ this.deps.escalationConfig.mode !== "off" && this.shouldDriveGateway();
224
+ if (shouldConnect && !this.wsClient.isConnected) {
225
+ log(TAG, "lane switched to gateway — connecting WS");
226
+ this.wsClient.resetConnection();
227
+ } else if (!shouldConnect && this.wsClient.isConnected) {
228
+ log(TAG, "lane switched off gateway — disconnecting WS");
229
+ this.wsClient.disconnect();
230
+ }
231
+ }
232
+
197
233
  /** Update escalation mode at runtime. */
198
234
  setMode(mode: EscalatorDeps["escalationConfig"]["mode"]): void {
199
235
  const wasOff = this.deps.escalationConfig.mode === "off";
200
236
  this.deps.escalationConfig.mode = mode;
201
- if (mode !== "off" && !this.wsClient.isConnected) {
237
+ if (mode !== "off" && !this.wsClient.isConnected && this.shouldDriveGateway()) {
202
238
  this.wsClient.resetConnection();
203
239
  }
204
240
  if (mode === "off") {
@@ -743,6 +743,11 @@ async function main() {
743
743
  onSituationUpdate: (content) => {
744
744
  escalator.pushSituationMd(content);
745
745
  },
746
+ // Gate SITUATION.md writes (and the subsequent push) on a gateway lane
747
+ // being active — see escalator.shouldDriveGateway. Users with no openclaw
748
+ // profile, or with the profile but no lane selecting it, pay zero disk
749
+ // I/O on every tick.
750
+ shouldWriteSituation: () => escalator.shouldDriveGateway(),
746
751
  onHudUpdate: (text) => {
747
752
  wsHandler.broadcastRaw({ type: "thinking", active: false } as any);
748
753
  wsHandler.broadcast(text, "normal", "stream");
@@ -1241,6 +1246,12 @@ async function main() {
1241
1246
  // Spawn "off" just means run.sh won't poll /spawn/pending; no
1242
1247
  // server-side state to flip. Queued spawn tasks TTL out naturally.
1243
1248
  }
1249
+ // Re-evaluate WS lifecycle: connect when a gateway lane just got
1250
+ // selected (zero attempts before this point), disconnect when the user
1251
+ // moved off every gateway lane. This is what makes the "no resources
1252
+ // when not in use" guarantee hold across runtime selection changes,
1253
+ // not just startup config.
1254
+ escalator.evaluateGatewayLifecycle();
1244
1255
  // Rebroadcast state so the overlay sees the switch immediately, and
1245
1256
  // the bare agent sees it on its next poll-response config piggyback.
1246
1257
  // `escalation` field reflects the current escalator mode so the flash
@@ -942,14 +942,16 @@ function setupSearch() {
942
942
  const q = input.value.trim();
943
943
  if (!q) { dropdown.classList.remove("open"); dropdown.innerHTML = ""; return; }
944
944
  const result = await api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=15");
945
+ // Always show "Search: query" as first option → topic page with combined recall
946
+ const topicLink = \`
947
+ <div class="search-result" onclick="navigate('/knowledge/ui/topic/' + encodeURIComponent('\${esc(q)}'))" style="border-bottom:1px solid rgba(255,255,255,0.1)">
948
+ <div class="entity">🔍 Search: \${esc(q)}</div>
949
+ <div class="snippet">Combined query — find facts across multiple entities</div>
950
+ </div>\`;
945
951
  if (!result.results || result.results.length === 0) {
946
- dropdown.innerHTML = \`
947
- <div class="search-result" onclick="navigate('/knowledge/ui/topic/' + encodeURIComponent('\${esc(q)}'))">
948
- <div class="entity">View as topic page</div>
949
- <div class="snippet">No matching entities — synthesize from search hits.</div>
950
- </div>\`;
952
+ dropdown.innerHTML = topicLink;
951
953
  } else {
952
- dropdown.innerHTML = result.results.map(r => \`
954
+ dropdown.innerHTML = topicLink + result.results.map(r => \`
953
955
  <div class="search-result" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(r.entity)}'))">
954
956
  <div class="entity">\${esc(r.entity)}</div>
955
957
  <div class="meta">\${esc(r.type)} · \${r.fact_count} fact\${r.fact_count === 1 ? "" : "s"}</div>
@@ -960,6 +962,12 @@ function setupSearch() {
960
962
  }, 220);
961
963
  input.addEventListener("input", handleQuery);
962
964
  input.addEventListener("focus", () => { if (input.value) handleQuery(); });
965
+ input.addEventListener("keydown", (e) => {
966
+ if (e.key === "Enter" && input.value.trim()) {
967
+ dropdown.classList.remove("open");
968
+ navigate("/knowledge/ui/topic/" + encodeURIComponent(input.value.trim()));
969
+ }
970
+ });
963
971
  document.addEventListener("click", (e) => {
964
972
  if (!e.target.closest(".search-wrap")) dropdown.classList.remove("open");
965
973
  });
@@ -1339,25 +1347,109 @@ function renderMissingConcept(entity, root) {
1339
1347
 
1340
1348
  // ── Topic page (simple, v1) ───────────────────────────────────────────────
1341
1349
  async function renderTopicPage(q) {
1342
- document.title = "Topic: " + q;
1350
+ document.title = "Topic: " + q + " · Sinain";
1343
1351
  const root = $("#root");
1344
- root.innerHTML = \`
1345
- <h1>Topic: \${esc(q)}</h1>
1346
- <div class="loading-block"><span class="spinner"></span> Searching…</div>\`;
1347
- const r = await api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=50");
1348
- if (!r.results || r.results.length === 0) {
1349
- root.innerHTML = \`<h1>Topic: \${esc(q)}</h1>
1352
+ root.innerHTML = \`<div class="loading-block"><span class="spinner"></span> Searching…</div>\`;
1353
+
1354
+ // Parallel: get combined facts + matching entities
1355
+ const [qr, sr] = await Promise.all([
1356
+ api("/knowledge/query?q=" + encodeURIComponent(q) + "&max=30"),
1357
+ api("/knowledge/search?q=" + encodeURIComponent(q) + "&limit=10"),
1358
+ ]);
1359
+ const factsText = qr.facts_text || "";
1360
+ const entities = sr.results || [];
1361
+
1362
+ if (!factsText && entities.length === 0) {
1363
+ root.innerHTML = \`
1364
+ <div class="page-header"><div class="title">Topic: \${esc(q)}</div></div>
1350
1365
  <div class="error-block">No matching facts.</div>\`;
1351
1366
  return;
1352
1367
  }
1368
+
1369
+ // Parse compact facts into structured items, group by entity
1370
+ const factItems = factsText ? factsText.split("; ").filter(Boolean) : [];
1371
+ const grouped = {};
1372
+ const ungrouped = [];
1373
+ for (const f of factItems) {
1374
+ const m = f.match(/^([^:]*?):\\s*(.+?)\\s*\\(([^)]+)\\)$/);
1375
+ if (m) {
1376
+ const ent = m[1].trim() || "general";
1377
+ (grouped[ent] = grouped[ent] || []).push({text: m[2], meta: m[3], raw: f});
1378
+ } else {
1379
+ ungrouped.push({text: f, meta: "", raw: f});
1380
+ }
1381
+ }
1382
+
1383
+ // Build summary from top entities
1384
+ const topEnts = Object.keys(grouped).slice(0, 5).join(", ");
1385
+ const summary = factItems.length > 0
1386
+ ? \`\${factItems.length} facts retrieved across \${Object.keys(grouped).length} entities\${topEnts ? ": " + topEnts : ""}\`
1387
+ : "No facts found for this query.";
1388
+
1353
1389
  root.innerHTML = \`
1354
- <h1>Topic: \${esc(q)}</h1>
1355
- <div class="summary">Top \${r.results.length} matches across the knowledge graph.</div>
1356
- \${r.results.map(rr => \`
1357
- <div class="bullet" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(rr.entity)}'))" style="cursor:pointer">
1358
- <span class="text"><strong>\${esc(rr.entity)}</strong> \${esc(rr.snippet || "")}</span>
1359
- <span class="conf">\${rr.fact_count} fact\${rr.fact_count === 1 ? "" : "s"}</span>
1360
- </div>\`).join("")}\`;
1390
+ <div class="page-header">
1391
+ <div class="title">Topic: \${esc(q)}</div>
1392
+ <div class="badges">
1393
+ <span class="badge">\${factItems.length} fact\${factItems.length === 1 ? "" : "s"}</span>
1394
+ <span class="badge">\${Object.keys(grouped).length} entit\${Object.keys(grouped).length === 1 ? "y" : "ies"}</span>
1395
+ </div>
1396
+ <div class="page-actions">
1397
+ <button id="topicCopyLink" class="icon" title="Copy topic URL">🔗</button>
1398
+ <button id="topicShare" class="icon" title="Share topic (auto-imports for recipient)">📤</button>
1399
+ </div>
1400
+ </div>
1401
+ <div class="summary">\${esc(summary)}</div>
1402
+ <div id="topicSections">
1403
+ \${Object.entries(grouped).map(([ent, facts], i) => \`
1404
+ <div class="section" id="sec-\${i}">
1405
+ <div class="section-heading" onclick="this.parentElement.classList.toggle('collapsed')">
1406
+ \${esc(ent)}
1407
+ <span style="opacity:0.5;font-size:0.85em;margin-left:8px">\${facts.length} fact\${facts.length === 1 ? "" : "s"}</span>
1408
+ </div>
1409
+ <ul class="bullets">\${facts.map(f => \`
1410
+ <li class="bullet">
1411
+ <span class="text">\${esc(f.text)}</span>
1412
+ <span class="conf">\${esc(f.meta)}</span>
1413
+ </li>\`).join("")}</ul>
1414
+ </div>\`).join("")}
1415
+ \${ungrouped.length > 0 ? \`
1416
+ <div class="section">
1417
+ <div class="section-heading">Other</div>
1418
+ <ul class="bullets">\${ungrouped.map(f => \`
1419
+ <li class="bullet"><span class="text">\${esc(f.text)}</span></li>\`).join("")}</ul>
1420
+ </div>\` : ""}
1421
+ </div>
1422
+ \${entities.length > 0 ? \`
1423
+ <div class="section" style="margin-top:16px">
1424
+ <div class="section-heading" onclick="this.parentElement.classList.toggle('collapsed')">
1425
+ Related Entities
1426
+ </div>
1427
+ <ul class="bullets">\${entities.map(rr => \`
1428
+ <li class="bullet" onclick="navigate('/knowledge/ui/entity/' + encodeURIComponent('\${esc(rr.entity)}'))" style="cursor:pointer">
1429
+ <span class="text"><strong>\${esc(rr.entity)}</strong> — \${esc(rr.snippet || "")}</span>
1430
+ <span class="conf">\${rr.fact_count} fact\${rr.fact_count === 1 ? "" : "s"}</span>
1431
+ </li>\`).join("")}</ul>
1432
+ </div>\` : ""}\`;
1433
+
1434
+ // Wire actions
1435
+ $("#topicCopyLink").onclick = () => {
1436
+ const url = location.origin + "/knowledge/ui/topic/" + encodeURIComponent(q);
1437
+ navigator.clipboard.writeText(url);
1438
+ showToast("✓ Link copied");
1439
+ };
1440
+ $("#topicShare").onclick = async () => {
1441
+ // Share all entities mentioned in the query
1442
+ const ents = (qr.entities || q.split(/[\\s,+]+/)).filter(Boolean);
1443
+ if (ents.length === 0) { showToast("No entities to share"); return; }
1444
+ showToast('<span class="spinner"></span> Preparing share…', 30_000);
1445
+ try {
1446
+ for (const ent of ents.slice(0, 3)) {
1447
+ await ShareManager.createShare(ent);
1448
+ }
1449
+ } catch (e) {
1450
+ showToast("Share failed: " + (e && e.message ? e.message : String(e)));
1451
+ }
1452
+ };
1361
1453
  }
1362
1454
 
1363
1455
  // ── Dropzone wiring (shared) ──────────────────────────────────────────────
@@ -1816,6 +1908,30 @@ export function createAppServer(deps: ServerDeps) {
1816
1908
  return;
1817
1909
  }
1818
1910
 
1911
+ // ── /knowledge/query ── (combined entity recall — used by topic page) ──
1912
+ if (req.method === "GET" && url.pathname === "/knowledge/query") {
1913
+ const q = url.searchParams.get("q") || "";
1914
+ const maxFacts = Math.min(parseInt(url.searchParams.get("max") || "20"), 50);
1915
+ if (!q.trim()) {
1916
+ res.writeHead(400);
1917
+ res.end(JSON.stringify({ ok: false, error: "q parameter required" }));
1918
+ return;
1919
+ }
1920
+ // Split query into entity keywords for queryKnowledgeFacts
1921
+ const entities = q.trim().split(/[\s,+]+/).filter(Boolean);
1922
+ if (deps.queryKnowledgeFacts) {
1923
+ try {
1924
+ const factsText = await deps.queryKnowledgeFacts(entities, maxFacts);
1925
+ res.end(JSON.stringify({ ok: true, query: q, facts_text: factsText, entities }));
1926
+ } catch (err) {
1927
+ res.end(JSON.stringify({ ok: false, error: String(err) }));
1928
+ }
1929
+ } else {
1930
+ res.end(JSON.stringify({ ok: true, query: q, facts_text: "", entities }));
1931
+ }
1932
+ return;
1933
+ }
1934
+
1819
1935
  // ── /knowledge/search ── (entity-prioritized) ──
1820
1936
  if (req.method === "GET" && url.pathname === "/knowledge/search") {
1821
1937
  const q = url.searchParams.get("q") || "";
@@ -0,0 +1,69 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { AgentEntry, ContextWindow } from "../types.js";
3
+
4
+ /**
5
+ * Domain-generic events emitted by sinain-core. Optional agent modules
6
+ * (currently just openclaw, future bridges to other gateways) subscribe
7
+ * to these instead of being wired directly into Escalator. The contract
8
+ * here is the only stable surface modules can rely on.
9
+ */
10
+ export interface CoreEvents {
11
+ /** An escalation has been routed to an agent. Modules whose roster
12
+ * matches `agent` should run their delivery (WS, HTTP, etc). */
13
+ "escalation:dispatched": {
14
+ entry: AgentEntry;
15
+ contextWindow: ContextWindow;
16
+ message: string;
17
+ agent: string;
18
+ };
19
+
20
+ /** User typed a direct message in the overlay command input. */
21
+ "user:direct": { text: string; ts: number };
22
+
23
+ /** Periodic feedback summary ready for forwarding to long-running agents. */
24
+ "feedback:periodic": { summary: string; lastNTicks: number };
25
+
26
+ /** Agent loop completed an analysis tick. Used by SITUATION.md writers
27
+ * and other context consumers. */
28
+ "tick:complete": { entry: AgentEntry };
29
+ }
30
+
31
+ export interface CoreEventBus {
32
+ on<K extends keyof CoreEvents>(
33
+ event: K,
34
+ listener: (payload: CoreEvents[K]) => void,
35
+ ): () => void;
36
+ emit<K extends keyof CoreEvents>(event: K, payload: CoreEvents[K]): void;
37
+ removeAllListeners(): void;
38
+ }
39
+
40
+ class TypedEventBus implements CoreEventBus {
41
+ private readonly emitter = new EventEmitter();
42
+
43
+ constructor() {
44
+ // Default Node limit (10) is too low for a long-running bus with many
45
+ // subscribers across the lifetime of the process. Disable the warning;
46
+ // we control the listener set internally.
47
+ this.emitter.setMaxListeners(0);
48
+ }
49
+
50
+ on<K extends keyof CoreEvents>(
51
+ event: K,
52
+ listener: (payload: CoreEvents[K]) => void,
53
+ ): () => void {
54
+ this.emitter.on(event, listener as (...args: unknown[]) => void);
55
+ return () => this.emitter.off(event, listener as (...args: unknown[]) => void);
56
+ }
57
+
58
+ emit<K extends keyof CoreEvents>(event: K, payload: CoreEvents[K]): void {
59
+ this.emitter.emit(event, payload);
60
+ }
61
+
62
+ removeAllListeners(): void {
63
+ this.emitter.removeAllListeners();
64
+ }
65
+ }
66
+
67
+ export function createCoreEventBus(): CoreEventBus {
68
+ return new TypedEventBus();
69
+ }