@geravant/sinain 1.13.0 → 1.15.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.
Files changed (78) hide show
  1. package/.env.example +33 -27
  2. package/cli.js +30 -14
  3. package/config-shared.js +173 -30
  4. package/launcher.js +38 -21
  5. package/onboard.js +36 -20
  6. package/package.json +4 -1
  7. package/sinain-agent/run.sh +600 -127
  8. package/sinain-core/src/agents-loader.ts +254 -0
  9. package/sinain-core/src/buffers/feed-buffer.ts +6 -4
  10. package/sinain-core/src/config.ts +77 -15
  11. package/sinain-core/src/escalation/escalator.ts +178 -18
  12. package/sinain-core/src/index.ts +218 -31
  13. package/sinain-core/src/learning/local-curation.ts +81 -27
  14. package/sinain-core/src/overlay/commands.ts +25 -0
  15. package/sinain-core/src/overlay/ws-handler.ts +3 -0
  16. package/sinain-core/src/server.ts +101 -10
  17. package/sinain-core/src/types.ts +29 -3
  18. package/sinain-memory/graph_query.py +12 -3
  19. package/sinain-memory/knowledge_integrator.py +194 -10
  20. package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
  21. package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
  22. package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
  23. package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
  24. package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
  25. package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
  26. package/sinain-memory/eval/__init__.py +0 -0
  27. package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/sinain-memory/eval/assertions.py +0 -267
  29. package/sinain-memory/eval/benchmarks/__init__.py +0 -0
  30. package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
  31. package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
  32. package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
  33. package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
  34. package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
  35. package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
  36. package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
  37. package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
  38. package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
  39. package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
  40. package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
  41. package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
  42. package/sinain-memory/eval/benchmarks/config.py +0 -23
  43. package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
  44. package/sinain-memory/eval/benchmarks/ingest.py +0 -152
  45. package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
  46. package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
  47. package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
  48. package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
  49. package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
  50. package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
  51. package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
  52. package/sinain-memory/eval/benchmarks/query.py +0 -193
  53. package/sinain-memory/eval/benchmarks/report.py +0 -87
  54. package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
  55. package/sinain-memory/eval/benchmarks/runner.py +0 -283
  56. package/sinain-memory/eval/judges/__init__.py +0 -0
  57. package/sinain-memory/eval/judges/base_judge.py +0 -61
  58. package/sinain-memory/eval/judges/curation_judge.py +0 -46
  59. package/sinain-memory/eval/judges/insight_judge.py +0 -48
  60. package/sinain-memory/eval/judges/mining_judge.py +0 -42
  61. package/sinain-memory/eval/judges/signal_judge.py +0 -45
  62. package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
  63. package/sinain-memory/eval/retrieval_evaluator.py +0 -186
  64. package/sinain-memory/eval/schemas.py +0 -247
  65. package/sinain-memory/tests/__init__.py +0 -0
  66. package/sinain-memory/tests/conftest.py +0 -189
  67. package/sinain-memory/tests/test_curator_helpers.py +0 -94
  68. package/sinain-memory/tests/test_embedder.py +0 -210
  69. package/sinain-memory/tests/test_extract_json.py +0 -124
  70. package/sinain-memory/tests/test_feedback_computation.py +0 -121
  71. package/sinain-memory/tests/test_miner_helpers.py +0 -71
  72. package/sinain-memory/tests/test_module_management.py +0 -458
  73. package/sinain-memory/tests/test_parsers.py +0 -96
  74. package/sinain-memory/tests/test_tick_evaluator.py +0 -430
  75. package/sinain-memory/tests/test_triple_extractor.py +0 -255
  76. package/sinain-memory/tests/test_triple_ingest.py +0 -191
  77. package/sinain-memory/tests/test_triple_migrate.py +0 -138
  78. package/sinain-memory/tests/test_triplestore.py +0 -248
@@ -33,6 +33,23 @@ export interface EscalatorDeps {
33
33
  feedbackStore?: FeedbackStore;
34
34
  signalCollector?: SignalCollector;
35
35
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
36
+ /** Returns the currently-selected spawn-lane agent from the bare-agent
37
+ * roster ("" = Off). When a local agent is selected, dispatchSpawnTask
38
+ * prefers the HTTP bare-agent path over the OpenClaw gateway WS path,
39
+ * so the overlay's agent-selector choice is respected even when the
40
+ * gateway is connected. */
41
+ getSpawnAgent?: () => string;
42
+ /** Returns the currently-selected escalation-lane agent. Gateway-typed
43
+ * profiles (any agent whose `type` is "openclaw" — see isGatewayAgent)
44
+ * route via WS; any other non-empty value routes to the local bare
45
+ * agent via HTTP httpPending. */
46
+ getEscalationAgent?: () => string;
47
+ /** Returns true if the named profile is a gateway-style profile
48
+ * (i.e. dispatched via WS RPC, not invoked as a local CLI). Lookup is
49
+ * by `agentsCfg.profiles[name].type === "openclaw"`. Custom profiles
50
+ * like "nemoclaw" or "nanoclaw-prod" with that type get WS dispatch
51
+ * automatically — the routing key is type, not name. */
52
+ isGatewayAgent?: (name: string) => boolean;
36
53
  }
37
54
 
38
55
  /**
@@ -52,6 +69,14 @@ export class Escalator {
52
69
  private slot: EscalationSlot;
53
70
  private httpPending: HttpPendingEscalation | null = null;
54
71
 
72
+ // Grace window for stale escalation IDs — when analyzer rotates the pending
73
+ // slot mid-response (agent takes 10-30s on MCP flow while ticks fire every
74
+ // 3-6s), the agent's respondHttp(oldId) would fail. Keep last 5 IDs for ~60s
75
+ // so those responses still land on HUD instead of being silently dropped.
76
+ private recentHttpIds: Array<{ id: string; ts: number }> = [];
77
+ private static readonly STALE_ID_GRACE_MS = 60_000;
78
+ private static readonly STALE_ID_BUFFER_SIZE = 5;
79
+
55
80
  private lastEscalationTs = 0;
56
81
  private lastEscalatedDigest = "";
57
82
 
@@ -139,9 +164,17 @@ export class Escalator {
139
164
  log(TAG, `user command set: "${preview}"`);
140
165
  }
141
166
 
142
- /** Start the WS connection to OpenClaw (skipped when transport=http). */
167
+ /** Start the WS connection to OpenClaw.
168
+ *
169
+ * Connects whenever the gateway URL is configured AND escalation isn't
170
+ * fully off. WS is the transport for the openclaw lane — the user selects
171
+ * it via the overlay's agent picker, and dispatch routes accordingly.
172
+ * Removing the openclaw profile from agents.json (and unsetting the env
173
+ * vars) leaves gatewayWsUrl empty → no connect attempt.
174
+ */
143
175
  start(): void {
144
- if (this.deps.escalationConfig.mode !== "off" && this.deps.escalationConfig.transport !== "http") {
176
+ const wsConfigured = !!this.deps.openclawConfig.gatewayWsUrl;
177
+ if (this.deps.escalationConfig.mode !== "off" && wsConfigured) {
145
178
  this.wsClient.connect();
146
179
  const tokenHash = this.deps.openclawConfig.gatewayToken
147
180
  ? createHash("sha256").update(this.deps.openclawConfig.gatewayToken).digest("hex").slice(0, 12)
@@ -185,11 +218,16 @@ export class Escalator {
185
218
  this.pendingUserCommand = null;
186
219
  }
187
220
 
188
- // Skip WS escalations when circuit is open (HTTP transport bypasses this)
189
- const transport = this.deps.escalationConfig.transport;
190
- if (this.wsClient.isCircuitOpen && transport !== "http") {
191
- log(TAG, `tick #${entry.id}: skipped circuit breaker open`);
192
- return;
221
+ // Early skip when circuit is open AND the user has selected openclaw —
222
+ // saves the cost of building the escalation message just to drop it.
223
+ // Local-agent lanes (claude, openclaude, etc.) bypass this since they
224
+ // route via HTTP and don't depend on WS.
225
+ if (this.wsClient.isCircuitOpen) {
226
+ const escalationAgent = this.deps.getEscalationAgent?.() || "";
227
+ if (this.deps.isGatewayAgent?.(escalationAgent)) {
228
+ log(TAG, `tick #${entry.id}: skipped — circuit breaker open and gateway agent "${escalationAgent}" selected`);
229
+ return;
230
+ }
193
231
  }
194
232
 
195
233
  // If user command is pending, force escalation (bypass score + cooldown)
@@ -275,9 +313,48 @@ export class Escalator {
275
313
  ts: entry.ts,
276
314
  };
277
315
 
278
- const useHttp = transport === "http" || (transport === "auto" && !this.wsClient.isConnected);
316
+ // Per-lane dispatch: agent identity *is* the transport.
317
+ // - profile.type === "openclaw" (gateway-style) → WS dispatch
318
+ // - any other non-empty agent (local CLI: claude, openclaude, ...) → HTTP
319
+ // - empty (Off) → escalator.setMode("off") should have stopped us
320
+ // upstream; defensive bailout.
321
+ //
322
+ // Routing keys off the profile's `type` field, not its name, so custom
323
+ // gateway profiles like "nemoclaw" or "nanoclaw-prod" route via WS
324
+ // automatically as long as they declare `type: "openclaw"` in agents.json.
325
+ //
326
+ // openclaw + WS-disconnected drops with a toast (no HTTP fallback),
327
+ // because the bare agent can't run a gateway profile as a local CLI —
328
+ // the fallback caused infinite skip loops historically.
329
+ const escalationAgent = this.deps.getEscalationAgent?.() || "";
330
+ const isGateway = this.deps.isGatewayAgent?.(escalationAgent) ?? false;
331
+ let useHttp: boolean;
332
+ if (isGateway) {
333
+ if (!this.wsClient.isConnected) {
334
+ log(TAG, `escalation dropped: gateway agent "${escalationAgent}" selected but WS disconnected`);
335
+ this.deps.wsHandler.broadcast(
336
+ `⚠ Gateway disconnected — escalation dropped. Pick a local agent or check the ${escalationAgent} gateway.`,
337
+ "high",
338
+ );
339
+ return;
340
+ }
341
+ useHttp = false;
342
+ } else if (escalationAgent) {
343
+ useHttp = true;
344
+ } else {
345
+ log(TAG, `escalation dropped: lane is Off (escalationAgent="")`);
346
+ return;
347
+ }
279
348
 
280
349
  if (useHttp) {
350
+ // Remember the outgoing ID before overwriting so late-arriving responses
351
+ // still find a valid match in respondHttp's grace window.
352
+ if (this.httpPending) {
353
+ this.recentHttpIds.push({ id: this.httpPending.id, ts: this.httpPending.ts });
354
+ if (this.recentHttpIds.length > Escalator.STALE_ID_BUFFER_SIZE) {
355
+ this.recentHttpIds.shift();
356
+ }
357
+ }
281
358
  // Store in HTTP pending slot (newest wins, like EscalationSlot)
282
359
  this.httpPending = {
283
360
  id: slotId,
@@ -287,13 +364,50 @@ export class Escalator {
287
364
  ts: entry.ts,
288
365
  feedbackCtx: slotEntry.feedbackCtx,
289
366
  };
290
- log(TAG, `tick #${entry.id} → httpPending id=${slotId} (transport=${transport})`);
367
+ log(TAG, `tick #${entry.id} → httpPending id=${slotId} (lane=${escalationAgent || "<default>"})`);
291
368
  } else {
292
369
  log(TAG, `tick #${entry.id} → slot.insert id=${slotId} depth=${this.slot.depth}`);
293
370
  this.slot.insert(slotEntry);
294
371
  }
295
372
  }
296
373
 
374
+ /** Redispatch a stale httpPending escalation through the WS slot.
375
+ *
376
+ * Called by index.ts when the escalation lane flips to a gateway-typed
377
+ * agent (e.g., openclaude → openclaw): an escalation queued for HTTP
378
+ * before the switch is now mis-routed. Rather than letting the bare
379
+ * agent skip it (which posts a confusing "[skipped: gateway-routed]"
380
+ * to the user's HUD), we move it into the WS slot so the gateway
381
+ * actually handles the user's pending question.
382
+ *
383
+ * If WS isn't connected, silently clear httpPending — the agent loop
384
+ * will produce a new escalation through the proper drop-with-toast
385
+ * path on the next tick. Better than the user seeing the skip message
386
+ * AND the gateway-disconnect toast for the same logical event.
387
+ *
388
+ * Returns true if a redispatch (or clear) actually happened, so the
389
+ * caller can log meaningfully.
390
+ */
391
+ redispatchHttpPendingToWs(): boolean {
392
+ if (!this.httpPending) return false;
393
+ const stale = this.httpPending;
394
+ this.httpPending = null;
395
+ if (!this.wsClient.isConnected) {
396
+ log(TAG, `redispatch skipped: WS not connected — cleared stale httpPending id=${stale.id}`);
397
+ return true;
398
+ }
399
+ const slotEntry: SlotEntry = {
400
+ id: stale.id,
401
+ message: stale.message,
402
+ sessionKey: this.deps.openclawConfig.sessionKey,
403
+ feedbackCtx: stale.feedbackCtx,
404
+ ts: stale.ts,
405
+ };
406
+ log(TAG, `redispatching stale httpPending id=${stale.id} → WS slot (lane switched to gateway)`);
407
+ this.slot.insert(slotEntry);
408
+ return true;
409
+ }
410
+
297
411
  /** Push fresh SITUATION.md content to the gateway server (fire-and-forget). */
298
412
  pushSituationMd(content: string): void {
299
413
  if (!this.wsClient.isConnected) return;
@@ -381,11 +495,29 @@ ${recentLines.join("\n")}`;
381
495
 
382
496
  /** Respond to an HTTP pending escalation. */
383
497
  respondHttp(id: string, response: string): { ok: boolean; error?: string } {
384
- if (!this.httpPending) {
385
- return { ok: false, error: "no pending escalation" };
386
- }
387
- if (this.httpPending.id !== id) {
388
- return { ok: false, error: `id mismatch: expected ${this.httpPending.id}` };
498
+ // Grace path: the agent's response arrived for a stale ID because the
499
+ // analyzer rotated the pending slot mid-flight. Still push to HUD — the
500
+ // response was written against context that was fresh seconds ago and is
501
+ // almost certainly still relevant — but don't clear the current pending,
502
+ // so the agent can still address the newer escalation on its next poll.
503
+ if (!this.httpPending || this.httpPending.id !== id) {
504
+ const recent = this.recentHttpIds.find((e) => e.id === id);
505
+ if (recent && Date.now() - recent.ts < Escalator.STALE_ID_GRACE_MS) {
506
+ // Grace path: response was generated against a context that's now
507
+ // stale (analyzer rotated the slot mid-flight) but still recent
508
+ // enough that the answer is almost certainly still relevant.
509
+ // Push to HUD and return a clean ok=true — don't surface the
510
+ // grace marker on the wire, because generic LLM clients read
511
+ // any non-empty `error` field as a failure signal and write
512
+ // apologetic meta-messages to the user. The breadcrumb stays
513
+ // in this log for debug.
514
+ log(TAG, `respondHttp grace: id=${id} is stale (rotated ${((Date.now() - recent.ts) / 1000).toFixed(1)}s ago) — pushing to HUD anyway`);
515
+ this.pushResponse(response, this.lastEscalationContext);
516
+ return { ok: true };
517
+ }
518
+ return this.httpPending
519
+ ? { ok: false, error: `id mismatch: expected ${this.httpPending.id}` }
520
+ : { ok: false, error: "no pending escalation" };
389
521
  }
390
522
 
391
523
  this.pushResponse(response, this.lastEscalationContext);
@@ -446,7 +578,6 @@ ${recentLines.join("\n")}`;
446
578
  getStats(): Record<string, unknown> {
447
579
  return {
448
580
  mode: this.deps.escalationConfig.mode,
449
- transport: this.deps.escalationConfig.transport,
450
581
  gatewayConnected: this.wsClient.isConnected,
451
582
  circuitOpen: this.wsClient.isCircuitOpen,
452
583
  slotDepth: this.slot.depth,
@@ -501,13 +632,42 @@ ${recentLines.join("\n")}`;
501
632
  // ★ Broadcast "spawned" BEFORE the RPC — TSK tab shows ··· immediately
502
633
  this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
503
634
 
504
- if (!this.wsClient.isConnected) {
505
- // No OpenClaw gateway queue for bare agent HTTP polling
635
+ // Route explicitly by the overlay's spawn-agent selection:
636
+ // "openclaw" (or "" with WS connected) send to remote gateway via WS RPC
637
+ // any other non-empty value → queue for local bare agent HTTP poll
638
+ // "" with WS disconnected → queue for HTTP fallback (same)
639
+ // This makes the overlay's choice authoritative. Before openclaw was a
640
+ // roster option, the old heuristic "if WS connected, use gateway" hijacked
641
+ // every spawn regardless of user intent, which surfaced as 401/credential
642
+ // errors from the gateway's stale OpenRouter key.
643
+ // Per-lane dispatch (mirror of escalation routing above):
644
+ // - profile.type === "openclaw" → WS to gateway (drop with toast if
645
+ // WS down — bare agent can't run gateway profiles as local CLIs)
646
+ // - any other non-empty agent → HTTP queue for bare agent polling
647
+ // - empty (Off) → drop; the spawn poll skip in run.sh should already
648
+ // prevent us from getting here.
649
+ const spawnAgent = this.deps.getSpawnAgent?.() || "";
650
+ const spawnIsGateway = this.deps.isGatewayAgent?.(spawnAgent) ?? false;
651
+ if (spawnIsGateway) {
652
+ if (!this.wsClient.isConnected) {
653
+ log(TAG, `spawn-task ${taskId}: dropped — gateway agent "${spawnAgent}" selected but WS disconnected`);
654
+ this.deps.wsHandler.broadcast(
655
+ `⚠ Gateway disconnected — spawn task dropped. Pick a local agent or check the ${spawnAgent} gateway.`,
656
+ "high",
657
+ );
658
+ return;
659
+ }
660
+ // Fall through to gateway dispatch below.
661
+ } else if (spawnAgent) {
662
+ // Local bare-agent path: queue for polling.
506
663
  this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
507
664
  const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
508
665
  this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
509
666
  this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
510
- log(TAG, `spawn-task ${taskId}: WS disconnected — queued for bare agent polling`);
667
+ log(TAG, `spawn-task ${taskId}: queued for bare agent (lane=${spawnAgent})`);
668
+ return;
669
+ } else {
670
+ log(TAG, `spawn-task ${taskId}: dropped — lane is Off (spawnAgent="")`);
511
671
  return;
512
672
  }
513
673
 
@@ -1,5 +1,6 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { loadConfig } from "./config.js";
3
+ import { loadAgentsConfig, isGatewayProfile, gatewayProfileNames } from "./agents-loader.js";
3
4
  import { FeedBuffer } from "./buffers/feed-buffer.js";
4
5
  import { SenseBuffer } from "./buffers/sense-buffer.js";
5
6
  import { WsHandler } from "./overlay/ws-handler.js";
@@ -67,35 +68,66 @@ async function queryKnowledgeFactsMulti(entities: string[], maxFacts: number): P
67
68
  ];
68
69
  const scriptPath = scriptCandidates.find(p => existsSync(p)) || scriptCandidates[0];
69
70
 
70
- const results: string[] = [];
71
+ // Step 1: Get candidates from Python (RRF-ranked, no embedding — avoids deadlock)
72
+ // Request 2x candidates in JSON for re-ranking in Node.js
73
+ const candidateFacts: Array<Record<string, string>> = [];
71
74
  for (const dbPath of dbPaths) {
72
75
  if (!existsSync(dbPath)) continue;
73
76
  try {
74
- const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "compact"];
77
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts * 2), "--format", "json"];
75
78
  if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
76
79
  const out = execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" }).trim();
77
- if (out) results.push(out);
80
+ if (out) {
81
+ const parsed = JSON.parse(out);
82
+ const facts = parsed.facts || parsed;
83
+ if (Array.isArray(facts)) candidateFacts.push(...facts);
84
+ }
78
85
  } catch { /* skip failed db */ }
79
86
  }
80
87
 
81
- if (results.length === 0) return "";
82
- if (results.length === 1) return results[0];
88
+ if (candidateFacts.length === 0) return "";
83
89
 
84
- // Merge and deduplicate lines from both sources
85
- const seen = new Set<string>();
86
- const merged: string[] = [];
87
- for (const block of results) {
88
- for (const line of block.split("\n")) {
89
- const key = line.replace(/\(confidence:.*$/, "").trim();
90
- if (key && !seen.has(key)) {
91
- seen.add(key);
92
- merged.push(line);
93
- }
90
+ // Step 2: Re-rank by embedding similarity in-process (no deadlock — model is in this process)
91
+ const queryText = entities.join(" ");
92
+ try {
93
+ if (embeddingService?.ready) {
94
+ const allTexts = [queryText, ...candidateFacts.map(f => f.value || "")];
95
+ const embeddings = await embeddingService.embed(allTexts);
96
+ const queryEmb = embeddings[0];
97
+ const scored = candidateFacts.map((f, i) => ({
98
+ fact: f,
99
+ sim: EmbeddingService.cosine(queryEmb, embeddings[i + 1]),
100
+ }));
101
+ scored.sort((a, b) => b.sim - a.sim);
102
+ candidateFacts.length = 0;
103
+ candidateFacts.push(...scored.slice(0, maxFacts).map(s => s.fact));
94
104
  }
105
+ } catch { /* embedding unavailable — use RRF order */ }
106
+
107
+ // Step 3: Format as compact text
108
+ const seen = new Set<string>();
109
+ const lines: string[] = [];
110
+ let total = 0;
111
+ const maxChars = 1200;
112
+ for (const f of candidateFacts.slice(0, maxFacts)) {
113
+ const eid = ((f as any).entity_id || (f as any).entityId || "").split(":").pop()?.slice(0, 20) || "?";
114
+ const value = (f as any).value || "";
115
+ const conf = (f as any).confidence || "?";
116
+ const count = (f as any).reinforce_count || "1";
117
+ const line = `${eid}: ${value} (${conf},${count}x)`;
118
+ const key = value.slice(0, 60);
119
+ if (seen.has(key)) continue;
120
+ seen.add(key);
121
+ if (total + line.length + 2 > maxChars) break;
122
+ lines.push(line);
123
+ total += line.length + 2;
95
124
  }
96
- return merged.slice(0, maxFacts).join("\n");
125
+ return lines.join("; ");
97
126
  }
98
127
 
128
+ // Reference to embedding service — set during init
129
+ let embeddingService: import("./embedding/service.js").EmbeddingService | null = null;
130
+
99
131
  /** List all entities from both local and workspace knowledge graphs. */
100
132
  async function listKnowledgeEntitiesMulti(max: number): Promise<string> {
101
133
  const { execFileSync } = await import("node:child_process");
@@ -340,11 +372,16 @@ async function main() {
340
372
  : null;
341
373
 
342
374
  // ── Initialize embedding service (non-blocking) ──
343
- const embeddingService = new EmbeddingService();
375
+ embeddingService = new EmbeddingService();
344
376
  embeddingService.loadAsync(); // ~9s background load, server starts immediately
345
377
 
346
378
  // ── Initialize local knowledge pipeline ──
347
- const localCuration = new LocalCurationService();
379
+ // Pass wsHandler.broadcast so the periodic curator (insight_synthesizer)
380
+ // can push suggestions/insights directly to HUD without going through the
381
+ // bare-agent heartbeat. Replaces the old sinain_post_feed MCP roundtrip.
382
+ const localCuration = new LocalCurationService(
383
+ (text) => wsHandler.broadcast(text),
384
+ );
348
385
  // Distill pending session in background — don't block server startup
349
386
  setImmediate(() => {
350
387
  localCuration.distillPendingSession();
@@ -359,6 +396,14 @@ async function main() {
359
396
  });
360
397
 
361
398
  // ── Initialize escalation ──
399
+ // getSpawnAgent reads bareAgentState (declared later in this function) via
400
+ // closure at call-time, NOT at construction time. Safe because
401
+ // dispatchSpawnTask only fires after an overlay message, which can't
402
+ // happen before server setup completes.
403
+ // Load agents.json once for lookup helpers passed to escalator. Same file
404
+ // config.ts reads at startup; re-loading here keeps the dispatch lookup
405
+ // contained to a closure (no need to expose agentsCfg through CoreConfig).
406
+ const escalatorAgentsCfg = loadAgentsConfig();
362
407
  const escalator = new Escalator({
363
408
  feedBuffer,
364
409
  wsHandler,
@@ -367,6 +412,12 @@ async function main() {
367
412
  profiler,
368
413
  feedbackStore: feedbackStore ?? undefined,
369
414
  queryKnowledgeFacts: queryKnowledgeFactsMulti,
415
+ getSpawnAgent: () => bareAgentState.spawnAgent,
416
+ getEscalationAgent: () => bareAgentState.escalationAgent,
417
+ // Type-based gateway lookup. Routing key is agents.json `profiles[name].type`,
418
+ // so any custom profile with `type: "openclaw"` (e.g. "nemoclaw",
419
+ // "nanoclaw-prod") gets WS dispatch automatically — no name-matching.
420
+ isGatewayAgent: (name: string) => isGatewayProfile(escalatorAgentsCfg, name),
370
421
  });
371
422
 
372
423
  // ── Initialize agent loop (event-driven) ──
@@ -548,6 +599,88 @@ async function main() {
548
599
  // ── Escalation pause/resume state ──
549
600
  let savedEscalationMode: typeof config.escalationConfig.mode | null = null;
550
601
 
602
+ /** Pause escalation (idempotent). Caches the pre-pause mode so resume
603
+ * can restore it. Re-entering while already paused is a no-op — crucially,
604
+ * does NOT overwrite savedEscalationMode with "off". */
605
+ function pauseEscalationInternal(): void {
606
+ const current = config.escalationConfig.mode;
607
+ if (current === "off") return;
608
+ savedEscalationMode = current;
609
+ escalator.setMode("off");
610
+ log(TAG, `escalation paused (was: ${savedEscalationMode})`);
611
+ }
612
+
613
+ /** Resume escalation (idempotent). Restores the saved mode or falls back
614
+ * to "rich" if no saved mode exists. Re-entering while already active
615
+ * is a no-op. */
616
+ function resumeEscalationInternal(): void {
617
+ const current = config.escalationConfig.mode;
618
+ if (current !== "off") return;
619
+ const mode = savedEscalationMode ?? "rich";
620
+ savedEscalationMode = null;
621
+ escalator.setMode(mode);
622
+ log(TAG, `escalation resumed (mode: ${mode})`);
623
+ }
624
+
625
+ // ── Bare-agent roster & per-lane current agent ──
626
+ // In-memory only (matches escalation-mode lifecycle). Populated when the
627
+ // bare agent POSTs /bareagent/register on startup; mutated by set_agent
628
+ // command from the overlay. Empty-string lane values = "Off" (disabled).
629
+ // Profile names from agents.json may be custom (e.g. "pclaude",
630
+ // "openclaude-spawn"), so the server validates by character class
631
+ // rather than a fixed whitelist. The bare agent owns the source of
632
+ // truth for which profiles actually exist on its host. The validator
633
+ // just rejects names that could break shell logging, paths, or be
634
+ // injection vectors.
635
+ const AGENT_NAME_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
636
+ // "openclaw" is reserved-injected below when gatewayWsUrl is set —
637
+ // it's not a local CLI, it's a routing choice that sends tasks to the
638
+ // remote OpenClaw gateway via WS RPC instead of the local bare agent.
639
+ const bareAgentState: {
640
+ available: string[];
641
+ escalationAgent: string;
642
+ spawnAgent: string;
643
+ } = { available: [], escalationAgent: "", spawnAgent: "" };
644
+
645
+ function registerBareAgent(availableList: string[], current: string): void {
646
+ const clean = availableList.filter((a) => typeof a === "string" && AGENT_NAME_RE.test(a));
647
+ // Inject every gateway-style profile (any agents.json profile with
648
+ // `type: "openclaw"`) into the roster — they have no local binary, so
649
+ // run.sh's PATH filter drops them, but sinain-core knows they exist
650
+ // and routes them via WS RPC.
651
+ //
652
+ // This generalizes the legacy "auto-inject the literal name 'openclaw'"
653
+ // behavior: now custom gateway profiles like "nemoclaw" or
654
+ // "nanoclaw-prod" appear in the overlay roster automatically as soon
655
+ // as you add them to agents.json. The single WS client uses the first
656
+ // gateway profile's connection params (config.ts findGatewayProfile);
657
+ // simultaneous multi-gateway is a follow-up.
658
+ if (config.openclawConfig.gatewayWsUrl) {
659
+ for (const gwName of gatewayProfileNames(escalatorAgentsCfg)) {
660
+ if (!clean.includes(gwName)) clean.push(gwName);
661
+ }
662
+ // Legacy fallback: if no gateway profiles are defined in agents.json
663
+ // but gatewayWsUrl is set via env, still inject the canonical name.
664
+ if (clean.filter((n) => isGatewayProfile(escalatorAgentsCfg, n)).length === 0
665
+ && !clean.includes("openclaw")) {
666
+ clean.push("openclaw");
667
+ }
668
+ }
669
+ bareAgentState.available = clean;
670
+ // If neither lane is set yet (fresh boot), adopt the bare agent's
671
+ // reported current. If state survives from a prior register call AND
672
+ // the agent still exists in the roster, keep it; otherwise fall back
673
+ // to the new current.
674
+ if (!bareAgentState.escalationAgent || !clean.includes(bareAgentState.escalationAgent)) {
675
+ bareAgentState.escalationAgent = clean.includes(current) ? current : (clean[0] ?? "");
676
+ }
677
+ if (!bareAgentState.spawnAgent || !clean.includes(bareAgentState.spawnAgent)) {
678
+ bareAgentState.spawnAgent = clean.includes(current) ? current : (clean[0] ?? "");
679
+ }
680
+ wsHandler.updateState({ agents: { ...bareAgentState } });
681
+ log(TAG, `bareagent register: available=[${clean.join(",")}] current=${current} → lanes esc=${bareAgentState.escalationAgent} spawn=${bareAgentState.spawnAgent}`);
682
+ }
683
+
551
684
  // ── Create HTTP + WS server ──
552
685
  const server = createAppServer({
553
686
  config,
@@ -661,6 +794,18 @@ async function main() {
661
794
  isEscalationPaused: () => savedEscalationMode !== null,
662
795
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
663
796
 
797
+ // Bare-agent roster & config (wired to server endpoints in step 2).
798
+ registerBareAgent,
799
+ getBareAgentConfig: () => ({
800
+ escalationAgent: bareAgentState.escalationAgent,
801
+ spawnAgent: bareAgentState.spawnAgent,
802
+ // Tells the bare agent whether core still has its roster. On core
803
+ // restart this flips to false until the next /bareagent/register POST
804
+ // — distinguishes "user picked Off/Off" (registered=true, lanes="")
805
+ // from "core forgot about us" (registered=false).
806
+ registered: bareAgentState.available.length > 0,
807
+ }),
808
+
664
809
  // Knowledge graph integration (checks both local and workspace DBs)
665
810
  getKnowledgeDocPath: () => {
666
811
  // Check local first, then workspace
@@ -683,8 +828,8 @@ async function main() {
683
828
  },
684
829
  getSpawnPending: () => escalator.getSpawnPending(),
685
830
  respondSpawn: (id: string, result: string) => escalator.respondSpawn(id, result),
686
- embedTexts: (texts: string[]) => embeddingService.embed(texts),
687
- isEmbeddingReady: () => embeddingService.ready,
831
+ embedTexts: (texts: string[]) => embeddingService!.embed(texts),
832
+ isEmbeddingReady: () => embeddingService?.ready ?? false,
688
833
  });
689
834
 
690
835
  // ── Wire overlay profiling ──
@@ -721,20 +866,62 @@ async function main() {
721
866
  return screenActive;
722
867
  },
723
868
  onToggleEscalation: () => {
724
- if (savedEscalationMode === null) {
725
- // Pause: save current mode, switch to off
726
- savedEscalationMode = config.escalationConfig.mode;
727
- escalator.setMode("off");
728
- log(TAG, `escalation paused (was: ${savedEscalationMode})`);
869
+ // Routes through the shared helpers so the set_agent("escalation","")
870
+ // path and the flash-icon-toggle path share a single source of truth
871
+ // for savedEscalationMode. Kept for WS backward-compat; new UI uses
872
+ // the agent selector.
873
+ if (config.escalationConfig.mode === "off") {
874
+ resumeEscalationInternal();
875
+ return true;
876
+ } else {
877
+ pauseEscalationInternal();
729
878
  return false;
879
+ }
880
+ },
881
+ onSetAgent: (lane: "escalation" | "spawn", agent: string): { ok: boolean; error?: string } => {
882
+ // Empty-string agent = Off (lane disabled). Non-empty agent must be
883
+ // in the current roster; stale overlay state can send something that
884
+ // isn't available — reject with a clear error.
885
+ if (agent !== "" && !bareAgentState.available.includes(agent)) {
886
+ return { ok: false, error: `Agent "${agent}" not available` };
887
+ }
888
+ if (lane === "escalation") {
889
+ const prevAgent = bareAgentState.escalationAgent;
890
+ bareAgentState.escalationAgent = agent;
891
+ if (agent === "") {
892
+ pauseEscalationInternal();
893
+ } else {
894
+ resumeEscalationInternal();
895
+ }
896
+ // If the user flipped to a gateway-typed agent (openclaw, nemoclaw,
897
+ // ...) and there's a stale httpPending escalation queued from BEFORE
898
+ // the switch, re-dispatch it through the WS path. Without this, the
899
+ // bare agent picks up the stale entry on its next poll and posts a
900
+ // "[skipped: gateway-routed]" message to the HUD — confusing for
901
+ // the user, who just told us "use the gateway".
902
+ const wasGateway = isGatewayProfile(escalatorAgentsCfg, prevAgent);
903
+ const isGateway = isGatewayProfile(escalatorAgentsCfg, agent);
904
+ if (!wasGateway && isGateway) {
905
+ const did = escalator.redispatchHttpPendingToWs();
906
+ if (did) log(TAG, `lane switch ${prevAgent || "<empty>"} → ${agent}: stale httpPending redispatched`);
907
+ }
730
908
  } else {
731
- // Resume: restore saved mode
732
- const mode = savedEscalationMode;
733
- savedEscalationMode = null;
734
- escalator.setMode(mode);
735
- log(TAG, `escalation resumed (mode: ${mode})`);
736
- return true;
909
+ bareAgentState.spawnAgent = agent;
910
+ // Spawn "off" just means run.sh won't poll /spawn/pending; no
911
+ // server-side state to flip. Queued spawn tasks TTL out naturally.
737
912
  }
913
+ // Rebroadcast state so the overlay sees the switch immediately, and
914
+ // the bare agent sees it on its next poll-response config piggyback.
915
+ // `escalation` field reflects the current escalator mode so the flash
916
+ // icon's color (active/paused) updates on Off-for-escalation.
917
+ wsHandler.updateState({
918
+ agents: { ...bareAgentState },
919
+ escalation: config.escalationConfig.mode === "off" ? "paused" : "active",
920
+ });
921
+ const displayAgent = agent || "off";
922
+ wsHandler.broadcast(`Agent switched: ${lane} → ${displayAgent}`, "normal", "stream");
923
+ log(TAG, `set_agent lane=${lane} agent=${displayAgent}`);
924
+ return { ok: true };
738
925
  },
739
926
  });
740
927