@geravant/sinain 1.6.8 → 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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.6.8",
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
@@ -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