@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
@@ -60,11 +60,17 @@ export class LocalCurationService {
60
60
  private _incrementalRunning = false;
61
61
  private _rearmCb: (() => void) | null = null; // callback to re-arm feed buffer onFull
62
62
  private _senseBuffer: SenseBuffer | null = null;
63
+ // Optional HUD broadcast callback — when insight_synthesizer emits a
64
+ // suggestion/insight, we push it here so the overlay sees it. Replaces the
65
+ // bare-agent's prior `sinain_post_feed` roundtrip during heartbeat.
66
+ // Always posts at default priority; curation isn't the place for urgent pings.
67
+ private _broadcast: ((text: string) => void) | null = null;
63
68
 
64
- constructor() {
69
+ constructor(broadcast?: (text: string) => void) {
65
70
  this.memoryDir = resolveMemoryDir();
66
71
  this.scriptsDir = resolveScriptsDir();
67
72
  this.sessionStartTs = Date.now();
73
+ if (broadcast) this._broadcast = broadcast;
68
74
 
69
75
  // Ensure memory directory exists
70
76
  for (const subdir of ["", "playbook-logs", "playbook-archive", "eval-logs", "eval-reports"]) {
@@ -384,39 +390,87 @@ export class LocalCurationService {
384
390
  }
385
391
  }
386
392
 
387
- /** Run the periodic curation pipeline (feedback → mining → curation). */
393
+ /** Run the periodic curation pipeline.
394
+ * Replaces the bare-agent heartbeat entirely:
395
+ * signal_analyzer + insight_synthesizer (formerly heartbeat-only)
396
+ * + feedback_analyzer + memory_miner + playbook_curator (existing).
397
+ * insight_synthesizer output is parsed and, if it emits a suggestion or
398
+ * insight, broadcast to HUD via the constructor's broadcast callback.
399
+ */
388
400
  private runCurationPipeline(): void {
389
401
  log(TAG, "running periodic curation...");
390
402
 
391
- const scripts = [
392
- "feedback_analyzer.py",
393
- "memory_miner.py",
394
- "playbook_curator.py",
395
- ];
403
+ const sessionSummary = "Periodic curation cycle";
404
+ const currentTime = new Date().toISOString();
405
+
406
+ // Step 1: signal_analyzer — detects actionable signals from session memory
407
+ this.runScript("signal_analyzer.py", [
408
+ "--memory-dir", this.memoryDir,
409
+ "--session-summary", sessionSummary,
410
+ "--current-time", currentTime,
411
+ ]);
412
+
413
+ // Step 2: insight_synthesizer — produces {suggestion, insight}, broadcast to HUD
414
+ const insightOut = this.runScript("insight_synthesizer.py", [
415
+ "--memory-dir", this.memoryDir,
416
+ "--session-summary", sessionSummary,
417
+ "--current-time", currentTime,
418
+ ], /* captureStdout */ true);
419
+ if (insightOut) this.maybeBroadcastInsight(insightOut);
420
+
421
+ // Steps 3-5: existing periodic pipeline (feedback → mining → curation)
422
+ for (const script of ["feedback_analyzer.py", "memory_miner.py", "playbook_curator.py"]) {
423
+ this.runScript(script, ["--memory-dir", this.memoryDir]);
424
+ }
396
425
 
397
- for (const script of scripts) {
398
- const scriptPath = resolve(this.scriptsDir, script);
399
- if (!existsSync(scriptPath)) {
400
- warn(TAG, `${script} not found — skipping`);
401
- continue;
402
- }
426
+ log(TAG, "periodic curation complete");
427
+ }
403
428
 
404
- try {
405
- execFileSync("python3", [
406
- scriptPath,
407
- "--memory-dir", this.memoryDir,
408
- ], {
409
- timeout: 60_000,
410
- encoding: "utf-8",
411
- env: { ...process.env, PYTHONPATH: this.scriptsDir },
412
- });
413
- log(TAG, ` ✓ ${script}`);
414
- } catch (err: any) {
415
- warn(TAG, ` ✗ ${script}: ${err.message?.slice(0, 100)}`);
416
- }
429
+ /** Run a single curation script. Returns captured stdout when requested, else empty. */
430
+ private runScript(script: string, args: string[], captureStdout = false): string {
431
+ const scriptPath = resolve(this.scriptsDir, script);
432
+ if (!existsSync(scriptPath)) {
433
+ warn(TAG, `${script} not found — skipping`);
434
+ return "";
435
+ }
436
+ try {
437
+ const stdout = execFileSync("python3", [scriptPath, ...args], {
438
+ timeout: 60_000,
439
+ encoding: "utf-8",
440
+ env: { ...process.env, PYTHONPATH: this.scriptsDir },
441
+ });
442
+ log(TAG, ` ✓ ${script}`);
443
+ return captureStdout ? (stdout || "") : "";
444
+ } catch (err: any) {
445
+ warn(TAG, ` ✗ ${script}: ${err.message?.slice(0, 100)}`);
446
+ return "";
417
447
  }
448
+ }
418
449
 
419
- log(TAG, "periodic curation complete");
450
+ /** Parse insight_synthesizer stdout (expects JSON with {suggestion?, insight?})
451
+ * and broadcast non-empty fields to the HUD feed. Safe no-op if JSON parsing
452
+ * fails or if no broadcast callback was wired. */
453
+ private maybeBroadcastInsight(stdout: string): void {
454
+ if (!this._broadcast) return;
455
+ const lines = stdout.trim().split("\n").filter((l) => l.trim().length > 0);
456
+ for (let i = lines.length - 1; i >= 0; i--) {
457
+ const line = lines[i].trim();
458
+ if (!line.startsWith("{")) continue;
459
+ try {
460
+ const parsed = JSON.parse(line);
461
+ const suggestion = typeof parsed.suggestion === "string" ? parsed.suggestion.trim() : "";
462
+ const insight = typeof parsed.insight === "string" ? parsed.insight.trim() : "";
463
+ if (suggestion) {
464
+ log(TAG, ` posting suggestion to HUD (${suggestion.length} chars)`);
465
+ this._broadcast(suggestion);
466
+ }
467
+ if (insight && insight !== suggestion) {
468
+ log(TAG, ` posting insight to HUD (${insight.length} chars)`);
469
+ this._broadcast(insight);
470
+ }
471
+ return;
472
+ } catch { /* try previous line */ }
473
+ }
420
474
  }
421
475
 
422
476
  /** Write distilled session notes to daily file. */
@@ -23,6 +23,9 @@ export interface CommandDeps {
23
23
  onToggleScreen: () => boolean;
24
24
  /** Toggle escalation pause/resume — returns true if now active */
25
25
  onToggleEscalation: () => boolean;
26
+ /** Set the agent for a lane. agent="" means Off (lane disabled).
27
+ * Returns { ok: false, error } if agent isn't in the current roster. */
28
+ onSetAgent?: (lane: "escalation" | "spawn", agent: string) => { ok: boolean; error?: string };
26
29
  }
27
30
 
28
31
  /**
@@ -186,6 +189,28 @@ function handleCommand(msg: InboundMessage & { action: string }, deps: CommandDe
186
189
  }
187
190
  break;
188
191
  }
192
+ case "set_agent": {
193
+ const lane = (msg as any).lane as "escalation" | "spawn" | undefined;
194
+ const agent = (msg as any).agent;
195
+ if (lane !== "escalation" && lane !== "spawn") {
196
+ log(TAG, `set_agent: invalid lane "${lane}"`);
197
+ break;
198
+ }
199
+ if (typeof agent !== "string") {
200
+ log(TAG, `set_agent: missing or non-string agent field`);
201
+ break;
202
+ }
203
+ if (!deps.onSetAgent) {
204
+ log(TAG, `set_agent: no handler wired`);
205
+ break;
206
+ }
207
+ const result = deps.onSetAgent(lane, agent);
208
+ if (!result.ok) {
209
+ wsHandler.broadcast(`⚠ ${result.error ?? "set_agent failed"}`, "normal");
210
+ }
211
+ log(TAG, `set_agent lane=${lane} agent=${agent || "<off>"} (ok=${result.ok})`);
212
+ break;
213
+ }
189
214
  case "open_settings": {
190
215
  const envPath = loadedEnvPath || `${process.env.HOME || process.env.USERPROFILE}/.sinain/.env`;
191
216
  const cmd = process.platform === "win32" ? "notepad" : "open";
@@ -40,6 +40,7 @@ export class WsHandler {
40
40
  escalation: "active",
41
41
  connection: "disconnected",
42
42
  responseSize: "medium",
43
+ agents: { available: [], escalationAgent: "", spawnAgent: "" },
43
44
  };
44
45
  private replayBuffer: FeedMessage[] = [];
45
46
  private spawnTaskBuffer: Map<string, SpawnTaskMessage> = new Map();
@@ -77,6 +78,7 @@ export class WsHandler {
77
78
  escalation: this.state.escalation,
78
79
  connection: this.state.connection,
79
80
  responseSize: this.state.responseSize,
81
+ agents: this.state.agents,
80
82
  });
81
83
 
82
84
  // Replay recent feed messages for late-joining clients
@@ -161,6 +163,7 @@ export class WsHandler {
161
163
  escalation: this.state.escalation,
162
164
  connection: this.state.connection,
163
165
  responseSize: this.state.responseSize,
166
+ agents: this.state.agents,
164
167
  };
165
168
  if (loadedEnvPath) msg.envPath = loadedEnvPath;
166
169
  this.broadcastMessage(msg);
@@ -186,6 +186,15 @@ export interface ServerDeps {
186
186
  respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
187
187
  embedTexts?: (texts: string[]) => Promise<Float32Array[]>;
188
188
  isEmbeddingReady?: () => boolean;
189
+
190
+ /** Bare-agent announced its roster on startup. */
191
+ registerBareAgent?: (available: string[], current: string) => void;
192
+ /** Current per-lane agent choice; read by run.sh via the piggyback field
193
+ * on /escalation/pending and /spawn/pending responses, and by manual
194
+ * debug via GET /bareagent/config. `registered` distinguishes "user
195
+ * chose Off" (registered=true, lanes="") from "core forgot our
196
+ * registration" (registered=false) so run.sh heals only on the latter. */
197
+ getBareAgentConfig?: () => { escalationAgent: string; spawnAgent: string; registered: boolean };
189
198
  }
190
199
 
191
200
  function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
@@ -209,6 +218,17 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
209
218
  /** Pending spawn questions/permissions — resolve callbacks keyed by "ask:{taskId}" or "perm:{taskId}" */
210
219
  const pendingSpawnQuestions = new Map<string, (answer: string) => void>();
211
220
 
221
+ // YOLO mode: "allow all" for the current agent session. Keyed on the openclaude
222
+ // session_id from the PreToolUse hook input (stable across tool calls within
223
+ // one invoke_agent run, discarded when that run ends). User enters YOLO by
224
+ // clicking the YOLO button on any permission prompt. Session id is cleared
225
+ // implicitly when the bare agent restarts (new session_id on next run).
226
+ const yoloSessions = new Set<string>();
227
+ // Map permission-request id (perm-<ts>) -> session id it came from. Used so
228
+ // that /spawn/permission-reply can flag the right session as YOLO when the
229
+ // user picks the YOLO button. Cleaned on resolve/timeout.
230
+ const permissionToSession = new Map<string, string>();
231
+
212
232
  export function createAppServer(deps: ServerDeps) {
213
233
  const { config, feedBuffer, senseBuffer, wsHandler } = deps;
214
234
  let senseInBytes = 0;
@@ -596,14 +616,17 @@ export function createAppServer(deps: ServerDeps) {
596
616
  }
597
617
 
598
618
  // ── /escalation/pending ──
619
+ // Response piggybacks the per-lane agent config so run.sh learns
620
+ // about overlay-side agent switches without a separate poll.
599
621
  if (req.method === "GET" && url.pathname === "/escalation/pending") {
622
+ const config = deps.getBareAgentConfig?.() ?? { escalationAgent: "", spawnAgent: "", registered: false };
600
623
  const paused = deps.isEscalationPaused?.() ?? false;
601
624
  if (paused) {
602
- res.end(JSON.stringify({ ok: true, escalation: null, paused: true }));
625
+ res.end(JSON.stringify({ ok: true, escalation: null, paused: true, config }));
603
626
  return;
604
627
  }
605
628
  const pending = deps.getEscalationPending?.();
606
- res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
629
+ res.end(JSON.stringify({ ok: true, escalation: pending ?? null, config }));
607
630
  return;
608
631
  }
609
632
 
@@ -640,9 +663,11 @@ export function createAppServer(deps: ServerDeps) {
640
663
  }
641
664
 
642
665
  // ── /spawn/pending (bare agent polls for queued tasks) ──
666
+ // Response piggybacks the per-lane agent config (see /escalation/pending).
643
667
  if (req.method === "GET" && url.pathname === "/spawn/pending") {
668
+ const config = deps.getBareAgentConfig?.() ?? { escalationAgent: "", spawnAgent: "", registered: false };
644
669
  const task = deps.getSpawnPending?.() ?? null;
645
- res.end(JSON.stringify({ ok: true, task }));
670
+ res.end(JSON.stringify({ ok: true, task, config }));
646
671
  return;
647
672
  }
648
673
 
@@ -715,17 +740,43 @@ export function createAppServer(deps: ServerDeps) {
715
740
  const hookInput = JSON.parse(body);
716
741
  const tool = hookInput?.tool_name || hookInput?.toolName || "unknown";
717
742
  const input = hookInput?.tool_input || hookInput?.input || {};
718
-
719
- // Auto-approve safe read-only tools
720
- const safeTools = ["Read", "Glob", "Grep", "Ls", "Cat"];
721
- if (safeTools.includes(tool) || tool.startsWith("mcp__sinain")) {
743
+ // session_id is provided by Claude Code / openclaude per the standard
744
+ // PreToolUse hook contract — stable within one CLI invocation. Falls
745
+ // back to sinainTaskId (injected by approve-tool.sh from env) for
746
+ // hosts that don't emit session_id.
747
+ const sessionId: string =
748
+ (typeof hookInput?.session_id === "string" && hookInput.session_id) ||
749
+ (typeof hookInput?.sinainTaskId === "string" && hookInput.sinainTaskId) ||
750
+ "";
751
+
752
+ // Auto-approve safe tools (configured via SINAIN_AUTO_APPROVE_TOOLS).
753
+ // Tokens are exact matches, or prefix patterns ending with "*".
754
+ const autoApproveTools = deps.config.permissionsConfig.autoApproveTools;
755
+ const autoApproved = autoApproveTools.some((p) =>
756
+ p.endsWith("*") ? tool.startsWith(p.slice(0, -1)) : tool === p,
757
+ );
758
+ if (autoApproved) {
722
759
  res.end(JSON.stringify({
723
760
  hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
724
761
  }));
725
762
  return;
726
763
  }
727
764
 
765
+ // YOLO short-circuit: if this session previously clicked YOLO, auto-allow
766
+ // without routing to overlay. No user interaction needed.
767
+ if (sessionId && yoloSessions.has(sessionId)) {
768
+ res.end(JSON.stringify({
769
+ hookSpecificOutput: {
770
+ hookEventName: "PreToolUse",
771
+ permissionDecision: "allow",
772
+ permissionDecisionReason: "YOLO mode active for this session",
773
+ },
774
+ }));
775
+ return;
776
+ }
777
+
728
778
  const taskId = `perm-${Date.now()}`;
779
+ if (sessionId) permissionToSession.set(taskId, sessionId);
729
780
  // Broadcast permission request to overlay
730
781
  deps.wsHandler?.broadcastRaw({
731
782
  type: "spawn_task",
@@ -746,17 +797,49 @@ export function createAppServer(deps: ServerDeps) {
746
797
  }
747
798
  }, 2 * 60_000);
748
799
  });
800
+ // Clean up the permission→session mapping once we're done with it.
801
+ permissionToSession.delete(taskId);
802
+ const allowed = decision === "allow" || decision === "yolo";
749
803
  res.end(JSON.stringify({
750
804
  hookSpecificOutput: {
751
805
  hookEventName: "PreToolUse",
752
- permissionDecision: decision === "allow" ? "allow" : "deny",
753
- permissionDecisionReason: decision === "allow" ? "User approved via HUD" : "User denied or timed out",
806
+ permissionDecision: allowed ? "allow" : "deny",
807
+ permissionDecisionReason:
808
+ decision === "yolo" ? "User engaged YOLO mode — allow all for this session"
809
+ : decision === "allow" ? "User approved via HUD"
810
+ : "User denied or timed out",
754
811
  },
755
812
  }));
756
813
  return;
757
814
  }
758
815
 
759
- // ── /spawn/permission-reply (overlay sends allow/deny) ──
816
+ // ── /bareagent/register (bare agent announces its roster on startup) ──
817
+ if (req.method === "POST" && url.pathname === "/bareagent/register") {
818
+ const body = await readBody(req, 4096);
819
+ let parsed: any;
820
+ try { parsed = JSON.parse(body); }
821
+ catch { res.writeHead(400); res.end(JSON.stringify({ ok: false, error: "invalid json" })); return; }
822
+ const available = Array.isArray(parsed?.available) ? parsed.available : null;
823
+ const current = typeof parsed?.current === "string" ? parsed.current : "";
824
+ if (!available) {
825
+ res.writeHead(400);
826
+ res.end(JSON.stringify({ ok: false, error: "missing available[]" }));
827
+ return;
828
+ }
829
+ deps.registerBareAgent?.(available, current);
830
+ res.end(JSON.stringify({ ok: true }));
831
+ return;
832
+ }
833
+
834
+ // ── /bareagent/config (debug; the hot path uses the config field
835
+ // piggybacked on /escalation/pending and /spawn/pending responses) ──
836
+ if (req.method === "GET" && url.pathname === "/bareagent/config") {
837
+ const cfg = deps.getBareAgentConfig?.() ?? { escalationAgent: "", spawnAgent: "", registered: false };
838
+ res.end(JSON.stringify({ ok: true, ...cfg }));
839
+ return;
840
+ }
841
+
842
+ // ── /spawn/permission-reply (overlay sends allow | deny | yolo) ──
760
843
  if (req.method === "POST" && url.pathname === "/spawn/permission-reply") {
761
844
  const body = await readBody(req, 1024);
762
845
  const { taskId, decision } = JSON.parse(body);
@@ -764,6 +847,14 @@ export function createAppServer(deps: ServerDeps) {
764
847
  const resolve = pendingSpawnQuestions.get(key);
765
848
  if (resolve) {
766
849
  pendingSpawnQuestions.delete(key);
850
+ // YOLO: flag the session so subsequent permission requests for the
851
+ // same openclaude invocation auto-allow without routing to overlay.
852
+ // Session id was captured in permissionToSession when we broadcast
853
+ // the request; it's cleared by the /spawn/approve handler after resolve.
854
+ if (decision === "yolo") {
855
+ const sid = permissionToSession.get(taskId);
856
+ if (sid) yoloSessions.add(sid);
857
+ }
767
858
  resolve(decision || "deny");
768
859
  res.end(JSON.stringify({ ok: true }));
769
860
  } else {
@@ -21,6 +21,14 @@ export interface StatusMessage {
21
21
  escalation?: string;
22
22
  connection: string;
23
23
  responseSize?: string;
24
+ /** Bare-agent roster + per-lane current choice. Omitted until the bare
25
+ * agent has registered via POST /bareagent/register. empty-string lane
26
+ * values mean "Off" (lane disabled). */
27
+ agents?: {
28
+ available: string[];
29
+ escalationAgent: string;
30
+ spawnAgent: string;
31
+ };
24
32
  }
25
33
 
26
34
  /** sinain-core → Overlay: heartbeat ping */
@@ -323,14 +331,16 @@ export interface ContextWindow {
323
331
  }
324
332
 
325
333
  // ── Escalation types ──
326
-
327
- export type EscalationTransport = "ws" | "http" | "auto";
334
+ //
335
+ // Transport choice is per-lane now (driven by the overlay's agent selector):
336
+ // `escalationAgent === "openclaw"` → WS; any other non-empty agent → HTTP.
337
+ // The old global `transport` setting was removed — picking an agent IS the
338
+ // transport.
328
339
 
329
340
  export interface EscalationConfig {
330
341
  mode: EscalationMode;
331
342
  cooldownMs: number;
332
343
  staleMs: number; // force escalation after this many ms of silence (0 = disabled)
333
- transport: EscalationTransport;
334
344
  }
335
345
 
336
346
  export interface OpenClawConfig {
@@ -396,6 +406,13 @@ export interface BridgeState {
396
406
  escalation: "active" | "paused";
397
407
  connection: "connected" | "disconnected" | "connecting";
398
408
  responseSize: ResponseSize;
409
+ /** Bare-agent roster + per-lane current choice. Populated after the
410
+ * bare agent's POST /bareagent/register. "" lane value = lane disabled. */
411
+ agents: {
412
+ available: string[];
413
+ escalationAgent: string;
414
+ spawnAgent: string;
415
+ };
399
416
  }
400
417
 
401
418
  // ── Learning / feedback types ──
@@ -461,6 +478,14 @@ export interface PrivacyConfig {
461
478
  matrix: PrivacyMatrix;
462
479
  }
463
480
 
481
+ // ── Permission gating for /spawn/approve ──
482
+ // Controls which tool-invocations auto-approve (vs. route to overlay for user).
483
+ // Tokens are exact tool names (e.g. "Read") or prefix patterns ending with `*`
484
+ // (e.g. "mcp__sinain*"). Everything else gets the overlay Allow/Deny prompt.
485
+ export interface PermissionsConfig {
486
+ autoApproveTools: string[];
487
+ }
488
+
464
489
  // ── Full core config ──
465
490
 
466
491
  export interface CoreConfig {
@@ -478,4 +503,5 @@ export interface CoreConfig {
478
503
  traceDir: string;
479
504
  learningConfig: LearningConfig;
480
505
  privacyConfig: PrivacyConfig;
506
+ permissionsConfig: PermissionsConfig;
481
507
  }
@@ -330,6 +330,10 @@ def query_facts_hybrid(
330
330
  if eid and eid not in fact_map:
331
331
  fact_map[eid] = f
332
332
 
333
+ # Return top RRF candidates. Embedding re-ranking is done by the caller
334
+ # (sinain-core Node.js) to avoid deadlock — the Python subprocess can't call
335
+ # back to sinain-core's /embed endpoint while sinain-core is blocked waiting
336
+ # for the subprocess.
333
337
  results = [fact_map[eid] for eid in sorted_ids[:max_facts] if eid in fact_map]
334
338
 
335
339
  # Expand top results with 1-hop graph neighbors
@@ -396,7 +400,7 @@ def format_facts_text(facts: list[dict], max_chars: int = 500) -> str:
396
400
  return "\n".join(lines)
397
401
 
398
402
 
399
- def format_facts_compact(facts: list[dict], max_chars: int = 400) -> str:
403
+ def format_facts_compact(facts: list[dict], max_chars: int = 1200) -> str:
400
404
  """Encode facts for efficient escalation context injection.
401
405
 
402
406
  Compact format: domain/entity: value (conf, Nx)
@@ -409,7 +413,7 @@ def format_facts_compact(facts: list[dict], max_chars: int = 400) -> str:
409
413
  total = 0
410
414
  for f in facts:
411
415
  entity = f.get("entityId", "").split(":")[-1][:20]
412
- value = f.get("value", "")[:60]
416
+ value = f.get("value", "")
413
417
  conf = f.get("confidence", "?")
414
418
  count = f.get("reinforce_count", "1")
415
419
  domain = f.get("domain", "")
@@ -469,7 +473,12 @@ def main() -> None:
469
473
  facts = query_top_facts(args.db, limit=args.top)
470
474
  elif args.entities:
471
475
  entities = json.loads(args.entities)
472
- facts = query_facts_by_entities(args.db, entities, max_facts=args.max_facts)
476
+ # Use hybrid retrieval (FTS5 + tags + entity graph + RRF) for best results
477
+ query_text = " ".join(entities)
478
+ facts = query_facts_hybrid(args.db, query_text, max_facts=args.max_facts)
479
+ # Fallback to tag-only if hybrid returns nothing
480
+ if not facts:
481
+ facts = query_facts_by_entities(args.db, entities, max_facts=args.max_facts)
473
482
  else:
474
483
  facts = query_top_facts(args.db, limit=args.max_facts)
475
484