@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
|
@@ -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
|
|
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
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
if (!existsSync(scriptPath)) {
|
|
400
|
-
warn(TAG, `${script} not found — skipping`);
|
|
401
|
-
continue;
|
|
402
|
-
}
|
|
426
|
+
log(TAG, "periodic curation complete");
|
|
427
|
+
}
|
|
403
428
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
720
|
-
|
|
721
|
-
|
|
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:
|
|
753
|
-
permissionDecisionReason:
|
|
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
|
-
// ── /
|
|
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 {
|
package/sinain-core/src/types.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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", "")
|
|
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
|
-
|
|
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
|
|