@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.
- package/launcher.js +20 -0
- package/mcp-register.js +572 -0
- package/onboard.js +32 -8
- package/package.json +2 -1
- package/sinain-core/src/agent/loop.ts +14 -3
- package/sinain-core/src/escalation/escalator.ts +44 -8
- package/sinain-core/src/index.ts +11 -0
- package/sinain-core/src/server.ts +136 -20
- package/sinain-core/src/util/event-bus.ts +69 -0
- package/sinain-memory/graph_query.py +188 -33
|
@@ -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
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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
|
-
|
|
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") {
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
1346
|
-
|
|
1347
|
-
const
|
|
1348
|
-
|
|
1349
|
-
|
|
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
|
-
<
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
<span class="
|
|
1359
|
-
|
|
1360
|
-
|
|
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
|
+
}
|