@geravant/sinain 1.6.7 → 1.6.9

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/launcher.js CHANGED
@@ -35,11 +35,13 @@ let skipSense = false;
35
35
  let skipOverlay = false;
36
36
  let skipAgent = false;
37
37
  let agentName = null;
38
+ let forceSetup = false;
38
39
 
39
40
  for (const arg of args) {
40
41
  if (arg === "--no-sense") { skipSense = true; continue; }
41
42
  if (arg === "--no-overlay") { skipOverlay = true; continue; }
42
43
  if (arg === "--no-agent") { skipAgent = true; continue; }
44
+ if (arg === "--setup") { forceSetup = true; continue; }
43
45
  if (arg.startsWith("--agent=")) { agentName = arg.split("=")[1]; continue; }
44
46
  console.error(`Unknown flag: ${arg}`);
45
47
  process.exit(1);
@@ -60,15 +62,20 @@ async function main() {
60
62
  await preflight();
61
63
  console.log();
62
64
 
63
- // Run setup wizard on first launch (no ~/.sinain/.env)
65
+ // Run setup wizard on first launch (no ~/.sinain/.env) or when --setup flag is passed
64
66
  const userEnvPath = path.join(SINAIN_DIR, ".env");
65
- if (!fs.existsSync(userEnvPath)) {
67
+ if (forceSetup || !fs.existsSync(userEnvPath)) {
66
68
  await setupWizard(userEnvPath);
67
69
  }
68
70
 
69
71
  // Load user config
70
72
  loadUserEnv();
71
73
 
74
+ // Ensure Ollama is running (if local vision enabled)
75
+ if (process.env.LOCAL_VISION_ENABLED === "true") {
76
+ await ensureOllama();
77
+ }
78
+
72
79
  // Auto-detect transcription backend
73
80
  detectTranscription();
74
81
 
@@ -127,7 +134,10 @@ async function main() {
127
134
  const scDir = path.join(PKG_DIR, "sense_client");
128
135
  // Check if key package is importable to skip pip
129
136
  try {
130
- execSync('python3 -c "import PIL; import skimage"', { stdio: "pipe" });
137
+ const depCheck = IS_WINDOWS
138
+ ? 'python3 -c "import PIL; import skimage"'
139
+ : 'python3 -c "import PIL; import skimage; import Quartz; import Vision"';
140
+ execSync(depCheck, { stdio: "pipe" });
131
141
  } catch {
132
142
  log("Installing sense_client Python dependencies...");
133
143
  try {
@@ -213,7 +223,41 @@ async function main() {
213
223
  warn("flutter not found — overlay source found but can't build");
214
224
  }
215
225
  } else {
216
- warn("overlay not found — run: sinain setup-overlay");
226
+ // Auto-download overlay if not found
227
+ log("overlay not found — downloading from GitHub Releases...");
228
+ try {
229
+ const { downloadOverlay } = await import("./setup-overlay.js");
230
+ const success = await downloadOverlay({ silent: false });
231
+ if (success) {
232
+ // Re-find and launch the freshly downloaded overlay
233
+ const freshOverlay = findOverlay();
234
+ if (freshOverlay?.type === "prebuilt") {
235
+ if (!IS_WINDOWS) {
236
+ try {
237
+ execSync(`xattr -cr "${freshOverlay.path}"`, { stdio: "pipe" });
238
+ } catch { /* no quarantine */ }
239
+ }
240
+ log("Starting overlay (pre-built)...");
241
+ const binary = IS_WINDOWS
242
+ ? freshOverlay.path
243
+ : path.join(freshOverlay.path, "Contents/MacOS/sinain_hud");
244
+ startProcess("overlay", binary, [], { color: MAGENTA });
245
+ await sleep(2000);
246
+ const overlayChild = children.find(c => c.name === "overlay");
247
+ if (overlayChild && !overlayChild.proc.killed && overlayChild.proc.exitCode === null) {
248
+ ok(`overlay running (pid:${overlayChild.pid})`);
249
+ overlayStatus = "running";
250
+ } else {
251
+ warn("overlay exited early — check logs above");
252
+ overlayStatus = "failed";
253
+ }
254
+ }
255
+ } else {
256
+ warn("overlay auto-download failed — run: sinain setup-overlay");
257
+ }
258
+ } catch (e) {
259
+ warn(`overlay auto-download failed: ${e.message}`);
260
+ }
217
261
  }
218
262
  }
219
263
 
@@ -309,25 +353,38 @@ async function preflight() {
309
353
  ok("port 9500 free");
310
354
  }
311
355
 
312
- // Ollama (if local vision enabled)
313
- if (process.env.LOCAL_VISION_ENABLED === "true") {
314
- try {
315
- const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
316
- if (resp.ok) {
317
- ok("ollama server running");
318
- } else {
319
- warn("ollama server not responding — local vision will be unavailable");
320
- }
321
- } catch {
322
- // Try to start Ollama in background
356
+ }
357
+
358
+ async function ensureOllama() {
359
+ try {
360
+ const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(2000) });
361
+ if (resp.ok) {
362
+ ok("ollama server running");
363
+ return true;
364
+ }
365
+ } catch { /* not running */ }
366
+
367
+ // Try to start Ollama in background
368
+ log("Starting ollama server...");
369
+ try {
370
+ const { spawn: spawnProc } = await import("child_process");
371
+ spawnProc("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref();
372
+ // Wait for it to become ready
373
+ for (let i = 0; i < 10; i++) {
374
+ await sleep(500);
323
375
  try {
324
- const { spawn: spawnProc } = await import("child_process");
325
- spawnProc("ollama", ["serve"], { detached: true, stdio: "ignore" }).unref();
326
- ok("ollama server started in background");
327
- } catch {
328
- warn("ollama not running and could not auto-start — local vision disabled");
329
- }
376
+ const resp = await fetch("http://localhost:11434/api/tags", { signal: AbortSignal.timeout(1000) });
377
+ if (resp.ok) {
378
+ ok("ollama server started");
379
+ return true;
380
+ }
381
+ } catch { /* not ready yet */ }
330
382
  }
383
+ warn("ollama started but not responding — local vision may not work");
384
+ return false;
385
+ } catch {
386
+ warn("ollama not found — local vision disabled. Install: brew install ollama");
387
+ return false;
331
388
  }
332
389
  }
333
390
 
@@ -337,9 +394,20 @@ async function setupWizard(envPath) {
337
394
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
338
395
  const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
339
396
 
397
+ // Load existing .env values as defaults (for re-configuration)
398
+ const existing = {};
399
+ if (fs.existsSync(envPath)) {
400
+ for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
401
+ const m = line.match(/^([A-Z_]+)=(.*)$/);
402
+ if (m) existing[m[1]] = m[2];
403
+ }
404
+ }
405
+ const hasExisting = Object.keys(existing).length > 0;
406
+
340
407
  console.log();
341
- console.log(`${BOLD}── First-time setup ────────────────────${RESET}`);
408
+ console.log(`${BOLD}── ${hasExisting ? "Re-configure" : "First-time setup"} ────────────────────${RESET}`);
342
409
  console.log(` Configuring ${DIM}~/.sinain/.env${RESET}`);
410
+ if (hasExisting) console.log(` ${DIM}Press Enter to keep current values shown in [brackets]${RESET}`);
343
411
  console.log();
344
412
 
345
413
  const vars = {};
@@ -391,10 +459,13 @@ async function setupWizard(envPath) {
391
459
 
392
460
  // 2. OpenRouter API key (if cloud backend or for vision/OCR)
393
461
  if (transcriptionBackend === "openrouter") {
462
+ const existingKey = existing.OPENROUTER_API_KEY;
463
+ const keyHint = existingKey ? ` [${existingKey.slice(0, 8)}...${existingKey.slice(-4)}]` : "";
394
464
  let key = "";
395
465
  while (!key) {
396
- key = await ask(` OpenRouter API key (sk-or-...): `);
466
+ key = await ask(` OpenRouter API key (sk-or-...)${keyHint}: `);
397
467
  key = key.trim();
468
+ if (!key && existingKey) { key = existingKey; break; }
398
469
  if (key && !key.startsWith("sk-or-")) {
399
470
  console.log(` ${YELLOW}⚠${RESET} Key should start with sk-or-. Try again or press Enter to skip.`);
400
471
  const retry = await ask(` Use this key anyway? [y/N]: `);
@@ -408,13 +479,17 @@ async function setupWizard(envPath) {
408
479
  if (key) vars.OPENROUTER_API_KEY = key;
409
480
  } else {
410
481
  // Still ask for OpenRouter key (needed for vision/OCR)
411
- const key = await ask(` OpenRouter API key for vision/OCR (optional, Enter to skip): `);
482
+ const existingKey = existing.OPENROUTER_API_KEY;
483
+ const keyHint = existingKey ? ` [${existingKey.slice(0, 8)}...${existingKey.slice(-4)}]` : "";
484
+ const key = await ask(` OpenRouter API key for vision/OCR (optional, Enter to skip)${keyHint}: `);
412
485
  if (key.trim()) vars.OPENROUTER_API_KEY = key.trim();
486
+ else if (existingKey) vars.OPENROUTER_API_KEY = existingKey;
413
487
  }
414
488
 
415
489
  // 3. Agent selection
416
- const agentChoice = await ask(` Agent? [${BOLD}claude${RESET}/codex/goose/junie/aider]: `);
417
- vars.SINAIN_AGENT = agentChoice.trim().toLowerCase() || "claude";
490
+ const defaultAgent = existing.SINAIN_AGENT || "claude";
491
+ const agentChoice = await ask(` Agent? [${BOLD}${defaultAgent}${RESET}/claude/codex/goose/junie/aider]: `);
492
+ vars.SINAIN_AGENT = agentChoice.trim().toLowerCase() || defaultAgent;
418
493
 
419
494
  // 3b. Local vision (Ollama)
420
495
  const IS_MACOS = os.platform() === "darwin";
@@ -423,20 +498,24 @@ async function setupWizard(envPath) {
423
498
  const useVision = await ask(` Enable local vision AI? [Y/n] (Ollama — screen understanding without cloud API): `);
424
499
  if (!useVision.trim() || useVision.trim().toLowerCase() === "y") {
425
500
  vars.LOCAL_VISION_ENABLED = "true";
426
- try {
427
- const models = execSync("ollama list 2>/dev/null", { encoding: "utf-8" });
428
- if (!models.includes("llava")) {
429
- const pull = await ask(` Pull llava vision model (~4GB)? [Y/n]: `);
430
- if (!pull.trim() || pull.trim().toLowerCase() === "y") {
431
- console.log(` ${DIM}Pulling llava...${RESET}`);
432
- execSync("ollama pull llava", { stdio: "inherit" });
433
- ok("llava model pulled");
501
+ // Ensure ollama serve is running before list/pull
502
+ const ollamaReady = await ensureOllama();
503
+ if (ollamaReady) {
504
+ try {
505
+ const models = execSync("ollama list 2>/dev/null", { encoding: "utf-8" });
506
+ if (!models.includes("llava")) {
507
+ const pull = await ask(` Pull llava vision model (~4GB)? [Y/n]: `);
508
+ if (!pull.trim() || pull.trim().toLowerCase() === "y") {
509
+ console.log(` ${DIM}Pulling llava...${RESET}`);
510
+ execSync("ollama pull llava", { stdio: "inherit" });
511
+ ok("llava model pulled");
512
+ }
513
+ } else {
514
+ ok("llava model already available");
434
515
  }
435
- } else {
436
- ok("llava model already available");
516
+ } catch {
517
+ warn("Could not check Ollama models");
437
518
  }
438
- } catch {
439
- warn("Could not check Ollama models");
440
519
  }
441
520
  vars.LOCAL_VISION_MODEL = "llava";
442
521
  }
@@ -451,6 +530,8 @@ async function setupWizard(envPath) {
451
530
  console.log(` ${DIM}Installing Ollama...${RESET}`);
452
531
  execSync("curl -fsSL https://ollama.com/install.sh | sh", { stdio: "inherit" });
453
532
  }
533
+ // Start ollama serve before pulling
534
+ await ensureOllama();
454
535
  console.log(` ${DIM}Pulling llava vision model...${RESET}`);
455
536
  execSync("ollama pull llava", { stdio: "inherit" });
456
537
  vars.LOCAL_VISION_ENABLED = "true";
@@ -469,25 +550,37 @@ async function setupWizard(envPath) {
469
550
  console.log(` selective — score-based (errors, questions trigger it)`);
470
551
  console.log(` focus — always escalate every tick`);
471
552
  console.log(` rich — always escalate with maximum context`);
472
- const escMode = await ask(` Escalation mode? [off/${BOLD}selective${RESET}/focus/rich]: `);
473
- vars.ESCALATION_MODE = escMode.trim().toLowerCase() || "selective";
553
+ const defaultEsc = existing.ESCALATION_MODE || "selective";
554
+ const escMode = await ask(` Escalation mode? [off/${BOLD}${defaultEsc}${RESET}/selective/focus/rich]: `);
555
+ vars.ESCALATION_MODE = escMode.trim().toLowerCase() || defaultEsc;
474
556
 
475
557
  // 5. OpenClaw gateway
476
- const hasGateway = await ask(` Do you have an OpenClaw gateway? [y/N]: `);
477
- if (hasGateway.trim().toLowerCase() === "y") {
478
- const wsUrl = await ask(` Gateway WebSocket URL [ws://localhost:18789]: `);
479
- vars.OPENCLAW_WS_URL = wsUrl.trim() || "ws://localhost:18789";
480
-
481
- const wsToken = await ask(` Gateway auth token (48-char hex): `);
558
+ const hadGateway = !!(existing.OPENCLAW_WS_URL);
559
+ const gatewayDefault = hadGateway ? "Y" : "N";
560
+ const hasGateway = await ask(` Do you have an OpenClaw gateway? [${gatewayDefault === "Y" ? "Y/n" : "y/N"}]: `);
561
+ const wantsGateway = hasGateway.trim()
562
+ ? hasGateway.trim().toLowerCase() === "y"
563
+ : hadGateway;
564
+ if (wantsGateway) {
565
+ const defaultWs = existing.OPENCLAW_WS_URL || "ws://localhost:18789";
566
+ const wsUrl = await ask(` Gateway WebSocket URL [${defaultWs}]: `);
567
+ vars.OPENCLAW_WS_URL = wsUrl.trim() || defaultWs;
568
+
569
+ const existingToken = existing.OPENCLAW_WS_TOKEN;
570
+ const tokenHint = existingToken ? ` [${existingToken.slice(0, 6)}...${existingToken.slice(-4)}]` : "";
571
+ const wsToken = await ask(` Gateway auth token (48-char hex)${tokenHint}: `);
482
572
  if (wsToken.trim()) {
483
573
  vars.OPENCLAW_WS_TOKEN = wsToken.trim();
484
574
  vars.OPENCLAW_HTTP_TOKEN = wsToken.trim();
575
+ } else if (existingToken) {
576
+ vars.OPENCLAW_WS_TOKEN = existingToken;
577
+ vars.OPENCLAW_HTTP_TOKEN = existing.OPENCLAW_HTTP_TOKEN || existingToken;
485
578
  }
486
579
 
487
580
  // Derive HTTP URL from WS URL
488
581
  const httpBase = vars.OPENCLAW_WS_URL.replace(/^ws/, "http");
489
582
  vars.OPENCLAW_HTTP_URL = `${httpBase}/hooks/agent`;
490
- vars.OPENCLAW_SESSION_KEY = "agent:main:sinain";
583
+ vars.OPENCLAW_SESSION_KEY = existing.OPENCLAW_SESSION_KEY || "agent:main:sinain";
491
584
  } else {
492
585
  // No gateway — disable WS connection attempts
493
586
  vars.OPENCLAW_WS_URL = "";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.6.7",
4
- "description": "Ambient AI overlay invisible to screen capture real-time insights from audio + screen context",
3
+ "version": "1.6.9",
4
+ "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "sinain": "./cli.js",
@@ -12,6 +12,7 @@ if sys.platform == "win32":
12
12
 
13
13
  import argparse
14
14
  import concurrent.futures
15
+ import copy
15
16
  import json
16
17
  import os
17
18
  import time
@@ -28,7 +29,7 @@ from .capture import ScreenCapture, create_capture
28
29
  from .change_detector import ChangeDetector
29
30
  from .roi_extractor import ROIExtractor
30
31
  from .ocr import OCRResult, create_ocr
31
- from .gate import DecisionGate, SenseObservation
32
+ from .gate import DecisionGate, SenseEvent, SenseObservation, SenseMeta
32
33
  from .sender import SenseSender, package_full_frame, package_roi
33
34
  from .app_detector import AppDetector
34
35
  from .config import load_config
@@ -128,6 +129,7 @@ def main():
128
129
  )
129
130
  app_detector = AppDetector()
130
131
  ocr_pool = concurrent.futures.ThreadPoolExecutor(max_workers=4)
132
+ vision_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="vision")
131
133
 
132
134
  # Vision provider — routes to Ollama (local) or OpenRouter (cloud) based on config/privacy
133
135
  vision_cfg = config.get("vision", {})
@@ -196,13 +198,16 @@ def main():
196
198
  time.sleep(1)
197
199
  continue
198
200
 
199
- # First-frame log
201
+ # First-frame log + force initial context event
202
+ _is_first_frame = not _logged_first_frame
200
203
  if not _logged_first_frame:
201
204
  log(f"first frame: {frame.size[0]}x{frame.size[1]} (scale={config['capture']['scale']})")
202
205
  _logged_first_frame = True
203
206
 
204
- # 1. Check app/window change
207
+ # 1. Check app/window change (first frame always treated as app change)
205
208
  app_changed, window_changed, app_name, window_title = app_detector.detect_change()
209
+ if _is_first_frame:
210
+ app_changed = True # force context event on startup
206
211
 
207
212
  # Adaptive SSIM threshold
208
213
  now_sec = time.time()
@@ -355,18 +360,28 @@ def main():
355
360
  title=title, subtitle=subtitle, facts=facts,
356
361
  )
357
362
 
358
- # Vision scene analysis (throttled, non-blocking on failure)
363
+ # Vision scene analysis — async: send text event immediately, vision follows
359
364
  if vision_provider and time.time() - last_vision_time >= vision_throttle_s:
360
- try:
361
- from PIL import Image as PILImage
362
- pil_frame = PILImage.fromarray(use_frame) if isinstance(use_frame, np.ndarray) else use_frame
363
- scene = vision_provider.describe(pil_frame, prompt=vision_prompt or None)
364
- if scene:
365
- event.observation.scene = scene
366
- last_vision_time = time.time()
367
- log(f"vision: {scene[:80]}...")
368
- except Exception as e:
369
- log(f"vision error: {e}")
365
+ last_vision_time = time.time() # claim slot immediately to prevent concurrent calls
366
+ _v_frame = use_frame.copy() if isinstance(use_frame, np.ndarray) else use_frame.copy()
367
+ _v_meta = copy.copy(event.meta)
368
+ _v_ts = event.ts
369
+ _v_prompt = vision_prompt
370
+ def _do_vision(frame, meta, ts, prompt):
371
+ try:
372
+ from PIL import Image as PILImage
373
+ pil = PILImage.fromarray(frame) if isinstance(frame, np.ndarray) else frame
374
+ scene = vision_provider.describe(pil, prompt=prompt or None)
375
+ if scene:
376
+ log(f"vision: {scene[:80]}...")
377
+ ctx_ev = SenseEvent(type="context", ts=ts)
378
+ ctx_ev.observation = SenseObservation(scene=scene)
379
+ ctx_ev.meta = meta
380
+ ctx_ev.roi = package_full_frame(frame)
381
+ sender.send(ctx_ev)
382
+ except Exception as e:
383
+ log(f"vision error: {e}")
384
+ vision_pool.submit(_do_vision, _v_frame, _v_meta, _v_ts, _v_prompt)
370
385
 
371
386
  # Send small thumbnail for ALL event types (agent uses vision)
372
387
  # Privacy matrix: gate image sending based on PRIVACY_IMAGES_OPENROUTER
@@ -4,6 +4,7 @@ numpy>=1.24
4
4
  pytesseract>=0.3
5
5
  requests>=2.31
6
6
  pyobjc-framework-Quartz>=10.0; sys_platform == "darwin"
7
+ pyobjc-framework-Vision>=10.0; sys_platform == "darwin"
7
8
  mss>=9.0; sys_platform == "win32"
8
9
  psutil>=5.9; sys_platform == "win32"
9
10
  winrt-Windows.Media.Ocr>=2.0; sys_platform == "win32"
package/setup-overlay.js CHANGED
@@ -37,25 +37,36 @@ function ok(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${GREEN}✓${RE
37
37
  function warn(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${YELLOW}⚠${RESET} ${msg}`); }
38
38
  function fail(msg) { console.error(`${BOLD}[setup-overlay]${RESET} ${RED}✗${RESET} ${msg}`); process.exit(1); }
39
39
 
40
- // ── Parse flags ──────────────────────────────────────────────────────────────
40
+ // ── Entry point (only when run directly, not when imported) ──────────────────
41
41
 
42
- const args = process.argv.slice(2);
43
- const fromSource = args.includes("--from-source");
44
- const forceUpdate = args.includes("--update");
42
+ const isMain = process.argv[1] && (
43
+ import.meta.url === `file://${process.argv[1]}` ||
44
+ import.meta.url === new URL(process.argv[1], "file://").href
45
+ );
45
46
 
46
- if (fromSource) {
47
- await buildFromSource();
48
- } else {
49
- await downloadPrebuilt();
47
+ if (isMain) {
48
+ const args = process.argv.slice(2);
49
+ const fromSource = args.includes("--from-source");
50
+ const forceUpdate = args.includes("--update");
51
+
52
+ if (fromSource) {
53
+ await buildFromSource();
54
+ } else {
55
+ await downloadOverlay({ forceUpdate });
56
+ }
50
57
  }
51
58
 
52
59
  // ── Download pre-built .app ──────────────────────────────────────────────────
53
60
 
54
- async function downloadPrebuilt() {
61
+ export async function downloadOverlay({ silent = false, forceUpdate = false } = {}) {
62
+ const _log = silent ? () => {} : log;
63
+ const _ok = silent ? () => {} : ok;
64
+ const _warn = silent ? () => {} : warn;
65
+
55
66
  fs.mkdirSync(APP_DIR, { recursive: true });
56
67
 
57
68
  // Find latest overlay release
58
- log("Checking for latest overlay release...");
69
+ _log("Checking for latest overlay release...");
59
70
  let release;
60
71
  try {
61
72
  const res = await fetch(`${RELEASES_API}?per_page=20`, {
@@ -67,6 +78,7 @@ async function downloadPrebuilt() {
67
78
  release = releases.find(r => r.tag_name?.startsWith("overlay-v"));
68
79
  if (!release) throw new Error("No overlay release found");
69
80
  } catch (e) {
81
+ if (silent) return false;
70
82
  fail(`Failed to fetch releases: ${e.message}\n Try: sinain setup-overlay --from-source`);
71
83
  }
72
84
 
@@ -78,21 +90,22 @@ async function downloadPrebuilt() {
78
90
  try {
79
91
  const local = JSON.parse(fs.readFileSync(VERSION_FILE, "utf-8"));
80
92
  if (local.tag === tag) {
81
- ok(`Overlay already up-to-date (${version})`);
82
- return;
93
+ _ok(`Overlay already up-to-date (${version})`);
94
+ return true;
83
95
  }
84
- log(`Updating: ${local.tag} → ${tag}`);
96
+ _log(`Updating: ${local.tag} → ${tag}`);
85
97
  } catch { /* corrupt version file — re-download */ }
86
98
  }
87
99
 
88
100
  // Find the .zip asset for this platform
89
101
  const zipAsset = release.assets?.find(a => a.name === ASSET_NAME);
90
102
  if (!zipAsset) {
103
+ if (silent) return false;
91
104
  fail(`Release ${tag} has no ${ASSET_NAME} asset.\n Try: sinain setup-overlay --from-source`);
92
105
  }
93
106
 
94
107
  // Download with progress
95
- log(`Downloading overlay ${version} for ${IS_WINDOWS ? "Windows" : "macOS"} (${formatBytes(zipAsset.size)})...`);
108
+ _log(`Downloading overlay ${version} for ${IS_WINDOWS ? "Windows" : "macOS"} (${formatBytes(zipAsset.size)})...`);
96
109
  const zipPath = path.join(APP_DIR, ASSET_NAME);
97
110
 
98
111
  try {
@@ -112,17 +125,18 @@ async function downloadPrebuilt() {
112
125
  if (done) break;
113
126
  chunks.push(value);
114
127
  downloaded += value.length;
115
- if (total > 0) {
128
+ if (total > 0 && !silent) {
116
129
  const pct = Math.round((downloaded / total) * 100);
117
130
  process.stdout.write(`\r${BOLD}[setup-overlay]${RESET} ${DIM}${pct}% (${formatBytes(downloaded)} / ${formatBytes(total)})${RESET}`);
118
131
  }
119
132
  }
120
- process.stdout.write("\n");
133
+ if (!silent) process.stdout.write("\n");
121
134
 
122
135
  const buffer = Buffer.concat(chunks);
123
136
  fs.writeFileSync(zipPath, buffer);
124
- ok(`Downloaded ${formatBytes(buffer.length)}`);
137
+ _ok(`Downloaded ${formatBytes(buffer.length)}`);
125
138
  } catch (e) {
139
+ if (silent) return false;
126
140
  fail(`Download failed: ${e.message}`);
127
141
  }
128
142
 
@@ -132,7 +146,7 @@ async function downloadPrebuilt() {
132
146
  }
133
147
 
134
148
  // Extract
135
- log("Extracting...");
149
+ _log("Extracting...");
136
150
  if (IS_WINDOWS) {
137
151
  try {
138
152
  execSync(
@@ -140,6 +154,7 @@ async function downloadPrebuilt() {
140
154
  { stdio: "pipe" }
141
155
  );
142
156
  } catch (e) {
157
+ if (silent) return false;
143
158
  fail(`Extraction failed: ${e.message}`);
144
159
  }
145
160
  } else {
@@ -150,6 +165,7 @@ async function downloadPrebuilt() {
150
165
  try {
151
166
  execSync(`unzip -o -q "${zipPath}" -d "${APP_DIR}"`, { stdio: "pipe" });
152
167
  } catch (e) {
168
+ if (silent) return false;
153
169
  fail(`Extraction failed: ${e.message}`);
154
170
  }
155
171
  }
@@ -170,12 +186,15 @@ async function downloadPrebuilt() {
170
186
  // Clean up zip
171
187
  fs.unlinkSync(zipPath);
172
188
 
173
- ok(`Overlay ${version} installed`);
174
- console.log(`
189
+ _ok(`Overlay ${version} installed`);
190
+ if (!silent) {
191
+ console.log(`
175
192
  ${GREEN}✓${RESET} Overlay ready!
176
193
  Location: ${APP_PATH}
177
194
  The overlay will auto-start with: ${BOLD}sinain start${RESET}
178
195
  `);
196
+ }
197
+ return true;
179
198
  }
180
199
 
181
200
  // ── Build from source (legacy) ───────────────────────────────────────────────
@@ -49,20 +49,20 @@ invoke_agent() {
49
49
  local prompt="$1"
50
50
  case "$AGENT" in
51
51
  claude)
52
- claude --dangerously-skip-permissions \
52
+ claude --enable-auto-mode \
53
53
  --mcp-config "$MCP_CONFIG" \
54
54
  --max-turns 5 --output-format text \
55
- -p "$prompt" 2>/dev/null
55
+ -p "$prompt"
56
56
  ;;
57
57
  codex)
58
58
  codex exec -s danger-full-access \
59
- "$prompt" 2>/dev/null
59
+ "$prompt"
60
60
  ;;
61
61
  junie)
62
62
  if $JUNIE_HAS_MCP; then
63
63
  junie --output-format text \
64
64
  --mcp-location "$JUNIE_MCP_DIR" \
65
- --task "$prompt" 2>/dev/null
65
+ --task "$prompt"
66
66
  else
67
67
  return 1
68
68
  fi
@@ -70,7 +70,7 @@ invoke_agent() {
70
70
  goose)
71
71
  goose run --text "$prompt" \
72
72
  --output-format text \
73
- --max-turns 10 2>/dev/null
73
+ --max-turns 10
74
74
  ;;
75
75
  aider)
76
76
  # No MCP support — signal pipe mode
@@ -104,10 +104,10 @@ invoke_pipe() {
104
104
  local msg="$1"
105
105
  case "$AGENT" in
106
106
  junie)
107
- junie --output-format text --task "$msg" 2>/dev/null
107
+ junie --output-format text --task "$msg"
108
108
  ;;
109
109
  aider)
110
- aider --yes -m "$msg" 2>/dev/null
110
+ aider --yes -m "$msg"
111
111
  ;;
112
112
  *)
113
113
  # Generic: pipe message to stdin
@@ -131,7 +131,9 @@ if [ "$AGENT" = "junie" ]; then
131
131
  if junie --help 2>&1 | grep -q "mcp-location"; then
132
132
  JUNIE_HAS_MCP=true
133
133
  mkdir -p "$JUNIE_MCP_DIR"
134
- cp "$MCP_CONFIG" "$JUNIE_MCP_DIR/mcp.json"
134
+ # Junie expects relative paths from the config file location.
135
+ # Since we moved the config into a sub-directory, we need to adjust ../ to ../../
136
+ sed 's|"\.\./|"../../|g' "$MCP_CONFIG" > "$JUNIE_MCP_DIR/mcp.json"
135
137
  else
136
138
  echo "NOTE: junie $(junie --version 2>&1 | grep -oE '[0-9.]+' | head -1) lacks --mcp-location, using pipe mode"
137
139
  echo " Upgrade junie for MCP support: brew upgrade junie"
@@ -1,9 +1,10 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import fs from "node:fs";
2
3
  import type { FeedBuffer } from "../buffers/feed-buffer.js";
3
4
  import type { SenseBuffer } from "../buffers/sense-buffer.js";
4
- import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus } from "../types.js";
5
+ import type { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
5
6
  import type { Profiler } from "../profiler.js";
6
- import { buildContextWindow } from "./context-window.js";
7
+ import { buildContextWindow, RICHNESS_PRESETS } from "./context-window.js";
7
8
  import { analyzeContext } from "./analyzer.js";
8
9
  import { writeSituationMd } from "./situation-writer.js";
9
10
  import { calculateEscalationScore } from "../escalation/scorer.js";
@@ -35,6 +36,10 @@ export interface AgentLoopDeps {
35
36
  traitEngine?: TraitEngine;
36
37
  /** Directory to write per-day trait log JSONL files. */
37
38
  traitLogDir?: string;
39
+ /** Optional: path to sinain-knowledge.md for startup recap. */
40
+ getKnowledgeDocPath?: () => string | null;
41
+ /** Optional: feedback store for startup recap context. */
42
+ feedbackStore?: { queryRecent(n: number): FeedbackRecord[] };
38
43
  }
39
44
 
40
45
  export interface TraceContext {
@@ -69,6 +74,7 @@ export class AgentLoop extends EventEmitter {
69
74
  private lastRunTs = 0;
70
75
  private running = false;
71
76
  private started = false;
77
+ private firstTick = true;
72
78
 
73
79
  private lastPushedHud = "";
74
80
  private agentNextId = 1;
@@ -112,6 +118,9 @@ export class AgentLoop extends EventEmitter {
112
118
  }, this.deps.agentConfig.maxIntervalMs);
113
119
 
114
120
  log(TAG, `loop started (debounce=${this.deps.agentConfig.debounceMs}ms, max=${this.deps.agentConfig.maxIntervalMs}ms, cooldown=${this.deps.agentConfig.cooldownMs}ms, model=${this.deps.agentConfig.model})`);
121
+
122
+ // Fire recap tick: immediate HUD from persistent knowledge (no sense data needed)
123
+ this.fireRecapTick().catch(e => debug(TAG, "recap skipped:", String(e)));
115
124
  }
116
125
 
117
126
  /** Stop the agent loop. */
@@ -131,12 +140,13 @@ export class AgentLoop extends EventEmitter {
131
140
  onNewContext(): void {
132
141
  if (!this.started) return;
133
142
 
134
- // Debounce: wait N ms after last event before running
143
+ // Fast first tick: 500ms debounce on startup, normal debounce after
144
+ const delay = this.firstTick ? 500 : this.deps.agentConfig.debounceMs;
135
145
  if (this.debounceTimer) clearTimeout(this.debounceTimer);
136
146
  this.debounceTimer = setTimeout(() => {
137
147
  this.debounceTimer = null;
138
148
  this.run().catch(err => error(TAG, "debounce tick error:", err.message));
139
- }, this.deps.agentConfig.debounceMs);
149
+ }, delay);
140
150
  }
141
151
 
142
152
  /** Get agent results history (newest first). */
@@ -398,7 +408,80 @@ export class AgentLoop extends EventEmitter {
398
408
  traceCtx?.finish({ totalLatencyMs: Date.now() - Date.now(), llmLatencyMs: 0, llmInputTokens: 0, llmOutputTokens: 0, llmCost: 0, escalated: false, escalationScore: 0, contextScreenEvents: 0, contextAudioEntries: 0, contextRichness: richness, digestLength: 0, hudChanged: false });
399
409
  } finally {
400
410
  this.running = false;
411
+ this.firstTick = false;
401
412
  this.lastRunTs = Date.now();
402
413
  }
403
414
  }
415
+
416
+ // ── Private: startup recap tick from persistent knowledge ──
417
+
418
+ private async fireRecapTick(): Promise<void> {
419
+ if (this.running) return;
420
+ this.running = true;
421
+
422
+ try {
423
+ const sections: string[] = [];
424
+ const startTs = Date.now();
425
+
426
+ // 1. sinain-knowledge.md (established patterns, user preferences)
427
+ const knowledgePath = this.deps.getKnowledgeDocPath?.();
428
+ if (knowledgePath) {
429
+ const content = await fs.promises.readFile(knowledgePath, "utf-8").catch(() => "");
430
+ if (content.length > 50) sections.push(content.slice(0, 2000));
431
+ }
432
+
433
+ // 2. SITUATION.md digest (if fresh — less than 5 minutes old)
434
+ try {
435
+ const stat = await fs.promises.stat(this.deps.situationMdPath);
436
+ if (Date.now() - stat.mtimeMs < 5 * 60_000) {
437
+ const sit = await fs.promises.readFile(this.deps.situationMdPath, "utf-8");
438
+ const digestMatch = sit.match(/## Digest\n([\s\S]*?)(?=\n##|$)/);
439
+ if (digestMatch?.[1]?.trim()) {
440
+ sections.push(`Last session digest:\n${digestMatch[1].trim()}`);
441
+ }
442
+ }
443
+ } catch { /* SITUATION.md missing — fine */ }
444
+
445
+ // 3. Recent feedback records (last 5 escalation summaries)
446
+ const records = this.deps.feedbackStore?.queryRecent(5) ?? [];
447
+ if (records.length > 0) {
448
+ const recaps = records.slice(0, 5).map(r => `- ${r.currentApp}: ${r.hud}`).join("\n");
449
+ sections.push(`Recent activity:\n${recaps}`);
450
+ }
451
+
452
+ if (sections.length === 0) { return; }
453
+
454
+ const recapContext = sections.join("\n\n");
455
+
456
+ // Build synthetic ContextWindow with knowledge as screen entry
457
+ const recapWindow: ContextWindow = {
458
+ audio: [],
459
+ screen: [{
460
+ ts: Date.now(),
461
+ ocr: recapContext,
462
+ meta: { app: "sinain-recap", windowTitle: "startup" },
463
+ type: "context",
464
+ } as unknown as SenseEvent],
465
+ images: [],
466
+ currentApp: "sinain-recap",
467
+ appHistory: [],
468
+ audioCount: 0,
469
+ screenCount: 1,
470
+ windowMs: 0,
471
+ newestEventTs: Date.now(),
472
+ preset: RICHNESS_PRESETS.lean,
473
+ };
474
+
475
+ const result = await analyzeContext(recapWindow, this.deps.agentConfig, null);
476
+ if (result?.hud && result.hud !== "—" && result.hud !== "Idle") {
477
+ this.deps.onHudUpdate(result.hud);
478
+ log(TAG, `recap tick (${Date.now() - startTs}ms, ${result.tokensIn}in+${result.tokensOut}out tok) hud="${result.hud}"`);
479
+ }
480
+ } catch (err: any) {
481
+ debug(TAG, "recap tick error:", err.message || err);
482
+ } finally {
483
+ this.running = false;
484
+ // Do NOT update lastRunTs — normal cooldown should not be affected by recap
485
+ }
486
+ }
404
487
  }
@@ -7,6 +7,9 @@ import { PRESETS } from "./privacy/presets.js";
7
7
 
8
8
  const __dirname = dirname(fileURLToPath(import.meta.url));
9
9
 
10
+ /** The .env file path that was actually loaded (if any). */
11
+ export let loadedEnvPath: string | undefined;
12
+
10
13
  function loadDotEnv(): void {
11
14
  // Try sinain-core/.env first, then project root .env
12
15
  const candidates = [
@@ -35,6 +38,7 @@ function loadDotEnv(): void {
35
38
  process.env[key] = val;
36
39
  }
37
40
  }
41
+ loadedEnvPath = envPath;
38
42
  console.log(`[config] loaded ${envPath}`);
39
43
  return;
40
44
  } catch { /* ignore */ }
@@ -52,7 +52,7 @@ export class Escalator {
52
52
  private slot: EscalationSlot;
53
53
  private httpPending: HttpPendingEscalation | null = null;
54
54
 
55
- private lastEscalationTs = Date.now();
55
+ private lastEscalationTs = 0;
56
56
  private lastEscalatedDigest = "";
57
57
 
58
58
  // Spawn deduplication state
@@ -25,6 +25,12 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
25
25
 
26
26
  const TAG = "core";
27
27
 
28
+ /** Resolve workspace path, expanding leading ~ to HOME. */
29
+ function resolveWorkspace(): string {
30
+ const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
31
+ return raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
32
+ }
33
+
28
34
  async function main() {
29
35
  log(TAG, "sinain-core starting...");
30
36
 
@@ -80,7 +86,7 @@ async function main() {
80
86
  profiler,
81
87
  feedbackStore: feedbackStore ?? undefined,
82
88
  queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
83
- const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
89
+ const workspace = resolveWorkspace();
84
90
  const dbPath = `${workspace}/memory/knowledge-graph.db`;
85
91
  const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
86
92
  try {
@@ -139,6 +145,7 @@ async function main() {
139
145
  escalator.pushSituationMd(content);
140
146
  },
141
147
  onHudUpdate: (text) => {
148
+ wsHandler.broadcastRaw({ type: "thinking", active: false } as any);
142
149
  wsHandler.broadcast(text, "normal", "stream");
143
150
  },
144
151
  onTraceStart: tracer ? (tickId) => {
@@ -156,6 +163,13 @@ async function main() {
156
163
  } : undefined,
157
164
  traitEngine,
158
165
  traitLogDir: config.traitConfig.logDir,
166
+ getKnowledgeDocPath: () => {
167
+ const workspace = resolveWorkspace();
168
+ const p = `${workspace}/memory/sinain-knowledge.md`;
169
+ try { if (existsSync(p)) return p; } catch {}
170
+ return null;
171
+ },
172
+ feedbackStore: feedbackStore ?? undefined,
159
173
  });
160
174
 
161
175
  // ── Wire learning signal collector (needs agentLoop) ──
@@ -400,13 +414,13 @@ async function main() {
400
414
 
401
415
  // Knowledge graph integration
402
416
  getKnowledgeDocPath: () => {
403
- const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
417
+ const workspace = resolveWorkspace();
404
418
  const p = `${workspace}/memory/sinain-knowledge.md`;
405
419
  try { if (existsSync(p)) return p; } catch {}
406
420
  return null;
407
421
  },
408
422
  queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
409
- const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
423
+ const workspace = resolveWorkspace();
410
424
  const dbPath = `${workspace}/memory/knowledge-graph.db`;
411
425
  const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
412
426
  try {
@@ -43,6 +43,17 @@ export function setupCommands(deps: CommandDeps): void {
43
43
  }
44
44
  case "user_command": {
45
45
  log(TAG, `user command received: "${msg.text.slice(0, 60)}"`);
46
+ // Echo user message to all overlay clients as a feed item
47
+ wsHandler.broadcastRaw({
48
+ type: "feed",
49
+ text: msg.text,
50
+ priority: "normal",
51
+ ts: Date.now(),
52
+ channel: "agent",
53
+ sender: "user",
54
+ } as any);
55
+ // Show thinking indicator
56
+ wsHandler.broadcastRaw({ type: "thinking", active: true } as any);
46
57
  deps.onUserCommand(msg.text);
47
58
  break;
48
59
  }
@@ -11,6 +11,7 @@ import type {
11
11
  FeedChannel,
12
12
  } from "../types.js";
13
13
  import { log, warn } from "../log.js";
14
+ import { loadedEnvPath } from "../config.js";
14
15
 
15
16
  const TAG = "ws";
16
17
  const HEARTBEAT_INTERVAL_MS = 10_000;
@@ -135,13 +136,14 @@ export class WsHandler {
135
136
 
136
137
  /** Send a status update to all connected overlays. */
137
138
  broadcastStatus(): void {
138
- const msg: StatusMessage = {
139
+ const msg: StatusMessage & { envPath?: string } = {
139
140
  type: "status",
140
141
  audio: this.state.audio,
141
142
  mic: this.state.mic,
142
143
  screen: this.state.screen,
143
144
  connection: this.state.connection,
144
145
  };
146
+ if (loadedEnvPath) msg.envPath = loadedEnvPath;
145
147
  this.broadcastMessage(msg);
146
148
  }
147
149