@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.
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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) {
@@ -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
- traits?: "active" | "off";
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 + recent sessions)",
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"}