@geravant/sinain 1.10.1 → 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.
@@ -21,8 +21,8 @@ export interface CommandDeps {
21
21
  onSpawnCommand?: (text: string) => void;
22
22
  /** Toggle screen capture — returns new state */
23
23
  onToggleScreen: () => boolean;
24
- /** Toggle trait voices — returns new enabled state */
25
- onToggleTraits?: () => boolean;
24
+ /** Toggle escalation pause/resume — returns true if now active */
25
+ onToggleEscalation: () => boolean;
26
26
  }
27
27
 
28
28
  /**
@@ -79,6 +79,27 @@ export function setupCommands(deps: CommandDeps): void {
79
79
  }
80
80
  break;
81
81
  }
82
+ case "spawn_reply": {
83
+ const { taskId, text } = msg as any;
84
+ log(TAG, `spawn reply for ${taskId}: "${(text || "").slice(0, 60)}"`);
85
+ // Forward to the /spawn/reply HTTP endpoint internally
86
+ fetch(`http://localhost:${deps.config.port}/spawn/reply`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ taskId, text }),
90
+ }).catch(() => {});
91
+ break;
92
+ }
93
+ case "spawn_permission_reply": {
94
+ const { taskId, decision } = msg as any;
95
+ log(TAG, `spawn permission reply for ${taskId}: ${decision}`);
96
+ fetch(`http://localhost:${deps.config.port}/spawn/permission-reply`, {
97
+ method: "POST",
98
+ headers: { "Content-Type": "application/json" },
99
+ body: JSON.stringify({ taskId, decision }),
100
+ }).catch(() => {});
101
+ break;
102
+ }
82
103
  case "command": {
83
104
  handleCommand(msg.action, deps);
84
105
  log(TAG, `command processed: ${msg.action}`);
@@ -142,15 +163,14 @@ function handleCommand(action: string, deps: CommandDeps): void {
142
163
  log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
143
164
  break;
144
165
  }
145
- case "toggle_traits": {
146
- if (!deps.onToggleTraits) {
147
- wsHandler.broadcast("Trait voices not configured", "normal");
148
- break;
149
- }
150
- const nowEnabled = deps.onToggleTraits();
151
- wsHandler.updateState({ traits: nowEnabled ? "active" : "off" });
152
- wsHandler.broadcast(`Trait voices ${nowEnabled ? "on" : "off"}`, "normal");
153
- log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
166
+ case "toggle_escalation": {
167
+ const nowActive = deps.onToggleEscalation();
168
+ wsHandler.updateState({ escalation: nowActive ? "active" : "paused" });
169
+ wsHandler.broadcast(
170
+ nowActive ? "Escalations resumed" : "Escalations paused — context still accumulating",
171
+ "normal"
172
+ );
173
+ log(TAG, `escalation toggled ${nowActive ? "ON" : "OFF"}`);
154
174
  break;
155
175
  }
156
176
  case "open_settings": {
@@ -37,6 +37,7 @@ export class WsHandler {
37
37
  audio: "muted",
38
38
  mic: "muted",
39
39
  screen: "off",
40
+ escalation: "active",
40
41
  connection: "disconnected",
41
42
  };
42
43
  private replayBuffer: FeedMessage[] = [];
@@ -72,6 +73,7 @@ export class WsHandler {
72
73
  audio: this.state.audio,
73
74
  mic: this.state.mic,
74
75
  screen: this.state.screen,
76
+ escalation: this.state.escalation,
75
77
  connection: this.state.connection,
76
78
  });
77
79
 
@@ -149,11 +151,12 @@ export class WsHandler {
149
151
 
150
152
  /** Send a status update to all connected overlays. */
151
153
  broadcastStatus(): void {
152
- const msg: StatusMessage & { envPath?: string } = {
154
+ const msg: StatusMessage & { envPath?: string; escalation?: string } = {
153
155
  type: "status",
154
156
  audio: this.state.audio,
155
157
  mic: this.state.mic,
156
158
  screen: this.state.screen,
159
+ escalation: this.state.escalation,
157
160
  connection: this.state.connection,
158
161
  };
159
162
  if (loadedEnvPath) msg.envPath = loadedEnvPath;
@@ -229,6 +232,12 @@ export class WsHandler {
229
232
  case "spawn_command":
230
233
  log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
231
234
  break;
235
+ case "spawn_reply":
236
+ log(TAG, `\u2190 spawn reply: taskId=${(msg as any).taskId}`);
237
+ break;
238
+ case "spawn_permission_reply":
239
+ log(TAG, `\u2190 spawn permission reply: taskId=${(msg as any).taskId} decision=${(msg as any).decision}`);
240
+ break;
232
241
  case "profiling":
233
242
  if (this.onProfilingCb) this.onProfilingCb(msg);
234
243
  return;
@@ -174,6 +174,7 @@ export interface ServerDeps {
174
174
  feedbackStore?: FeedbackStore;
175
175
  setUserCommand?: (text: string) => void;
176
176
  getEscalationPending?: () => any;
177
+ isEscalationPaused?: () => boolean;
177
178
  respondEscalation?: (id: string, response: string) => any;
178
179
  getKnowledgeDocPath?: () => string | null;
179
180
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
@@ -203,6 +204,9 @@ function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
203
204
  });
204
205
  }
205
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
+
206
210
  export function createAppServer(deps: ServerDeps) {
207
211
  const { config, feedBuffer, senseBuffer, wsHandler } = deps;
208
212
  let senseInBytes = 0;
@@ -562,6 +566,11 @@ export function createAppServer(deps: ServerDeps) {
562
566
 
563
567
  // ── /escalation/pending ──
564
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
+ }
565
574
  const pending = deps.getEscalationPending?.();
566
575
  res.end(JSON.stringify({ ok: true, escalation: pending ?? null }));
567
576
  return;
@@ -620,6 +629,118 @@ export function createAppServer(deps: ServerDeps) {
620
629
  return;
621
630
  }
622
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
+
623
744
  res.writeHead(404);
624
745
  res.end(JSON.stringify({ error: "not found" }));
625
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
  }
@@ -451,6 +451,34 @@ server.tool(
451
451
  },
452
452
  );
453
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
+
454
482
  // ---------------------------------------------------------------------------
455
483
  // Startup
456
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
  # ---------------------------------------------------------------------------