@geravant/sinain 1.10.0 → 1.11.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/package.json +1 -1
- package/sinain-agent/CLAUDE.md +50 -0
- package/sinain-agent/run.sh +73 -10
- package/sinain-core/src/agent/analyzer.ts +4 -27
- package/sinain-core/src/agent/loop.ts +10 -40
- package/sinain-core/src/agent/situation-writer.ts +0 -16
- package/sinain-core/src/config.ts +1 -9
- package/sinain-core/src/escalation/escalator.ts +43 -16
- package/sinain-core/src/index.ts +316 -61
- package/sinain-core/src/learning/local-curation.ts +373 -0
- package/sinain-core/src/overlay/commands.ts +31 -11
- package/sinain-core/src/overlay/ws-handler.ts +10 -1
- package/sinain-core/src/server.ts +318 -0
- package/sinain-core/src/types.ts +22 -28
- package/sinain-mcp-server/index.ts +62 -4
- package/sinain-memory/eval/assertions.py +0 -21
- package/sinain-memory/eval/retrieval_benchmark.jsonl +12 -0
- package/sinain-memory/eval/retrieval_evaluator.py +186 -0
- package/sinain-memory/graph_query.py +34 -1
- package/sinain-memory/knowledge_integrator.py +54 -0
- package/sinain-memory/triplestore.py +76 -5
- package/sinain-core/src/agent/traits.ts +0 -520
|
@@ -12,6 +12,142 @@ import { log, error } from "./log.js";
|
|
|
12
12
|
const TAG = "server";
|
|
13
13
|
const MAX_SENSE_BODY = 2 * 1024 * 1024;
|
|
14
14
|
|
|
15
|
+
const KNOWLEDGE_UI_HTML = `<!DOCTYPE html>
|
|
16
|
+
<html><head>
|
|
17
|
+
<meta charset="utf-8"><title>Sinain Knowledge</title>
|
|
18
|
+
<style>
|
|
19
|
+
body { font-family: -apple-system, sans-serif; background: #1a1a2e; color: #e0e0e0; margin: 0; padding: 20px; }
|
|
20
|
+
h1 { color: #00ff88; font-size: 18px; }
|
|
21
|
+
h2 { color: #00cc66; font-size: 14px; margin-top: 20px; }
|
|
22
|
+
.card { background: #16213e; border-radius: 8px; padding: 12px; margin: 8px 0; border-left: 3px solid #00ff88; }
|
|
23
|
+
.card .domain { color: #00ff88; font-size: 11px; text-transform: uppercase; }
|
|
24
|
+
.card .value { margin-top: 4px; }
|
|
25
|
+
.card .meta { color: #888; font-size: 11px; margin-top: 4px; }
|
|
26
|
+
.controls { display: flex; gap: 10px; margin: 16px 0; flex-wrap: wrap; }
|
|
27
|
+
button { background: #00ff88; color: #1a1a2e; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-weight: bold; }
|
|
28
|
+
button:hover { background: #00cc66; }
|
|
29
|
+
button.secondary { background: #333; color: #ccc; }
|
|
30
|
+
input, select { background: #16213e; color: #e0e0e0; border: 1px solid #333; padding: 8px; border-radius: 4px; }
|
|
31
|
+
#status { color: #00ff88; font-size: 12px; margin: 8px 0; }
|
|
32
|
+
#facts { max-height: 70vh; overflow-y: auto; }
|
|
33
|
+
.import-area { margin: 16px 0; }
|
|
34
|
+
textarea { width: 100%; height: 100px; background: #16213e; color: #e0e0e0; border: 1px solid #333; border-radius: 4px; padding: 8px; font-family: monospace; font-size: 12px; }
|
|
35
|
+
</style>
|
|
36
|
+
</head><body>
|
|
37
|
+
<h1>Sinain Knowledge Graph</h1>
|
|
38
|
+
<div class="controls">
|
|
39
|
+
<input id="search" type="text" placeholder="Search entities..." oninput="filterFacts()">
|
|
40
|
+
<select id="domainFilter" onchange="filterFacts()"><option value="">All domains</option></select>
|
|
41
|
+
<button onclick="loadFacts()">Refresh</button>
|
|
42
|
+
<button onclick="exportKnowledge()" class="secondary">Export</button>
|
|
43
|
+
<button onclick="exportDomain()" class="secondary">Export Domain</button>
|
|
44
|
+
</div>
|
|
45
|
+
<div id="status">Loading...</div>
|
|
46
|
+
<div id="facts"></div>
|
|
47
|
+
|
|
48
|
+
<h2>Import Knowledge</h2>
|
|
49
|
+
<div class="import-area">
|
|
50
|
+
<textarea id="importData" placeholder="Paste exported JSON here, or enter a URL to fetch from another sinain instance..."></textarea>
|
|
51
|
+
<div class="controls">
|
|
52
|
+
<button onclick="importKnowledge()">Import</button>
|
|
53
|
+
<button onclick="importFromUrl()" class="secondary">Import from URL</button>
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
let allFacts = [];
|
|
59
|
+
|
|
60
|
+
async function loadFacts() {
|
|
61
|
+
document.getElementById('status').textContent = 'Loading...';
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch('/knowledge/entities?max=200');
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
allFacts = typeof data.entities === 'string' ? JSON.parse(data.entities) : data.entities;
|
|
66
|
+
const domains = [...new Set(allFacts.map(f => f.domain).filter(Boolean))].sort();
|
|
67
|
+
const sel = document.getElementById('domainFilter');
|
|
68
|
+
sel.innerHTML = '<option value="">All domains (' + allFacts.length + ')</option>' +
|
|
69
|
+
domains.map(d => '<option value="' + d + '">' + d + ' (' + allFacts.filter(f=>f.domain===d).length + ')</option>').join('');
|
|
70
|
+
document.getElementById('status').textContent = allFacts.length + ' entities loaded';
|
|
71
|
+
filterFacts();
|
|
72
|
+
} catch (e) { document.getElementById('status').textContent = 'Error: ' + e.message; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function filterFacts() {
|
|
76
|
+
const q = document.getElementById('search').value.toLowerCase();
|
|
77
|
+
const domain = document.getElementById('domainFilter').value;
|
|
78
|
+
const filtered = allFacts.filter(f => {
|
|
79
|
+
if (domain && f.domain !== domain) return false;
|
|
80
|
+
if (q) {
|
|
81
|
+
const text = JSON.stringify(f).toLowerCase();
|
|
82
|
+
return text.includes(q);
|
|
83
|
+
}
|
|
84
|
+
return true;
|
|
85
|
+
});
|
|
86
|
+
document.getElementById('facts').innerHTML = filtered.map(f =>
|
|
87
|
+
'<div class="card">' +
|
|
88
|
+
'<span class="domain">' + (f.domain||'general') + '</span>' +
|
|
89
|
+
'<div class="value">' + esc(f.entity || f.entityId || '?') + ': ' + esc(f.value||'') + '</div>' +
|
|
90
|
+
'<div class="meta">confidence: ' + (f.confidence||'?') + ' | confirmed: ' + (f.reinforce_count||1) + 'x | id: ' + esc(f.entityId||'') + '</div>' +
|
|
91
|
+
'</div>'
|
|
92
|
+
).join('');
|
|
93
|
+
document.getElementById('status').textContent = filtered.length + ' of ' + allFacts.length + ' entities';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function esc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
97
|
+
|
|
98
|
+
async function exportKnowledge() {
|
|
99
|
+
window.open('/knowledge/export?max=500', '_blank');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function exportDomain() {
|
|
103
|
+
const domain = document.getElementById('domainFilter').value;
|
|
104
|
+
if (!domain) { alert('Select a domain first'); return; }
|
|
105
|
+
window.open('/knowledge/export?domain=' + encodeURIComponent(domain) + '&max=500', '_blank');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function importKnowledge() {
|
|
109
|
+
const el = document.getElementById('status');
|
|
110
|
+
const data = document.getElementById('importData').value.trim();
|
|
111
|
+
if (!data) { el.textContent = 'Error: paste JSON data first'; return; }
|
|
112
|
+
el.textContent = 'Importing...';
|
|
113
|
+
try {
|
|
114
|
+
JSON.parse(data); // validate JSON first
|
|
115
|
+
} catch(e) { el.textContent = 'Error: invalid JSON — ' + e.message; return; }
|
|
116
|
+
try {
|
|
117
|
+
const res = await fetch('/knowledge/import', { method: 'POST', body: data });
|
|
118
|
+
const text = await res.text();
|
|
119
|
+
console.log('Import response:', text);
|
|
120
|
+
const result = JSON.parse(text);
|
|
121
|
+
el.textContent = result.ok
|
|
122
|
+
? 'Imported ' + (result.imported||0) + ' facts, skipped ' + (result.skipped||0)
|
|
123
|
+
: 'Error: ' + (result.error||'unknown');
|
|
124
|
+
if (result.ok) { document.getElementById('importData').value = ''; loadFacts(); }
|
|
125
|
+
} catch (e) { el.textContent = 'Import failed: ' + e.message; console.error(e); }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function importFromUrl() {
|
|
129
|
+
const el = document.getElementById('status');
|
|
130
|
+
const input = document.getElementById('importData').value.trim();
|
|
131
|
+
if (!input.startsWith('http')) { el.textContent = 'Error: enter a URL starting with http'; return; }
|
|
132
|
+
el.textContent = 'Fetching from ' + input + '...';
|
|
133
|
+
try {
|
|
134
|
+
const res = await fetch(input);
|
|
135
|
+
if (!res.ok) { el.textContent = 'Fetch failed: HTTP ' + res.status; return; }
|
|
136
|
+
const data = await res.text();
|
|
137
|
+
el.textContent = 'Fetched ' + data.length + ' bytes, importing...';
|
|
138
|
+
const importRes = await fetch('/knowledge/import', { method: 'POST', body: data });
|
|
139
|
+
const result = await importRes.json();
|
|
140
|
+
el.textContent = result.ok
|
|
141
|
+
? 'Imported ' + (result.imported||0) + ' facts from URL, skipped ' + (result.skipped||0)
|
|
142
|
+
: 'Error: ' + (result.error||'unknown');
|
|
143
|
+
if (result.ok) { document.getElementById('importData').value = ''; loadFacts(); }
|
|
144
|
+
} catch (e) { el.textContent = 'Fetch error: ' + e.message + ' (CORS may block cross-origin URLs — use export file instead)'; console.error(e); }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
loadFacts();
|
|
148
|
+
</script>
|
|
149
|
+
</body></html>`;
|
|
150
|
+
|
|
15
151
|
/** Server epoch — lets clients detect restarts. */
|
|
16
152
|
const serverEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
17
153
|
|
|
@@ -38,9 +174,13 @@ export interface ServerDeps {
|
|
|
38
174
|
feedbackStore?: FeedbackStore;
|
|
39
175
|
setUserCommand?: (text: string) => void;
|
|
40
176
|
getEscalationPending?: () => any;
|
|
177
|
+
isEscalationPaused?: () => boolean;
|
|
41
178
|
respondEscalation?: (id: string, response: string) => any;
|
|
42
179
|
getKnowledgeDocPath?: () => string | null;
|
|
43
180
|
queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
|
|
181
|
+
listKnowledgeEntities?: (max: number) => Promise<string>;
|
|
182
|
+
exportKnowledge?: (domain: string | null, max: number) => Promise<string>;
|
|
183
|
+
importKnowledge?: (data: string) => Promise<string>;
|
|
44
184
|
onSpawnCommand?: (text: string) => void;
|
|
45
185
|
getSpawnPending?: () => { id: string; task: string; label: string; ts: number } | null;
|
|
46
186
|
respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
|
|
@@ -64,6 +204,9 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
|
|
|
64
204
|
});
|
|
65
205
|
}
|
|
66
206
|
|
|
207
|
+
/** Pending spawn questions/permissions — resolve callbacks keyed by "ask:{taskId}" or "perm:{taskId}" */
|
|
208
|
+
const pendingSpawnQuestions = new Map<string, (answer: string) => void>();
|
|
209
|
+
|
|
67
210
|
export function createAppServer(deps: ServerDeps) {
|
|
68
211
|
const { config, feedBuffer, senseBuffer, wsHandler } = deps;
|
|
69
212
|
let senseInBytes = 0;
|
|
@@ -272,6 +415,64 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
272
415
|
return;
|
|
273
416
|
}
|
|
274
417
|
|
|
418
|
+
if (req.method === "GET" && url.pathname === "/knowledge/entities") {
|
|
419
|
+
// List all entities in the knowledge graph
|
|
420
|
+
const max = Math.min(parseInt(url.searchParams.get("max") || "50"), 200);
|
|
421
|
+
if (deps.listKnowledgeEntities) {
|
|
422
|
+
try {
|
|
423
|
+
const entities = await deps.listKnowledgeEntities(max);
|
|
424
|
+
res.end(JSON.stringify({ ok: true, entities }));
|
|
425
|
+
} catch (err) {
|
|
426
|
+
res.end(JSON.stringify({ ok: true, entities: [], error: String(err) }));
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
res.end(JSON.stringify({ ok: true, entities: [] }));
|
|
430
|
+
}
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (req.method === "GET" && url.pathname === "/knowledge/export") {
|
|
435
|
+
// Export knowledge module (filterable by domain)
|
|
436
|
+
const domain = url.searchParams.get("domain") || null;
|
|
437
|
+
const max = Math.min(parseInt(url.searchParams.get("max") || "100"), 500);
|
|
438
|
+
if (deps.exportKnowledge) {
|
|
439
|
+
try {
|
|
440
|
+
const data = await deps.exportKnowledge(domain, max);
|
|
441
|
+
res.setHeader("Content-Type", "application/json");
|
|
442
|
+
res.setHeader("Content-Disposition", `attachment; filename="sinain-knowledge-${domain || "all"}.json"`);
|
|
443
|
+
res.end(data);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
res.end(JSON.stringify({ ok: false, error: "export not available" }));
|
|
449
|
+
}
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (req.method === "POST" && url.pathname === "/knowledge/import") {
|
|
454
|
+
// Import knowledge module
|
|
455
|
+
const body = await readBody(req, 1_000_000); // 1MB max
|
|
456
|
+
if (deps.importKnowledge) {
|
|
457
|
+
try {
|
|
458
|
+
const result = await deps.importKnowledge(body);
|
|
459
|
+
res.end(result);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
res.end(JSON.stringify({ ok: false, error: String(err) }));
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
res.end(JSON.stringify({ ok: false, error: "import not available" }));
|
|
465
|
+
}
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (req.method === "GET" && url.pathname === "/knowledge/ui") {
|
|
470
|
+
// Simple web UI for browsing and transferring knowledge
|
|
471
|
+
res.setHeader("Content-Type", "text/html");
|
|
472
|
+
res.end(KNOWLEDGE_UI_HTML);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
275
476
|
// ── /traces ──
|
|
276
477
|
if (req.method === "GET" && url.pathname === "/traces") {
|
|
277
478
|
const after = parseInt(url.searchParams.get("after") || "0");
|
|
@@ -365,6 +566,11 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
365
566
|
|
|
366
567
|
// ── /escalation/pending ──
|
|
367
568
|
if (req.method === "GET" && url.pathname === "/escalation/pending") {
|
|
569
|
+
const paused = deps.isEscalationPaused?.() ?? false;
|
|
570
|
+
if (paused) {
|
|
571
|
+
res.end(JSON.stringify({ ok: true, escalation: null, paused: true }));
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
368
574
|
const pending = deps.getEscalationPending?.();
|
|
369
575
|
res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
|
|
370
576
|
return;
|
|
@@ -423,6 +629,118 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
423
629
|
return;
|
|
424
630
|
}
|
|
425
631
|
|
|
632
|
+
// ── /spawn/ask (MCP tool posts question, blocks until user replies) ──
|
|
633
|
+
if (req.method === "POST" && url.pathname === "/spawn/ask") {
|
|
634
|
+
const body = await readBody(req, 8192);
|
|
635
|
+
const { taskId, question } = JSON.parse(body);
|
|
636
|
+
if (!taskId || !question) {
|
|
637
|
+
res.writeHead(400);
|
|
638
|
+
res.end(JSON.stringify({ ok: false, error: "missing taskId or question" }));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
// Broadcast question to overlay
|
|
642
|
+
deps.wsHandler?.broadcastRaw({
|
|
643
|
+
type: "spawn_task",
|
|
644
|
+
taskId,
|
|
645
|
+
label: "user-command",
|
|
646
|
+
status: "awaiting_input",
|
|
647
|
+
startedAt: Date.now(),
|
|
648
|
+
question,
|
|
649
|
+
});
|
|
650
|
+
// Hold response open until user replies (or timeout after 5 min)
|
|
651
|
+
const answer = await new Promise<string>((resolve) => {
|
|
652
|
+
const key = `ask:${taskId}`;
|
|
653
|
+
pendingSpawnQuestions.set(key, resolve);
|
|
654
|
+
setTimeout(() => {
|
|
655
|
+
if (pendingSpawnQuestions.has(key)) {
|
|
656
|
+
pendingSpawnQuestions.delete(key);
|
|
657
|
+
resolve("(no reply — user did not respond within 5 minutes)");
|
|
658
|
+
}
|
|
659
|
+
}, 5 * 60_000);
|
|
660
|
+
});
|
|
661
|
+
res.end(JSON.stringify({ ok: true, answer }));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ── /spawn/reply (overlay sends answer to a spawn question) ──
|
|
666
|
+
if (req.method === "POST" && url.pathname === "/spawn/reply") {
|
|
667
|
+
const body = await readBody(req, 8192);
|
|
668
|
+
const { taskId, text } = JSON.parse(body);
|
|
669
|
+
const key = `ask:${taskId}`;
|
|
670
|
+
const resolve = pendingSpawnQuestions.get(key);
|
|
671
|
+
if (resolve) {
|
|
672
|
+
pendingSpawnQuestions.delete(key);
|
|
673
|
+
resolve(text || "(empty reply)");
|
|
674
|
+
res.end(JSON.stringify({ ok: true }));
|
|
675
|
+
} else {
|
|
676
|
+
res.end(JSON.stringify({ ok: false, error: "no pending question for this task" }));
|
|
677
|
+
}
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ── /spawn/approve (Claude hook posts tool permission, blocks until user decides) ──
|
|
682
|
+
if (req.method === "POST" && url.pathname === "/spawn/approve") {
|
|
683
|
+
const body = await readBody(req, 16384);
|
|
684
|
+
const hookInput = JSON.parse(body);
|
|
685
|
+
const tool = hookInput?.tool_name || hookInput?.toolName || "unknown";
|
|
686
|
+
const input = hookInput?.tool_input || hookInput?.input || {};
|
|
687
|
+
|
|
688
|
+
// Auto-approve safe read-only tools
|
|
689
|
+
const safeTools = ["Read", "Glob", "Grep", "Ls", "Cat"];
|
|
690
|
+
if (safeTools.includes(tool) || tool.startsWith("mcp__sinain")) {
|
|
691
|
+
res.end(JSON.stringify({
|
|
692
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow" },
|
|
693
|
+
}));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const taskId = `perm-${Date.now()}`;
|
|
698
|
+
// Broadcast permission request to overlay
|
|
699
|
+
deps.wsHandler?.broadcastRaw({
|
|
700
|
+
type: "spawn_task",
|
|
701
|
+
taskId,
|
|
702
|
+
label: "permission",
|
|
703
|
+
status: "awaiting_permission",
|
|
704
|
+
startedAt: Date.now(),
|
|
705
|
+
permission: { tool, input },
|
|
706
|
+
});
|
|
707
|
+
// Hold response open until user decides
|
|
708
|
+
const decision = await new Promise<string>((resolve) => {
|
|
709
|
+
const key = `perm:${taskId}`;
|
|
710
|
+
pendingSpawnQuestions.set(key, resolve);
|
|
711
|
+
setTimeout(() => {
|
|
712
|
+
if (pendingSpawnQuestions.has(key)) {
|
|
713
|
+
pendingSpawnQuestions.delete(key);
|
|
714
|
+
resolve("deny"); // default deny on timeout
|
|
715
|
+
}
|
|
716
|
+
}, 2 * 60_000);
|
|
717
|
+
});
|
|
718
|
+
res.end(JSON.stringify({
|
|
719
|
+
hookSpecificOutput: {
|
|
720
|
+
hookEventName: "PreToolUse",
|
|
721
|
+
permissionDecision: decision === "allow" ? "allow" : "deny",
|
|
722
|
+
permissionDecisionReason: decision === "allow" ? "User approved via HUD" : "User denied or timed out",
|
|
723
|
+
},
|
|
724
|
+
}));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// ── /spawn/permission-reply (overlay sends allow/deny) ──
|
|
729
|
+
if (req.method === "POST" && url.pathname === "/spawn/permission-reply") {
|
|
730
|
+
const body = await readBody(req, 1024);
|
|
731
|
+
const { taskId, decision } = JSON.parse(body);
|
|
732
|
+
const key = `perm:${taskId}`;
|
|
733
|
+
const resolve = pendingSpawnQuestions.get(key);
|
|
734
|
+
if (resolve) {
|
|
735
|
+
pendingSpawnQuestions.delete(key);
|
|
736
|
+
resolve(decision || "deny");
|
|
737
|
+
res.end(JSON.stringify({ ok: true }));
|
|
738
|
+
} else {
|
|
739
|
+
res.end(JSON.stringify({ ok: false, error: "no pending permission for this task" }));
|
|
740
|
+
}
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
|
|
426
744
|
res.writeHead(404);
|
|
427
745
|
res.end(JSON.stringify({ error: "not found" }));
|
|
428
746
|
} catch (err: any) {
|
package/sinain-core/src/types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface StatusMessage {
|
|
|
18
18
|
audio: string;
|
|
19
19
|
mic: string;
|
|
20
20
|
screen: string;
|
|
21
|
+
escalation?: string;
|
|
21
22
|
connection: string;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -28,7 +29,7 @@ export interface PingMessage {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** sinain-core → Overlay: spawn task lifecycle update */
|
|
31
|
-
export type SpawnTaskStatus = "spawned" | "polling" | "completed" | "failed" | "timeout";
|
|
32
|
+
export type SpawnTaskStatus = "spawned" | "polling" | "completed" | "failed" | "timeout" | "awaiting_input" | "awaiting_permission";
|
|
32
33
|
|
|
33
34
|
export interface SpawnTaskMessage {
|
|
34
35
|
type: "spawn_task";
|
|
@@ -38,6 +39,10 @@ export interface SpawnTaskMessage {
|
|
|
38
39
|
startedAt: number;
|
|
39
40
|
completedAt?: number;
|
|
40
41
|
resultPreview?: string;
|
|
42
|
+
/** Question the spawn is asking the user (status=awaiting_input) */
|
|
43
|
+
question?: string;
|
|
44
|
+
/** Tool permission request (status=awaiting_permission) */
|
|
45
|
+
permission?: { tool: string; input: Record<string, unknown> };
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
/** Overlay → sinain-core: user typed a message */
|
|
@@ -78,6 +83,20 @@ export interface SpawnCommandMessage {
|
|
|
78
83
|
text: string;
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
/** Overlay → sinain-core: reply to a spawn question */
|
|
87
|
+
export interface SpawnReplyMessage {
|
|
88
|
+
type: "spawn_reply";
|
|
89
|
+
taskId: string;
|
|
90
|
+
text: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Overlay → sinain-core: reply to a spawn permission request */
|
|
94
|
+
export interface SpawnPermissionReplyMessage {
|
|
95
|
+
type: "spawn_permission_reply";
|
|
96
|
+
taskId: string;
|
|
97
|
+
decision: "allow" | "deny";
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
/** Cost update broadcast to overlay. */
|
|
82
101
|
export interface CostMessage {
|
|
83
102
|
type: "cost";
|
|
@@ -108,7 +127,7 @@ export interface CostSnapshot {
|
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage | CostMessage;
|
|
111
|
-
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage;
|
|
130
|
+
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage | SpawnReplyMessage | SpawnPermissionReplyMessage;
|
|
112
131
|
|
|
113
132
|
/** Abstraction for user commands (text now, voice later). */
|
|
114
133
|
export interface UserCommand {
|
|
@@ -163,27 +182,6 @@ export interface AudioPipelineConfig {
|
|
|
163
182
|
gainDb: number;
|
|
164
183
|
}
|
|
165
184
|
|
|
166
|
-
export interface TraitConfig {
|
|
167
|
-
enabled: boolean;
|
|
168
|
-
configPath: string; // path to ~/.sinain/traits.json
|
|
169
|
-
entropyHigh: boolean; // Phase 2: boosts entropy roll to 15%
|
|
170
|
-
logDir: string; // path to ~/.sinain-core/traits/
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export interface TraitLogEntry {
|
|
174
|
-
ts: string;
|
|
175
|
-
tickId: number;
|
|
176
|
-
enabled: boolean;
|
|
177
|
-
voice: string;
|
|
178
|
-
voice_stat: number;
|
|
179
|
-
voice_confidence: number;
|
|
180
|
-
activation_scores: Record<string, number>;
|
|
181
|
-
context_app: string;
|
|
182
|
-
hud_length: number;
|
|
183
|
-
synthesis: boolean;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
185
|
export interface AudioChunk {
|
|
188
186
|
buffer: Buffer;
|
|
189
187
|
source: string;
|
|
@@ -282,9 +280,6 @@ export interface AgentResult {
|
|
|
282
280
|
tokensOut: number;
|
|
283
281
|
model: string;
|
|
284
282
|
parsedOk: boolean;
|
|
285
|
-
voice?: string;
|
|
286
|
-
voice_stat?: number;
|
|
287
|
-
voice_confidence?: number;
|
|
288
283
|
/** Actual USD cost returned by OpenRouter (undefined if not available). */
|
|
289
284
|
cost?: number;
|
|
290
285
|
}
|
|
@@ -396,7 +391,7 @@ export interface BridgeState {
|
|
|
396
391
|
audio: "active" | "muted";
|
|
397
392
|
mic: "active" | "muted";
|
|
398
393
|
screen: "active" | "off";
|
|
399
|
-
|
|
394
|
+
escalation: "active" | "paused";
|
|
400
395
|
connection: "connected" | "disconnected" | "connecting";
|
|
401
396
|
}
|
|
402
397
|
|
|
@@ -479,6 +474,5 @@ export interface CoreConfig {
|
|
|
479
474
|
costDisplayEnabled: boolean;
|
|
480
475
|
traceDir: string;
|
|
481
476
|
learningConfig: LearningConfig;
|
|
482
|
-
traitConfig: TraitConfig;
|
|
483
477
|
privacyConfig: PrivacyConfig;
|
|
484
478
|
}
|
|
@@ -203,19 +203,30 @@ server.tool(
|
|
|
203
203
|
);
|
|
204
204
|
|
|
205
205
|
// 8. sinain_get_knowledge
|
|
206
|
+
// Queries sinain-core's /knowledge API which merges both local and workspace DBs.
|
|
207
|
+
// Falls back to reading the workspace knowledge doc directly if sinain-core is unreachable.
|
|
206
208
|
server.tool(
|
|
207
209
|
"sinain_get_knowledge",
|
|
208
|
-
"Get the portable knowledge document (playbook + long-term facts
|
|
210
|
+
"Get the portable knowledge document (playbook + long-term facts from both local and workspace databases)",
|
|
209
211
|
{},
|
|
210
212
|
async () => {
|
|
213
|
+
// Try sinain-core API first (merges both DBs)
|
|
214
|
+
try {
|
|
215
|
+
const data = await coreRequest("GET", "/knowledge");
|
|
216
|
+
if (data.ok && data.content) {
|
|
217
|
+
return textResult(stripPrivateTags(data.content));
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
// sinain-core unreachable — fall through to local files
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Fallback: read workspace files directly
|
|
211
224
|
try {
|
|
212
|
-
// Read pre-rendered knowledge doc (fast, no subprocess)
|
|
213
225
|
const docPath = resolve(MEMORY_DIR, "sinain-knowledge.md");
|
|
214
226
|
if (existsSync(docPath)) {
|
|
215
227
|
const content = readFileSync(docPath, "utf-8");
|
|
216
228
|
return textResult(stripPrivateTags(content));
|
|
217
229
|
}
|
|
218
|
-
// Fallback: read playbook directly
|
|
219
230
|
const playbookPath = resolve(MEMORY_DIR, "sinain-playbook.md");
|
|
220
231
|
if (existsSync(playbookPath)) {
|
|
221
232
|
return textResult(stripPrivateTags(readFileSync(playbookPath, "utf-8")));
|
|
@@ -228,14 +239,33 @@ server.tool(
|
|
|
228
239
|
);
|
|
229
240
|
|
|
230
241
|
// 8b. sinain_knowledge_query (graph query — entity-based lookup)
|
|
242
|
+
// Queries sinain-core's /knowledge/facts API which merges both local and workspace DBs.
|
|
243
|
+
// Falls back to local graph_query.py (workspace DB only) if sinain-core is unreachable.
|
|
231
244
|
server.tool(
|
|
232
245
|
"sinain_knowledge_query",
|
|
233
|
-
"Query the knowledge graph for facts about specific entities/domains",
|
|
246
|
+
"Query the knowledge graph for facts about specific entities/domains (searches both local and workspace databases)",
|
|
234
247
|
{
|
|
235
248
|
entities: z.array(z.string()).optional().default([]),
|
|
236
249
|
max_facts: z.number().optional().default(5),
|
|
237
250
|
},
|
|
238
251
|
async ({ entities, max_facts }) => {
|
|
252
|
+
// Try sinain-core API first (merges both local + workspace DBs)
|
|
253
|
+
if (entities.length > 0) {
|
|
254
|
+
try {
|
|
255
|
+
const params = new URLSearchParams({
|
|
256
|
+
entities: entities.join(","),
|
|
257
|
+
max: String(max_facts),
|
|
258
|
+
});
|
|
259
|
+
const data = await coreRequest("GET", `/knowledge/facts?${params}`);
|
|
260
|
+
if (data.ok && data.facts) {
|
|
261
|
+
return textResult(stripPrivateTags(data.facts));
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
// sinain-core unreachable — fall through to local script
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Fallback: query workspace DB directly via graph_query.py
|
|
239
269
|
try {
|
|
240
270
|
const dbPath = resolve(MEMORY_DIR, "knowledge-graph.db");
|
|
241
271
|
const scriptPath = resolve(SCRIPTS_DIR, "graph_query.py");
|
|
@@ -421,6 +451,34 @@ server.tool(
|
|
|
421
451
|
},
|
|
422
452
|
);
|
|
423
453
|
|
|
454
|
+
// 15. sinain_ask_user — blocking question to the user via overlay
|
|
455
|
+
server.tool(
|
|
456
|
+
"sinain_ask_user",
|
|
457
|
+
"Ask the user a question and wait for their reply. Use when you need clarification, confirmation, or a decision. The question appears on the user's HUD overlay and blocks until they respond.",
|
|
458
|
+
{
|
|
459
|
+
question: z.string().describe("The question to ask the user"),
|
|
460
|
+
},
|
|
461
|
+
async ({ question }) => {
|
|
462
|
+
// Use the spawn task ID from the environment if available
|
|
463
|
+
const taskId = process.env.SINAIN_SPAWN_TASK_ID || `ask-${Date.now()}`;
|
|
464
|
+
try {
|
|
465
|
+
const resp = await fetch(`${SINAIN_CORE_URL}/spawn/ask`, {
|
|
466
|
+
method: "POST",
|
|
467
|
+
headers: { "Content-Type": "application/json" },
|
|
468
|
+
body: JSON.stringify({ taskId, question }),
|
|
469
|
+
signal: AbortSignal.timeout(6 * 60_000), // 6 min (server times out at 5)
|
|
470
|
+
});
|
|
471
|
+
const data = await resp.json() as { ok: boolean; answer?: string };
|
|
472
|
+
if (data.ok && data.answer) {
|
|
473
|
+
return textResult(`User replied: ${data.answer}`);
|
|
474
|
+
}
|
|
475
|
+
return textResult("User did not reply.");
|
|
476
|
+
} catch (err: any) {
|
|
477
|
+
return textResult(`Failed to ask user: ${err.message}`);
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
);
|
|
481
|
+
|
|
424
482
|
// ---------------------------------------------------------------------------
|
|
425
483
|
// Startup
|
|
426
484
|
// ---------------------------------------------------------------------------
|
|
@@ -206,27 +206,6 @@ def assert_playbook_header_footer_intact(playbook_text: str) -> dict:
|
|
|
206
206
|
f"missing playbook comments: {', '.join(missing)}")
|
|
207
207
|
|
|
208
208
|
|
|
209
|
-
# ---------------------------------------------------------------------------
|
|
210
|
-
# Trait voice assertions (sinain-core wiring verification)
|
|
211
|
-
# ---------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
def assert_situation_has_active_voice(
|
|
214
|
-
situation_content: str, expected_trait: str | None = None
|
|
215
|
-
) -> dict:
|
|
216
|
-
"""Check SITUATION.md contains an Active Voice section (after trait wiring).
|
|
217
|
-
|
|
218
|
-
Called by tick_evaluator.py when processing live ticks that have SITUATION.md
|
|
219
|
-
content and a trait was selected for that tick.
|
|
220
|
-
"""
|
|
221
|
-
has_section = "## Active Voice" in situation_content
|
|
222
|
-
if not has_section:
|
|
223
|
-
return _result("situation_has_active_voice", False, "no '## Active Voice' section")
|
|
224
|
-
if expected_trait and expected_trait not in situation_content:
|
|
225
|
-
return _result("situation_has_active_voice", False,
|
|
226
|
-
f"section present but '{expected_trait}' not found")
|
|
227
|
-
return _result("situation_has_active_voice", True, "Active Voice section present")
|
|
228
|
-
|
|
229
|
-
|
|
230
209
|
# ---------------------------------------------------------------------------
|
|
231
210
|
# Runner: execute all applicable assertions for a tick
|
|
232
211
|
# ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{"query": "OCR pipeline stalls on macOS 14", "expected_entities": ["fact:ocr-backpressure", "fact:sck-capture"], "category": "error-resolution"}
|
|
2
|
+
{"query": "camera conflicts with screen capture", "expected_entities": ["fact:camera-conflict", "fact:coremediaio"], "category": "error-resolution"}
|
|
3
|
+
{"query": "audio gain not applied in pipeline", "expected_entities": ["fact:audio-gain"], "category": "bug-fix"}
|
|
4
|
+
{"query": "Flutter ProviderNotFoundException in secondary window", "expected_entities": ["fact:flutter-provider", "fact:multi-window"], "category": "error-resolution"}
|
|
5
|
+
{"query": "user prefers concise Telegram messages", "expected_entities": ["fact:telegram-preference"], "category": "user-preference"}
|
|
6
|
+
{"query": "PyObjC performRequests_error_ returns bool not tuple", "expected_entities": ["fact:pyobjc-api"], "category": "bug-fix"}
|
|
7
|
+
{"query": "ScreenCaptureKit zero-copy IOSurface", "expected_entities": ["fact:sck-capture", "fact:iosurface"], "category": "tool-knowledge"}
|
|
8
|
+
{"query": "OpenClaw gateway workspace not initialized", "expected_entities": ["fact:workspace-init"], "category": "error-resolution"}
|
|
9
|
+
{"query": "react-native metro bundler cache invalidation", "expected_entities": ["fact:react-native-metro"], "category": "tool-knowledge"}
|
|
10
|
+
{"query": "sinain agent session key format", "expected_entities": ["fact:session-key"], "category": "tool-knowledge"}
|
|
11
|
+
{"query": "what was the OCR backend last month", "expected_entities": ["fact:ocr-backend"], "category": "temporal"}
|
|
12
|
+
{"query": "when did we switch from CGDisplayCreateImage to ScreenCaptureKit", "expected_entities": ["fact:sck-capture", "fact:cgdisplay-deprecation"], "category": "temporal"}
|