@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.
- package/.env.example +33 -27
- package/cli.js +30 -14
- package/config-shared.js +173 -30
- package/launcher.js +38 -21
- package/onboard.js +36 -20
- package/package.json +4 -1
- package/sinain-agent/run.sh +600 -127
- package/sinain-core/src/agents-loader.ts +254 -0
- package/sinain-core/src/buffers/feed-buffer.ts +6 -4
- package/sinain-core/src/config.ts +77 -15
- package/sinain-core/src/escalation/escalator.ts +178 -18
- package/sinain-core/src/index.ts +218 -31
- package/sinain-core/src/learning/local-curation.ts +81 -27
- package/sinain-core/src/overlay/commands.ts +25 -0
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-core/src/server.ts +101 -10
- package/sinain-core/src/types.ts +29 -3
- package/sinain-memory/graph_query.py +12 -3
- package/sinain-memory/knowledge_integrator.py +194 -10
- package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
- package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
- package/sinain-memory/eval/__init__.py +0 -0
- package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/assertions.py +0 -267
- package/sinain-memory/eval/benchmarks/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
- package/sinain-memory/eval/benchmarks/config.py +0 -23
- package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
- package/sinain-memory/eval/benchmarks/ingest.py +0 -152
- package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
- package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
- package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
- package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
- package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
- package/sinain-memory/eval/benchmarks/query.py +0 -193
- package/sinain-memory/eval/benchmarks/report.py +0 -87
- package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
- package/sinain-memory/eval/benchmarks/runner.py +0 -283
- package/sinain-memory/eval/judges/__init__.py +0 -0
- package/sinain-memory/eval/judges/base_judge.py +0 -61
- package/sinain-memory/eval/judges/curation_judge.py +0 -46
- package/sinain-memory/eval/judges/insight_judge.py +0 -48
- package/sinain-memory/eval/judges/mining_judge.py +0 -42
- package/sinain-memory/eval/judges/signal_judge.py +0 -45
- package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
- package/sinain-memory/eval/retrieval_evaluator.py +0 -186
- package/sinain-memory/eval/schemas.py +0 -247
- package/sinain-memory/tests/__init__.py +0 -0
- package/sinain-memory/tests/conftest.py +0 -189
- package/sinain-memory/tests/test_curator_helpers.py +0 -94
- package/sinain-memory/tests/test_embedder.py +0 -210
- package/sinain-memory/tests/test_extract_json.py +0 -124
- package/sinain-memory/tests/test_feedback_computation.py +0 -121
- package/sinain-memory/tests/test_miner_helpers.py +0 -71
- package/sinain-memory/tests/test_module_management.py +0 -458
- package/sinain-memory/tests/test_parsers.py +0 -96
- package/sinain-memory/tests/test_tick_evaluator.py +0 -430
- package/sinain-memory/tests/test_triple_extractor.py +0 -255
- package/sinain-memory/tests/test_triple_ingest.py +0 -191
- package/sinain-memory/tests/test_triple_migrate.py +0 -138
- 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
|
|
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
|
-
|
|
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
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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} (
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
505
|
-
|
|
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}:
|
|
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
|
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
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", "
|
|
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)
|
|
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 (
|
|
82
|
-
if (results.length === 1) return results[0];
|
|
88
|
+
if (candidateFacts.length === 0) return "";
|
|
83
89
|
|
|
84
|
-
//
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
375
|
+
embeddingService = new EmbeddingService();
|
|
344
376
|
embeddingService.loadAsync(); // ~9s background load, server starts immediately
|
|
345
377
|
|
|
346
378
|
// ── Initialize local knowledge pipeline ──
|
|
347
|
-
|
|
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
|
|
687
|
-
isEmbeddingReady: () => embeddingService
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
|