@geravant/sinain 1.4.0 → 1.5.1

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/cli.js CHANGED
@@ -162,12 +162,36 @@ async function runSetupWizard() {
162
162
  vars.SINAIN_HEARTBEAT_INTERVAL = "900";
163
163
  vars.PRIVACY_MODE = "standard";
164
164
 
165
- // Write
165
+ // Write — start from .env.example template, patch wizard values in
166
166
  fs.mkdirSync(SINAIN_DIR, { recursive: true });
167
- const lines = ["# sinain configuration — generated by setup wizard", `# ${new Date().toISOString()}`, ""];
168
- for (const [k, v] of Object.entries(vars)) lines.push(`${k}=${v}`);
169
- lines.push("");
170
- fs.writeFileSync(envPath, lines.join("\n"));
167
+
168
+ const PKG_DIR = path.dirname(new URL(import.meta.url).pathname);
169
+ const examplePath = path.join(PKG_DIR, ".env.example");
170
+ const siblingExample = path.join(PKG_DIR, "..", ".env.example");
171
+ let template = "";
172
+ if (fs.existsSync(examplePath)) {
173
+ template = fs.readFileSync(examplePath, "utf-8");
174
+ } else if (fs.existsSync(siblingExample)) {
175
+ template = fs.readFileSync(siblingExample, "utf-8");
176
+ }
177
+
178
+ if (template) {
179
+ for (const [k, v] of Object.entries(vars)) {
180
+ const regex = new RegExp(`^#?\\s*${k}=.*$`, "m");
181
+ if (regex.test(template)) {
182
+ template = template.replace(regex, `${k}=${v}`);
183
+ } else {
184
+ template += `\n${k}=${v}`;
185
+ }
186
+ }
187
+ template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
188
+ fs.writeFileSync(envPath, template);
189
+ } else {
190
+ const lines = ["# sinain configuration — generated by setup wizard", `# ${new Date().toISOString()}`, ""];
191
+ for (const [k, v] of Object.entries(vars)) lines.push(`${k}=${v}`);
192
+ lines.push("");
193
+ fs.writeFileSync(envPath, lines.join("\n"));
194
+ }
171
195
 
172
196
  rl.close();
173
197
  console.log(`\n ${GREEN}✓${RESET} Config written to ${envPath}\n`);
package/launcher.js CHANGED
@@ -409,17 +409,48 @@ async function setupWizard(envPath) {
409
409
  vars.SINAIN_HEARTBEAT_INTERVAL = "900";
410
410
  vars.PRIVACY_MODE = "standard";
411
411
 
412
- // Write .env
412
+ // Write .env — start from .env.example template, patch wizard values in
413
413
  fs.mkdirSync(path.dirname(envPath), { recursive: true });
414
- const lines = [];
415
- lines.push("# sinain configuration generated by setup wizard");
416
- lines.push(`# ${new Date().toISOString()}`);
417
- lines.push("");
418
- for (const [key, val] of Object.entries(vars)) {
419
- lines.push(`${key}=${val}`);
420
- }
421
- lines.push("");
422
- fs.writeFileSync(envPath, lines.join("\n"));
414
+
415
+ const examplePath = path.join(PKG_DIR, ".env.example");
416
+ let template = "";
417
+ if (fs.existsSync(examplePath)) {
418
+ template = fs.readFileSync(examplePath, "utf-8");
419
+ } else {
420
+ // Fallback: try sibling (running from cloned repo)
421
+ const siblingExample = path.join(PKG_DIR, "..", ".env.example");
422
+ if (fs.existsSync(siblingExample)) {
423
+ template = fs.readFileSync(siblingExample, "utf-8");
424
+ }
425
+ }
426
+
427
+ if (template) {
428
+ // Patch each wizard var into the template by replacing the KEY=... line
429
+ for (const [key, val] of Object.entries(vars)) {
430
+ // Match KEY=anything (possibly commented out with #)
431
+ const regex = new RegExp(`^#?\\s*${key}=.*$`, "m");
432
+ if (regex.test(template)) {
433
+ template = template.replace(regex, `${key}=${val}`);
434
+ } else {
435
+ // Key not in template — append it
436
+ template += `\n${key}=${val}`;
437
+ }
438
+ }
439
+ // Add wizard timestamp header
440
+ template = `# Generated by sinain setup wizard — ${new Date().toISOString()}\n${template}`;
441
+ fs.writeFileSync(envPath, template);
442
+ } else {
443
+ // No template found — write bare vars (fallback)
444
+ const lines = [];
445
+ lines.push("# sinain configuration — generated by setup wizard");
446
+ lines.push(`# ${new Date().toISOString()}`);
447
+ lines.push("");
448
+ for (const [key, val] of Object.entries(vars)) {
449
+ lines.push(`${key}=${val}`);
450
+ }
451
+ lines.push("");
452
+ fs.writeFileSync(envPath, lines.join("\n"));
453
+ }
423
454
 
424
455
  rl.close();
425
456
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
4
4
  "description": "Ambient AI overlay invisible to screen capture — real-time insights from audio + screen context",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "sinain-core/package.json",
27
27
  "sinain-core/package-lock.json",
28
28
  "sinain-core/tsconfig.json",
29
- "sinain-core/.env.example",
29
+ ".env.example",
30
30
  "sinain-mcp-server/index.ts",
31
31
  "sinain-mcp-server/package.json",
32
32
  "sinain-mcp-server/tsconfig.json",
@@ -224,6 +224,37 @@ while true; do
224
224
  echo ""
225
225
  fi
226
226
 
227
+ # Poll for pending spawn task (queued via HUD Shift+Enter or POST /spawn)
228
+ SPAWN=$(curl -sf "$CORE_URL/spawn/pending" 2>/dev/null || echo '{"ok":false}')
229
+ SPAWN_ID=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); t=d.get('task'); print(t['id'] if t else '')" 2>/dev/null || true)
230
+
231
+ if [ -n "$SPAWN_ID" ]; then
232
+ SPAWN_TASK=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task']['task'])" 2>/dev/null)
233
+ SPAWN_LABEL=$(echo "$SPAWN" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['task'].get('label','task'))" 2>/dev/null)
234
+
235
+ echo "[$(date +%H:%M:%S)] Spawn task $SPAWN_ID ($SPAWN_LABEL)"
236
+
237
+ if agent_has_mcp; then
238
+ # MCP path: agent runs task with sinain tools available
239
+ SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
240
+
241
+ Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
242
+ SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" || echo "ERROR: agent invocation failed")
243
+ else
244
+ # Pipe path: agent gets task text directly
245
+ SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
246
+ fi
247
+
248
+ # Post result back
249
+ if [ -n "$SPAWN_RESULT" ]; then
250
+ curl -sf -X POST "$CORE_URL/spawn/respond" \
251
+ -H 'Content-Type: application/json' \
252
+ -d "{\"id\":\"$SPAWN_ID\",\"result\":$(echo "$SPAWN_RESULT" | json_encode)}" >/dev/null 2>&1 || true
253
+ echo "[$(date +%H:%M:%S)] Spawn $SPAWN_ID completed: ${SPAWN_RESULT:0:120}..."
254
+ fi
255
+ echo ""
256
+ fi
257
+
227
258
  # Heartbeat check
228
259
  NOW=$(date +%s)
229
260
  ELAPSED=$((NOW - LAST_HEARTBEAT))
@@ -78,6 +78,9 @@ export class Escalator {
78
78
  private pendingUserCommand: UserCommand | null = null;
79
79
  private static readonly USER_COMMAND_EXPIRY_MS = 120_000; // 2 minutes
80
80
 
81
+ // HTTP spawn queue — for bare agents that poll (mirrors httpPending for escalation)
82
+ private spawnHttpPending: { id: string; task: string; label: string; ts: number } | null = null;
83
+
81
84
  private stats = {
82
85
  totalEscalations: 0,
83
86
  totalResponses: 0,
@@ -397,6 +400,37 @@ ${recentLines.join("\n")}`;
397
400
  return { ok: true };
398
401
  }
399
402
 
403
+ /** Return the current HTTP pending spawn task (or null). */
404
+ getSpawnPending(): { id: string; task: string; label: string; ts: number } | null {
405
+ return this.spawnHttpPending;
406
+ }
407
+
408
+ /** Respond to a pending spawn task from a bare agent. */
409
+ respondSpawn(id: string, result: string): { ok: boolean; error?: string } {
410
+ if (!this.spawnHttpPending) {
411
+ return { ok: false, error: "no pending spawn task" };
412
+ }
413
+ if (this.spawnHttpPending.id !== id) {
414
+ return { ok: false, error: `id mismatch: expected ${this.spawnHttpPending.id}` };
415
+ }
416
+
417
+ const label = this.spawnHttpPending.label;
418
+ const startedAt = this.spawnHttpPending.ts;
419
+
420
+ // Push result to HUD feed
421
+ const maxLen = 3000;
422
+ const text = `[🔧 ${label}] ${result.trim().slice(0, maxLen)}`;
423
+ this.deps.feedBuffer.push(text, "high", "openclaw", "agent");
424
+ this.deps.wsHandler.broadcast(text, "high", "agent");
425
+
426
+ // Broadcast completion
427
+ this.broadcastTaskEvent(id, "completed", label, startedAt, result.slice(0, 200));
428
+
429
+ log(TAG, `spawn ${id} responded (${result.length} chars)`);
430
+ this.spawnHttpPending = null;
431
+ return { ok: true };
432
+ }
433
+
400
434
  /** Whether the gateway WS client is currently connected. */
401
435
  get isGatewayConnected(): boolean {
402
436
  return this.wsClient.isConnected;
@@ -468,8 +502,12 @@ ${recentLines.join("\n")}`;
468
502
  this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
469
503
 
470
504
  if (!this.wsClient.isConnected) {
471
- warn(TAG, `spawn-task ${taskId}: WS disconnected cannot dispatch`);
472
- this.broadcastTaskEvent(taskId, "failed", label, startedAt);
505
+ // No OpenClaw gatewayqueue for bare agent HTTP polling
506
+ this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
507
+ const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
508
+ this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
509
+ this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
510
+ log(TAG, `spawn-task ${taskId}: WS disconnected — queued for bare agent polling`);
473
511
  return;
474
512
  }
475
513
 
@@ -416,6 +416,15 @@ async function main() {
416
416
  return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
417
417
  } catch { return ""; }
418
418
  },
419
+
420
+ // Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
421
+ onSpawnCommand: (text: string) => {
422
+ escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
423
+ log("srv", `spawn via HTTP failed: ${err}`);
424
+ });
425
+ },
426
+ getSpawnPending: () => escalator.getSpawnPending(),
427
+ respondSpawn: (id: string, result: string) => escalator.respondSpawn(id, result),
419
428
  });
420
429
 
421
430
  // ── Wire overlay profiling ──
@@ -435,6 +444,12 @@ async function main() {
435
444
  onUserCommand: (text) => {
436
445
  escalator.setUserCommand(text);
437
446
  },
447
+ onSpawnCommand: (text) => {
448
+ escalator.dispatchSpawnTask(text, "user-command").catch((err) => {
449
+ log("cmd", `spawn command failed: ${err}`);
450
+ wsHandler.broadcast(`\u26a0 Spawn failed: ${String(err).slice(0, 100)}`, "normal");
451
+ });
452
+ },
438
453
  onToggleScreen: () => {
439
454
  screenActive = !screenActive;
440
455
  if (!screenActive) {
@@ -15,6 +15,8 @@ export interface CommandDeps {
15
15
  onUserMessage: (text: string) => Promise<void>;
16
16
  /** Queue a user command to augment the next escalation */
17
17
  onUserCommand: (text: string) => void;
18
+ /** Spawn a background agent task */
19
+ onSpawnCommand?: (text: string) => void;
18
20
  /** Toggle screen capture — returns new state */
19
21
  onToggleScreen: () => boolean;
20
22
  /** Toggle trait voices — returns new enabled state */
@@ -44,6 +46,17 @@ export function setupCommands(deps: CommandDeps): void {
44
46
  deps.onUserCommand(msg.text);
45
47
  break;
46
48
  }
49
+ case "spawn_command": {
50
+ const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
51
+ log(TAG, `spawn command received: "${preview}"`);
52
+ if (deps.onSpawnCommand) {
53
+ deps.onSpawnCommand(msg.text);
54
+ } else {
55
+ log(TAG, `spawn command ignored — no handler configured`);
56
+ wsHandler.broadcast(`⚠ Spawn not available (no agent gateway connected)`, "normal");
57
+ }
58
+ break;
59
+ }
47
60
  case "command": {
48
61
  handleCommand(msg.action, deps);
49
62
  log(TAG, `command processed: ${msg.action}`);
@@ -39,6 +39,9 @@ export interface ServerDeps {
39
39
  respondEscalation?: (id: string, response: string) => any;
40
40
  getKnowledgeDocPath?: () => string | null;
41
41
  queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
42
+ onSpawnCommand?: (text: string) => void;
43
+ getSpawnPending?: () => { id: string; task: string; label: string; ts: number } | null;
44
+ respondSpawn?: (id: string, result: string) => { ok: boolean; error?: string };
42
45
  }
43
46
 
44
47
  function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
@@ -341,6 +344,45 @@ export function createAppServer(deps: ServerDeps) {
341
344
  return;
342
345
  }
343
346
 
347
+ // ── /spawn ──
348
+ if (req.method === "POST" && url.pathname === "/spawn") {
349
+ const body = await readBody(req, 65536);
350
+ const { text, label } = JSON.parse(body);
351
+ if (!text) {
352
+ res.writeHead(400);
353
+ res.end(JSON.stringify({ ok: false, error: "missing text" }));
354
+ return;
355
+ }
356
+ if (deps.onSpawnCommand) {
357
+ deps.onSpawnCommand(text);
358
+ res.end(JSON.stringify({ ok: true, spawned: true }));
359
+ } else {
360
+ res.end(JSON.stringify({ ok: false, error: "spawn not configured" }));
361
+ }
362
+ return;
363
+ }
364
+
365
+ // ── /spawn/pending (bare agent polls for queued tasks) ──
366
+ if (req.method === "GET" && url.pathname === "/spawn/pending") {
367
+ const task = deps.getSpawnPending?.() ?? null;
368
+ res.end(JSON.stringify({ ok: true, task }));
369
+ return;
370
+ }
371
+
372
+ // ── /spawn/respond (bare agent returns task result) ──
373
+ if (req.method === "POST" && url.pathname === "/spawn/respond") {
374
+ const body = await readBody(req, 65536);
375
+ const { id, result } = JSON.parse(body);
376
+ if (!id || !result) {
377
+ res.writeHead(400);
378
+ res.end(JSON.stringify({ ok: false, error: "missing id or result" }));
379
+ return;
380
+ }
381
+ const resp = deps.respondSpawn?.(id, result) ?? { ok: false, error: "spawn not configured" };
382
+ res.end(JSON.stringify(resp));
383
+ return;
384
+ }
385
+
344
386
  res.writeHead(404);
345
387
  res.end(JSON.stringify({ error: "not found" }));
346
388
  } catch (err: any) {
@@ -72,8 +72,14 @@ export interface UserCommandMessage {
72
72
  text: string;
73
73
  }
74
74
 
75
+ /** Overlay → sinain-core: spawn a background agent task */
76
+ export interface SpawnCommandMessage {
77
+ type: "spawn_command";
78
+ text: string;
79
+ }
80
+
75
81
  export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage;
76
- export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage;
82
+ export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage | SpawnCommandMessage;
77
83
 
78
84
  /** Abstraction for user commands (text now, voice later). */
79
85
  export interface UserCommand {
@@ -152,6 +152,24 @@ server.tool(
152
152
  );
153
153
 
154
154
  // 6. sinain_post_feed
155
+ // 6b. sinain_spawn
156
+ server.tool(
157
+ "sinain_spawn",
158
+ "Spawn a background agent task via sinain-core",
159
+ {
160
+ task: z.string(),
161
+ label: z.string().optional().default("background-task"),
162
+ },
163
+ async ({ task, label }) => {
164
+ try {
165
+ const data = await coreRequest("POST", "/spawn", { text: task, label });
166
+ return textResult(JSON.stringify(data, null, 2));
167
+ } catch (err: any) {
168
+ return textResult(`Error spawning task: ${err.message}`);
169
+ }
170
+ },
171
+ );
172
+
155
173
  server.tool(
156
174
  "sinain_post_feed",
157
175
  "Post a message to the sinain-core HUD feed",
@@ -1,92 +0,0 @@
1
- # sinain-core configuration
2
- # Copy to .env and fill in your values: cp .env.example .env
3
-
4
- # ── Server ──
5
- PORT=9500
6
-
7
- # ── System Audio ──
8
- # Default: ScreenCaptureKit (zero-setup, macOS 13+). Fallback: ffmpeg + BlackHole.
9
- AUDIO_CAPTURE_CMD=screencapturekit # screencapturekit | sox | ffmpeg
10
- AUDIO_DEVICE=BlackHole 2ch # macOS audio device (only used by sox/ffmpeg)
11
- AUDIO_SAMPLE_RATE=16000
12
- AUDIO_CHUNK_MS=5000
13
- AUDIO_VAD_ENABLED=true
14
- AUDIO_VAD_THRESHOLD=0.003
15
- AUDIO_AUTO_START=true
16
- AUDIO_GAIN_DB=20
17
-
18
- # ── Microphone (opt-in for privacy) ──
19
- MIC_ENABLED=false # set true to capture user's microphone
20
- MIC_DEVICE=default # "default" = system mic. For specific device: use exact CoreAudio name
21
- MIC_CAPTURE_CMD=sox # sox or ffmpeg (mic uses sox by default)
22
- MIC_SAMPLE_RATE=16000
23
- MIC_CHUNK_MS=5000
24
- MIC_VAD_ENABLED=true
25
- MIC_VAD_THRESHOLD=0.008 # higher threshold (ambient noise)
26
- MIC_AUTO_START=false
27
- MIC_GAIN_DB=0
28
-
29
- # ── Transcription ──
30
- OPENROUTER_API_KEY= # required (unless TRANSCRIPTION_BACKEND=local)
31
- TRANSCRIPTION_BACKEND=openrouter # openrouter | local (local = whisper.cpp on-device)
32
- TRANSCRIPTION_MODEL=google/gemini-2.5-flash
33
- TRANSCRIPTION_LANGUAGE=en-US
34
-
35
- # ── Local Transcription (only when TRANSCRIPTION_BACKEND=local) ──
36
- # Install: brew install whisper-cpp
37
- # Models: https://huggingface.co/ggerganov/whisper.cpp/tree/main
38
- # LOCAL_WHISPER_BIN=whisper-cli
39
- # LOCAL_WHISPER_MODEL=~/models/ggml-large-v3-turbo.bin
40
- # LOCAL_WHISPER_TIMEOUT_MS=15000
41
-
42
- # ── Agent ──
43
- AGENT_ENABLED=true
44
- AGENT_MODEL=google/gemini-2.5-flash-lite
45
- # AGENT_FALLBACK_MODELS=google/gemini-2.5-flash,anthropic/claude-3.5-haiku
46
- AGENT_MAX_TOKENS=300
47
- AGENT_TEMPERATURE=0.3
48
- AGENT_PUSH_TO_FEED=true
49
- AGENT_DEBOUNCE_MS=3000
50
- AGENT_MAX_INTERVAL_MS=30000
51
- AGENT_COOLDOWN_MS=10000
52
- AGENT_MAX_AGE_MS=120000 # context window lookback (2 min)
53
-
54
- # ── Escalation ──
55
- ESCALATION_MODE=selective # off | selective | focus | rich
56
- ESCALATION_COOLDOWN_MS=30000
57
- # ESCALATION_TRANSPORT=auto # ws | http | auto — use http for bare agent (no gateway)
58
- # auto = WS when gateway connected, HTTP fallback
59
- # http = skip gateway entirely, poll via GET /escalation/pending
60
- # See docs/INSTALL-BARE-AGENT.md for bare agent setup
61
-
62
- # ── OpenClaw / NemoClaw Gateway ─────────────────────────────────────────────
63
- # Run ./setup-nemoclaw.sh to fill these in interactively (recommended).
64
- #
65
- # NemoClaw (NVIDIA Brev) quick-start:
66
- # 1. In Brev dashboard: Expose Port(s) → enter 18789 → TCP → note the IP
67
- # 2. In Code-Server terminal: npx sinain (installs plugin, prints token)
68
- # 3. On Mac: ./setup-nemoclaw.sh (interactive wizard)
69
- #
70
- # URL: ws://YOUR-IP:18789 (use the IP shown after exposing port 18789)
71
- # Token: printed by `npx sinain` / visible in Brev dashboard → Gateway Token
72
- OPENCLAW_WS_URL=ws://localhost:18789
73
- OPENCLAW_WS_TOKEN= # 48-char hex — from gateway config or `npx sinain` output
74
- OPENCLAW_HTTP_URL=http://localhost:18789/hooks/agent
75
- OPENCLAW_HTTP_TOKEN= # same token as WS_TOKEN
76
- OPENCLAW_SESSION_KEY=agent:main:sinain # MUST be agent:main:sinain — see README § Session Key
77
- # OPENCLAW_PHASE1_TIMEOUT_MS=10000 # Phase 1 (delivery) timeout — circuit trips on failure
78
- # OPENCLAW_PHASE2_TIMEOUT_MS=120000 # Phase 2 (agent response) timeout — no circuit trip
79
- # OPENCLAW_QUEUE_TTL_MS=300000 # Outbound queue message TTL (5 min)
80
- # OPENCLAW_QUEUE_MAX_SIZE=10 # Max queued escalations (oldest dropped on overflow)
81
- # OPENCLAW_PING_INTERVAL_MS=30000 # WS ping keepalive interval
82
-
83
- # ── SITUATION.md ──
84
- SITUATION_MD_PATH=~/.openclaw/workspace/SITUATION.md
85
- # OPENCLAW_WORKSPACE_DIR=~/.openclaw/workspace
86
-
87
- # ── Debug ──
88
- # DEBUG=true # verbose logging (every tick, every chunk)
89
-
90
- # ── Tracing ──
91
- TRACE_ENABLED=true
92
- TRACE_DIR=~/.sinain-core/traces